@font-face{font-family:'Milling';src:url('fonts/MillingTriplexTrialVAR.ttf') format('truetype-variations');
  font-weight:100 500;font-style:normal;font-display:block}
@font-face{font-family:'Milling Simplex';src:url('fonts/MillingSimplexTrialVAR.ttf') format('truetype-variations');
  font-weight:50 100;font-style:normal;font-display:block}
@font-face{font-family:'Milling Simplex';src:url('fonts/MillingSimplexTrialVAR-Italic.ttf') format('truetype-variations');
  font-weight:50 100;font-style:italic;font-display:block}
:root{
  /* the page margin. On :root, not #page, because the grid button sits
     outside that box and aligns to the same edge. */
  --edge:6px;
  --bg:#fafafa; --panel:#fafafa; --ink:#0e0e0e; --dim:#8b8b88;
  --ghost:#e1e2f2; --accent:#4a2fd0;
  --tri:'Milling','Helvetica Neue',Arial,sans-serif;      /* Triplex, 100=1mm .. 500=5mm */
  --sim:'Milling Simplex','Milling',monospace;            /* Simplex, 50=0,5mm .. 100=1mm */
  --sans:var(--sim);
  --mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;  /* all-caps labels */
  /* The three text columns, shared by every caption row, the footer and the
     overlay. Tuned so the tag column starts at ~65.8% and the season at ~79.4%
     of the page — where the footer's links have always sat. */
  /* Columns 3-6, 7, and 8-9 of the page grid, written as a share of the row's
     own width (7c + 6g) so they hold at any size: (4c+3g)/W, g/W, c/W. */
  --cap-cols:56.86% 13.71% 13.71% 1fr;   /* c3-c6 / c7 / c8 / c9 */
  --cap-gap:0.6657%;
  /* The grid the overlay draws: equal columns with a 0.46% gutter, inside the
     --edge margins. [ and ] change the count live. Worth knowing: the layout's
     21.82% / 0.46% / 77.72% split is exactly 2 + 7 of a NINE column grid; on
     twelve the sidebar edge falls between bands. */
  --grid-cols:9;
  --grid-gutter:0.517;  /* % of the content width = 7.4px at 1440 */
  /* The same three numbers again, as lengths rather than as figures for the
     overlay to draw with, so a row can be built out of the grid instead of
     merely checked against it. Worked out the way grid.js works them out:
     the page's content box, one gutter of --grid-gutter, nine columns. The
     nine is written in because the layout is — 21.82/77.66 is 2 + 7 of it —
     so [ and ] still move the bars without moving the page. */
  --gw:calc(100vw - var(--sbw) - var(--edge) * 2);
  --ggut:calc(var(--gw) * 0.00517);
  --gcol:calc((var(--gw) - 8 * var(--ggut)) / 9);
}
*{box-sizing:border-box}
/* overscroll-behavior:none kills the elastic bounce past the top and bottom,
   which otherwise pulls the page away and shows bare root background. */
html{overflow-x:clip;overscroll-behavior:none;--sbw:0px}   /* --sbw: width of a classic scrollbar, measured below */
body{overscroll-behavior:none}
.nb{white-space:nowrap}
/* hand-set breaks for the rag; the measure changes on small screens, so they
   only apply where the rail keeps its width */
@media (max-width:1260px){ br.rag{display:none} }
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sim);font-weight:100;
  -webkit-font-smoothing:antialiased}
/* A selection is the one thing on the page the browser colours for us, and its
   blue belongs to no palette here. Washed in the accent instead, at a strength
   that leaves the ink on top readable. */
::selection{background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--ink)}

/* Figma frame: 1728 wide - sidebar 377, gutter 8, content 1337 (3 x 439 + 8) */
/* --gutter is the rail-to-content gap; --edge is the page margin. One number
   each, so either can be tuned without disturbing the other. */
/* the filter slides whole projects in from the right; without this their travel
   would hand the window a horizontal scrollbar for a third of a second. clip,
   not hidden: hidden would make this a scroll container and strand the sticky
   Directory inside it. */
#page{overflow-x:clip;position:relative;z-index:1;
  /* 2 + 7 of the nine-column grid: c = (100 - 8g)/9 with g = 0.517% */
  /* No max-width: the margin is --edge at every size. Capping the page meant
     the margin grew with the window (131px at 90% zoom, 336px at 80%), which
     read as the layout breaking rather than as a centred column. */
  --gutter:0.517%;margin:0 auto;padding:var(--edge) var(--edge) 90px;
  /* pull a child out to the true right edge of the window */
  --bleed:calc(-1 * var(--edge));          /* the page already reaches the edge */
  /* the grid gutter as a length. A percentage row-gap resolves against the
     block axis, which is indefinite here, so it silently became zero. */
  --gutpx:calc((100vw - var(--sbw) - var(--edge) * 2) * 0.00517);
  display:grid;grid-template-columns:21.8202% 77.6632%;column-gap:var(--gutter);align-items:start}
@media (max-width:1260px){ #page{grid-template-columns:1fr} }

/* ---------- sidebar ---------- */
/* The rail rides the page rather than being pinned whole: the head holds the
   top, the bio scrolls away under it, and the directory parks 36px below the
   nav and stays there while the work column keeps going. */
/* min-width:0 — a grid item defaults to min-width:auto and refuses to shrink
   below its content's min-content, which is how the rail blew past its track in
   the one-column layout. */
#rail{align-self:stretch;min-width:0;scrollbar-width:none}
#col{min-width:0}
#rail::-webkit-scrollbar{width:0;height:0}
.head{position:sticky;top:0;z-index:5;background:var(--bg);
  display:flex;justify-content:space-between;align-items:baseline;
  margin:calc(-1 * var(--edge)) 0 16px;padding:var(--edge) 0 8px}
.head .mark{font-family:var(--tri);font-weight:180;font-size:12px;letter-spacing:0;
  color:var(--ink);text-decoration:none;cursor:pointer}
.head .mark:hover{color:var(--accent)}
/* the nav is column 2 exactly: c/W of the page = 48.816% of the rail, spread
   so 'work' starts on the column's left line and 'about' ends on its right */
/* Column 2 holds the nav, the filter row and the note on the paper, and all
   three run to its right line — which is the work column's left line. The air
   between the two columns is set here rather than in the grid: the tracks stay
   on the nine, and the content inside column 2 stops short of its edge. */
:root{--col2-pad:13px}
.head nav,.filters{padding-right:var(--col2-pad)}
.head nav{display:flex;justify-content:space-between;gap:0;width:48.816%}
.head nav a{color:var(--ink);text-decoration:none;font-family:var(--tri);font-weight:180;font-size:12px}
.head nav a:hover,.head nav a.here{color:var(--accent)}

/* the whole bio sits in column 1 — 48.816% of the rail is c against the grid */
.bio{width:48.816%}
/* clamped by fitBio to the paper left above the Directory: whole lines, the
   browser's own ellipsis, and the bio's spans left intact */
.bio p.clip{display:-webkit-box;-webkit-box-orient:vertical;overflow:hidden}
#rail p{margin:0 0 13px;font-size:11px;line-height:1.58;letter-spacing:-0.012em;max-width:100%;
  text-wrap:pretty;hyphens:none}
/* balance, not pretty: pretty only protects the last line, and at four lines in
   a narrow column the whole rag is the problem — balance evens every line. */
/* The bio runs a step larger than the rest of the rail: it is the one block of
   the page anyone actually reads, and at the list's size it sat too quiet.
   #rail-prefixed because #rail p above is an ID, and a bare .bio p loses to it
   — the same trap that kept text-wrap:balance from applying at all. */
#rail .bio p{font-size:12px;line-height:1.5;text-wrap:balance}
#rail .bio a.more{font-size:12px}
#rail a.more{color:var(--dim);font-size:11px;letter-spacing:-0.012em;text-decoration:none}
#rail a.more:hover{color:var(--accent)}
h2.k{margin:0 0 3px;font-family:var(--mono);font-size:10.5px;font-weight:400;letter-spacing:-0.01em;
  text-transform:uppercase;color:var(--accent)}
.block{margin-top:26px;padding-left:8px}
#rail ul{margin:0;padding-left:11px;list-style:none}
#rail li{font-size:11px;line-height:1.5;letter-spacing:-0.012em;margin-bottom:4px;position:relative;max-width:31ch;
  text-wrap:pretty;hyphens:none}
#rail li:before{content:'\00b7';position:absolute;left:-11px}
#rail li i{font-style:normal;color:var(--dim)}

/* ---------- directory: top-aligned with the work grid ---------- */
#dir{position:sticky;top:var(--stick,58px);z-index:4;background:var(--bg);margin-top:64px;
  max-height:calc(100vh - var(--stick,58px) - var(--edge));overflow-y:auto;
  overscroll-behavior:contain;scrollbar-width:none}
#dir::-webkit-scrollbar{width:0;height:0}
@media (max-width:1260px){ #dir{position:static;max-height:none;overflow:visible} }
/* the label holds column 1 plus the gutter, so the filters start on column 2
   and then just sit at their own spacing */
.dirhead{display:flex;flex-wrap:nowrap;align-items:baseline;gap:0;margin-bottom:12px}
.dirhead .lab{flex:none;width:51.184%}
.dirhead .lab{font-family:var(--mono);font-size:10.5px;letter-spacing:-0.01em;text-transform:uppercase;color:var(--accent);
  text-decoration:none}
.filters{display:flex;justify-content:space-between;gap:0;width:48.816%}  /* column 2 */
/* the season's face, not the mono: Milling Simplex at its lighter weight */
.filters a{font-family:var(--sim);font-weight:100;color:var(--dim);font-size:10.5px;
  letter-spacing:-0.012em;white-space:nowrap;
  text-decoration:none;cursor:pointer}
.filters a[data-on="1"],.filters a:hover{color:var(--accent)}
/* Odometer: each digit is a 0-9 strip, ten characters tall, slid by whole
   characters. Rolling beats retyping — the count reads as a count, not a swap. */
.dirhead .tick{display:inline-flex;gap:1px;margin-left:5px;
  line-height:1;height:1em;overflow:hidden}
.tick i{display:block;height:1em;overflow:hidden}
.tick i span{display:block;transition:transform .55s cubic-bezier(.22,1,.36,1)}
.tick i b{display:block;height:1em;font-weight:400;font-style:normal}
@media (prefers-reduced-motion:reduce){ .tick i span{transition:none} }
#rail .dirlist{margin:0;padding:0;list-style:none}
#rail .dirlist li:before{content:none}
#rail .dirlist li[hidden]{display:none}   /* display:flex below would win otherwise */
#rail .dirlist li{display:flex;justify-content:space-between;gap:12px;align-items:baseline;
  padding:8px 0;border-bottom:1px solid #f0efed;font-size:11px;letter-spacing:-0.012em;max-width:none;
  margin:0;cursor:pointer}
.dirlist li b{font-weight:150;font-family:var(--tri)}
/* the tag, not a date: mono, uppercase, grey, and only on the hovered row */
.dirlist li em{font-style:normal;white-space:nowrap;color:var(--dim);
  font-family:var(--mono);font-size:10.5px;letter-spacing:-0.01em;text-transform:uppercase;
  opacity:0;transition:opacity .18s linear}
/* focus-visible, not focus-within: a clicked row keeps focus, and its tag stayed up after the click */
.dirlist li:hover em,.dirlist li:focus-visible em,.dirlist li:has(:focus-visible) em{opacity:1}
@media (hover:none){ .dirlist li em{opacity:1} }
.dirlist li:hover b,.dirlist li:hover em{color:var(--accent)}

/* ---------- hero: 642 / 1728 of the page width ---------- */
#hero{position:relative;height:clamp(400px,37.1vw,700px);overflow:hidden;margin-bottom:8px;
  margin-top:calc(-8px - var(--edge));margin-right:var(--bleed)}
/* No pointer surface of its own. Every handler the paper has — hover, click,
   drop — is on the window and hit-tests the sheet buffer by coordinate, so the
   canvas never needed to be a target; and it is masked out over its bottom
   fifth, where it sits on top of the first strip. That invisible band was
   swallowing the hover on the lead tile. */
#plate{position:absolute;inset:0;width:100%;height:100%;z-index:3;display:block;
  pointer-events:none;
  /* Comes up rather than switches on. Between the first frame and the last
     texture the sheets are printed with the stand-in drawing, and on a slow
     machine or a cold cache that is long enough to watch. The fade covers it
     without holding the fall back — the paper is already moving underneath. */
  opacity:0;transition:opacity .6s ease-out;
  mix-blend-mode:var(--blend,multiply);
  -webkit-mask-image:linear-gradient(to bottom,#000 78%,transparent 100%);
  mask-image:linear-gradient(to bottom,#000 78%,transparent 100%)}
#plate.lit{opacity:1}
#ghosts{position:absolute;inset:0;z-index:1;pointer-events:none;overflow:hidden}
/* the ghost words come up out of a blur. On the box, not the words: the words
   are rebuilt on every layout pass and would blur in again on each resize. */
#ghosts{animation:ghostin 1.2s cubic-bezier(.2,.6,.2,1) both}
@keyframes ghostin{from{opacity:0;filter:blur(16px)}}
@media (prefers-reduced-motion:reduce){ #ghosts{animation:none} }
#ghosts span,#name{position:absolute;font-family:var(--tri);font-weight:150;
  font-size:clamp(28px,3.24vw,56px);letter-spacing:-0.03em;line-height:1.2;
  white-space:nowrap;user-select:none}
/* The ghost words keep the old weight: set in --ghost they are already pale,
   and lightening the drawing as well left them thin rather than quiet. Only
   the ink type came down to 150. This has to sit after the rule above, not
   before it — the two selectors weigh the same, so source order decides. */
#ghosts span{color:var(--ghost);font-weight:200}
/* the asterisk sits against the word it marks, in the hero and on the sheet */
#ghosts .ast,#splash .reel .ast{font-style:normal;letter-spacing:-0.13em}
/* a touch tighter than the ghosts it shares a size with: the name is set solid
   and at this weight the default -0.03em left it reading loose against them */
#name{z-index:2;margin:0;color:var(--ink);pointer-events:none;visibility:hidden;
  font-weight:180;letter-spacing:-0.052em}
/* EAN pulled in behind the J, which stands on its own stem and needs the room,
   and AN closer still — the diagonal of the A lets the N sit nearer than the E
   can. Nested, so each span only tightens what follows its own letters. */
#name .tt,#splash .ink .tt{letter-spacing:-0.086em}
#name .tt2,#splash .ink .tt2{letter-spacing:-0.102em}
#name.set{visibility:visible}
/* the name's own box, so it can be moved without moving the h1 the ghosts are
   measured against */
#name i,#splash .ink i{display:inline-block;font-style:normal}
/* sun dial. --v is the filled fraction and --lit the raw value, both set from JS. */
/* The Directory is aligned to this, and the work column to the Directory, so
   the dial's line is what decides how far down the page the work begins. It is
   the lever to use for that: the hero's height would do it too, but the plate's
   vertical extent is fixed, so shortening the hero shrinks the paper itself. */
/* The dial is set in the paper's purple rather than the page's accent: it is a
   control for the light falling on the sheets, and it reads as part of them.
   --dial is the same ink the cursor's dot carries. */
#dial{position:absolute;z-index:5;left:71.5%;top:75.5%;display:flex;align-items:center;gap:9px;
  width:160px;--v:20%;--lit:0.2;--dial:#9b8aec}
#dial svg{width:15px;height:15px;color:var(--dial);flex:none;
  opacity:calc(0.55 + 0.45*var(--lit));transition:opacity .18s linear}
#dial input{-webkit-appearance:none;appearance:none;width:128px;height:14px;
  background:none;cursor:ew-resize}
#dial input:focus{outline:none}
/* The handle is the same dot the cursor is, and it blends the same way — no
   ring, no shadow; the ring was the only white shape on the page. The filled
   length stays, on a thinner rule than before: it is what says how far the sun
   has been carried, and the dot alone would not. */
#dial input::-webkit-slider-runnable-track{height:2px;border-radius:99px;
  background:linear-gradient(90deg,var(--dial) 0 var(--v),#e4e3e1 var(--v) 100%)}
#dial input::-webkit-slider-thumb{-webkit-appearance:none;width:11px;height:11px;
  border-radius:99px;background:var(--dial);border:0;mix-blend-mode:multiply;
  margin-top:-4.5px;                                 /* (track 2 - thumb 11) / 2 */
  transition:transform .16s cubic-bezier(.22,1,.36,1)}
#dial input:hover::-webkit-slider-thumb{transform:scale(1.14)}
#dial input:active::-webkit-slider-thumb{transform:scale(.94)}
/* focus is the one case that needs a mark of its own, since there is no ring */
#dial input:focus-visible::-webkit-slider-thumb{
  box-shadow:0 0 0 3px rgba(74,47,208,.22)}
#dial input::-moz-range-track{height:2px;border-radius:99px;background:#e4e3e1}
#dial input::-moz-range-progress{height:2px;border-radius:99px;background:var(--dial)}
#dial input::-moz-range-thumb{width:11px;height:11px;box-sizing:border-box;
  border-radius:99px;background:var(--dial);border:0;mix-blend-mode:multiply}

/* ---------- work: one horizontal strip, bleeding off the right edge ----------
   Tiles are sized from a shared height, so every card lines up on both edges
   whatever its aspect ratio. --sh is the media height; captions sit below it. */
#col{--sh:clamp(300px,min(44vw,62vh),660px)}
.strip{display:flex;align-items:flex-start;gap:8px;
  margin-right:var(--bleed);padding-right:var(--edge);
  /* x only: with one axis clipped the other computes to auto, which let the
     row scroll vertically inside itself */
  overflow-x:auto;overflow-y:hidden;
  overscroll-behavior-x:contain;scrollbar-width:none;cursor:grab;
  /* Not on .drag: that class only lands after the pointer has travelled its
     4px of slop, by which time the browser has already started a selection and
     painted it. The row is pictures and links, nothing anyone reads, so it
     simply never takes one. */
  user-select:none;-webkit-user-select:none}
.strip::-webkit-scrollbar{width:0;height:0}
.strip.drag{cursor:grabbing}
.tile{flex:none;display:flex;flex-direction:column;gap:8px}
.tile.stack .card{display:flex;flex-direction:column;gap:8px}
/* auto columns, not 1fr: each cell takes the width its height and ratio give it */
.tile.quad .card{display:grid;grid-template-columns:auto auto;gap:8px}
.tile.quad .rect{height:calc((var(--sh) - 8px) / 2)}
/* Everything that isn't the current project stacks under the strip instead of
   extending it — the scroller is one project's set, not the whole portfolio. */
.stackcol{display:flex;flex-direction:column;gap:22px;margin:22px var(--bleed) 0 0}
.stackcol .tile{width:100%}
.stackcol .rect{width:100%;aspect-ratio:auto}

/* --tile-scale lets one card sit shorter than the rest of its row */
.rect{position:relative;background:#eeedeb;overflow:hidden;
  height:calc(var(--sh) * var(--tile-scale,1));aspect-ratio:16/9}
.tile.stack .rect{height:calc((var(--sh) - 8px) / 2)}
.rect img,.rect video{position:absolute;inset:0;width:100%;height:100%;
  object-fit:cover;display:block;
  /* a picture is draggable by default, and that drag is the browser's, not the
     strip's: it lifts a ghost of the image and leaves the pan behind */
  -webkit-user-drag:none;user-select:none}
/* ---------- a row measured in columns instead of in height ----------
   The strip's unit is height: every card comes out the same height and the
   widths fall where their own shapes put them, which is what lines a row of
   mismatched pictures up along its top and bottom. A project whose row is
   meant to meet the overlay asks for .cols instead, and the widths become
   whole column spans off the arithmetic above — same content box, same
   gutter, same nine columns — so the edges land on the bars rather than near
   them. The gap between the cards is the grid's gutter too: at the strip's
   own 8px the second card would start a few pixels past the line it is
   aimed at. Heights are no longer set, so they follow from the spans, and it
   is on whoever picks the spans to pick two that come out level. */
.strip.cols{gap:var(--ggut);
  /* The row's height, which is whatever the lead tile's span makes of it: two
     16:9 pictures and the 8px between them. Stated here rather than left to
     fall out of each card's own shape, because no whole span of the reel
     beside it comes out level — 6 columns is 2px over at this size and 7px
     over at a wide one, and the strip's one promise is that a row ends on one
     line. */
  --leadw:calc(var(--lead,2) * var(--gcol) + (var(--lead,2) - 1) * var(--ggut));
  --rowh:calc(var(--leadw) * 9 / 16 * 2 + 8px)}
.strip.cols .tile{--w:calc(var(--span,1) * var(--gcol)
                           + (var(--span,1) - 1) * var(--ggut))}
.strip.cols .tile.lead{--span:var(--lead,2)}
.strip.cols .rect,.strip.cols .tile.stack .rect{width:var(--w);height:auto}
/* Set to the row instead of to its own shape. The picture takes up the
   difference through cover, which at the spans this is used at is a fraction
   of a percent off the top and bottom — the alternative is a card that ends a
   few pixels below its neighbour. */
.strip.cols .tile.fill .rect{width:var(--w);height:var(--rowh)}

/* shown whole, never cropped — any leftover box is just background */
.rect.fit img{object-fit:contain}
/* Cover crops evenly from both edges, which is the right default but the wrong
   one for a screen recording: the browser chrome and the site's own header are
   at the top and nothing worth keeping is. Pinning the foot spends the whole
   overflow on the top edge, so the box's ratio is the crop — no re-encode, the
   file on disk stays the export it came as. */
.rect.foot img,.rect.foot video{object-position:50% 100%}
.card{cursor:pointer}
.card:hover .rect{background:#e6e5e2}
/* Hovering washes the work back toward the paper it sits on, so the pointer
   reads as pointing at something rather than at nothing. The media fades, not
   the box: what shows through is .rect's own ground, which is the same grey the
   background above steps to — one gesture, not two.
   The unit is the project, not the picture: a strip is one project's whole set,
   so hovering any piece of it fades the entire row together — the pieces of a
   project answer as one. Cards outside a strip (the play grid, the figures in a
   case) are each their own piece, so those still answer alone.
   Pointer devices only; on a touch screen the state would stick to whatever was
   tapped last. */
@media (hover:hover){
  .rect img,.rect video{transition:opacity .4s cubic-bezier(.22,1,.36,1)}
  .strip[data-cur]:not([data-cur=""]):hover .rect img,.strip[data-cur]:not([data-cur=""]):hover .rect video{opacity:.62}
}
/* Only a project with somewhere to go, which is the same thing data-cur marks
   for the cursor: a row that has one answers the pointer, a row whose label
   still reads "coming soon" does not, because the fade is the picture
   acknowledging a click it is about to hand on and that click goes nowhere
   yet. So a project lights up the moment its page is worth opening, and the
   two signals cannot drift apart — there is only the one attribute.

   The same reasoning takes it off the cards that are not projects. A play
   grid piece or a figure in a case study opens itself in the lightbox rather
   than handing the click anywhere, and dimming a picture a moment before
   showing it larger reads as switching it off. Those say it with the pointer
   instead — see the glass below. */
@media (prefers-reduced-motion:reduce){ .rect img,.rect video{transition:none} }
/* Two caption forms: a titled line under a piece, and a tag/date rule under a
   set, ruled to the tile's own edges the way the directory rows are. */
.cap{font-size:12px;line-height:1.45;letter-spacing:-0.02em;color:var(--ink);margin:1px 0 6px}
.cap b{font-family:var(--tri);font-weight:150;color:var(--ink)}
/* a credit takes the last column, in the tags' voice */
.cap.cred{font-family:var(--mono);font-size:10.5px;letter-spacing:-0.01em;
  text-transform:uppercase;color:var(--dim)}
.cap.meta{font-family:var(--mono);font-size:10.5px;letter-spacing:-0.01em;
  text-transform:uppercase;color:var(--accent)}
/* The season is set exactly like a project description — Simplex, not Triplex.
   Both faces sit at weight 100, but Triplex draws every stroke three times, so
   at its lightest it still reads heavier; matching the weight means matching
   the face. Flush to the start of its column, under the footer's second pair. */
.cap.meta.end{font-family:var(--sim);font-weight:100;font-size:12px;
  letter-spacing:-0.02em;text-transform:none;color:var(--ink)}
/* The copy sits under the scroller rather than inside it, so nothing in it
   moves when the pieces are dragged: the project's line on the left, its tag
   and date ruled to the right edge of the page. */
/* ---------- filter swap ----------
   Filtering rearranges the reel rather than replacing it. Three parts, and the
   classes exist only while one is running:
     .gone  — a project the filter drops, collapsing where it stands
     .flip  — a survivor, held at the position it used to occupy
     .enter — an arrival, waiting just under its place
   .swap is the transition itself, added a frame after .flip/.enter so the
   offset is taken instantly and only the travel is animated. That is what makes
   a project that stays put visibly slide from its old row to its new one
   instead of cutting there. */
.swap{transition:transform .46s cubic-bezier(.22,1,.36,1),opacity .3s linear;
  transition-delay:var(--swd,0ms);will-change:transform}
.gone{opacity:0;transform:scale(.97);
  transition:opacity .18s linear,transform .18s cubic-bezier(.4,0,1,1)}
.enter{opacity:0;transform:translateY(18px)}
/* The same three states at the list's scale. A directory row is 34px, so the
   18px an arriving project rises through is half a row here, and the .97 a
   dropped one shrinks by reads as the type going soft rather than as the row
   leaving — rows go on the fade alone. */
.dirlist li.enter{transform:translateY(8px)}
.dirlist li.gone{transform:none}
@media (prefers-reduced-motion:reduce){
  .swap,.gone,.enter{transition:none;transform:none;opacity:1} }

/* Two reasons a project can be off the page, and they must not fight: [hidden]
   is the filter's, .over is applyOverflow's — everything past the seventh of
   whatever the filter left. Both have to beat the display the strip and its
   caption row carry by default. */
.strip[hidden],.caprow[hidden],
.strip.over,.caprow.over{display:none}
.strip + .caprow + .strip{margin-top:22px}   /* one project clear of the next */
/* the work behind the button is wrapped in #more, which breaks that chain:
   the wrapper carries the gap instead, so a revealed project sits as clear
   of the one above it as every other pair does */
.strip + .caprow + #more{margin-top:22px}
.caprow{display:grid;grid-template-columns:var(--cap-cols);align-items:baseline;gap:var(--cap-gap);
  margin:8px var(--bleed) 0 0;padding-right:var(--edge)}
.stackcol .caprow{margin-right:0}


/* ---------- see more ----------
   The rest of the work, behind one line. Ruled to the same edges as a caption
   row, so it reads as the last row of the reel rather than as a control that
   has been dropped underneath it. */
.morerow{margin:26px var(--bleed) 0 0;padding:14px var(--edge) 0 0;
  border-top:1px solid #e6e5e2}
#morebtn{display:flex;align-items:baseline;gap:9px;
  background:none;border:0;padding:0;
  font-family:var(--mono);font-size:10.5px;font-weight:400;letter-spacing:-0.01em;
  text-transform:uppercase;color:var(--accent);cursor:pointer}
#morebtn .arw{display:inline-block;
  transition:transform .3s cubic-bezier(.22,1,.36,1)}
#morebtn:hover .arw{transform:translateY(2px)}
#morebtn[aria-expanded="true"] .arw{transform:rotate(180deg)}
#morebtn[aria-expanded="true"]:hover .arw{transform:rotate(180deg) translateY(2px)}
@media (prefers-reduced-motion:reduce){ #morebtn .arw{transition:none} }
@media (max-width:1260px){ .morerow{margin-right:0;padding-right:0} }

/* ---------- reveal: pieces arrive on a short fade out of focus ----------
   Opacity alone reads as a dip in brightness; the defocus is what makes a card
   feel like it settles into place. Both classes are dropped the moment the
   transition ends, so nothing keeps a filter — a lingering one makes every
   revealed box a containing block and holds a composite layer for the rest of
   the session. Transforms are safe here because the alignment passes measure
   the strip and the directory, neither of which is ever revealed. */
.rv{opacity:0;filter:blur(9px);transform:translateY(12px)}
.rv.in{opacity:1;filter:blur(0);transform:none;
  transition:opacity .62s cubic-bezier(.22,1,.36,1),
             filter .62s cubic-bezier(.22,1,.36,1),
             transform .62s cubic-bezier(.22,1,.36,1);
  transition-delay:var(--rvd,0ms)}
/* the same arrival without the rise, for boxes an alignment pass measures: the
   dial, the directory's header and its rows are all read by getBoundingClientRect
   while this is still running, and a transform would hand those passes a box in
   mid-flight. Opacity and blur move nothing. */
.rvf{opacity:0;filter:blur(6px)}
.rvf.in{opacity:1;filter:blur(0);
  transition:opacity .62s cubic-bezier(.22,1,.36,1),
             filter .62s cubic-bezier(.22,1,.36,1);
  transition-delay:var(--rvd,0ms)}
/* See less: the same arrival played backwards, so the work goes back behind
   the button the way it came out rather than vanishing */
.rvout{opacity:0;filter:blur(9px);transform:translateY(12px);
  transition:opacity .42s cubic-bezier(.4,0,.7,.2),
             filter .42s cubic-bezier(.4,0,.7,.2),
             transform .42s cubic-bezier(.4,0,.7,.2);
  transition-delay:var(--rvd,0ms)}
@media (prefers-reduced-motion:reduce){ .rv,.rv.in,.rvf,.rvf.in,.rvout{opacity:1;filter:none;transform:none;transition:none} }

/* ---------- footer: paper on the left, ghost type on the right ----------
   Everything is placed in percentages of the footer box so the composition
   holds its proportions at every window size. */
/* Sits in the main column and bleeds right only, like the hero and the strip
   — its left edge is the column's, so the paper clips there and never reaches
   the sidebar. Type scales in cqw, i.e. against the footer, not the window. */
#foot{grid-column:1/-1;position:relative;overflow:hidden;isolation:isolate;
  container-type:inline-size;
  margin:96px var(--bleed) -90px;          /* cancels the page's bottom padding */
  aspect-ratio:1352 / 303;min-height:200px;   /* the paper runs past it */
  --frow-y:66%;                        /* the date and the sign-off start here */
  --sign:clamp(22px,3.0cqw,52px)}      /* wordmark and sign-off share a size */            /* the date and the sign-off start on this line */
/* The footer's paper is a second view of the same renderer, so it is one
   canvas like the hero's — clipped by the footer, hover and all. */
/* One fixed shape (the work page's hero, 77.66vw x 37.1vw), filled by
   object-fit, so the paper is the same size on every page whatever hero the
   page draws it from — app.js lays the sheets out on this same aspect. */
#foot .fplate{position:absolute;left:-2%;top:-2%;width:54%;height:auto;
  aspect-ratio:2.093;object-fit:cover;
  z-index:1;opacity:.94;mix-blend-mode:var(--blend,multiply)}
/* --bleed is how far the footer reaches past the page; negating it lands the
   mark on the page's own left margin, under the one in the header. */
/* clear of the footer's top edge: at -1% the overflow clipped its ascenders */
#foot .fmark{position:absolute;left:calc(-1 * var(--bleed));top:2%;z-index:0;
  font-family:var(--tri);font-weight:200;letter-spacing:-0.03em;
  font-size:var(--sign);line-height:1.04;color:var(--ghost);
  white-space:nowrap;user-select:none}
/* column 7 — the same line as the links and the tags */
/* Off the floor, not down from the ceiling. At --frow-y it was a share of the
   footer's height, so where it landed depended on how tall the footer happened
   to be — at one width that put its baseline exactly on the bottom edge, a
   pixel from being clipped. Clamped so the gap reads the same whether the
   footer is 200px tall or 430. */
#foot .fsay{position:absolute;z-index:0;margin:0;
  top:auto;bottom:clamp(14px,6%,26px);
  left:calc(var(--edge) + (100% - var(--edge) * 2) * 0.670103);
  font-family:var(--tri);font-weight:200;letter-spacing:-0.03em;
  font-size:var(--sign);line-height:1.04;color:var(--ghost);
  white-space:nowrap;user-select:none}
/* The footer's text sits on the same three columns as a project's caption row:
   the same left edge as the work column, the same 1fr / 19% / 10% split, the
   same 12px of end padding. So the date lines up under the project titles, the
   first pair of links under the tag, the second under the season. */
#foot .frow{position:absolute;z-index:2;
  left:calc(var(--edge) + (100% - var(--edge) * 2) * 0.223368);
  right:var(--edge);
  display:grid;grid-template-columns:var(--cap-cols);align-items:baseline;gap:var(--cap-gap)}
/* The link pairs may be wider than the columns they sit under — the type is
   larger than it was, and a fallback face is wider again — so their tracks are
   given a floor rather than a fixed width: exactly the tag and season columns
   while they fit, growing past them rather than running into each other when
   they do not. The row is the only one whose cells carry more than a few
   characters, which is why it is the only one that needs this. */
#foot .frow.links{top:6%;
  grid-template-columns:56.86% minmax(13.71%,auto) minmax(13.71%,auto) 1fr}
#foot .frow.meta{top:var(--frow-y)}
#foot .fmeta{margin:0;font-family:var(--mono);font-size:10.5px;line-height:1.5;
  letter-spacing:-0.01em;color:var(--ink)}
#foot .flinks{margin:0;display:flex;gap:10px}
/* Set like the season on a caption row, not like a label: Simplex at weight
   100, the same face and size as "Winter 2025" sitting in the column above. */
#foot .flinks a{font-family:var(--sim);font-weight:100;font-size:12px;letter-spacing:-0.02em;
  color:var(--ink);text-decoration:none}
#foot .flinks a:hover{color:var(--accent)}

/* The phone keeps the same four corners; only the box changes. At the wide
   footer's proportions a 430px screen gets 96px of height, which is not enough
   to put anything in a corner — so the box goes square, the type is set against
   the container rather than the window, and the paper is pulled in behind it.
   Stacking it into rows, which is what this used to do, threw the composition
   away at every width below a laptop. */
@media (max-width:820px){
  /* Tall enough for four corners and no taller. Height, not a ratio: a ratio is
     a share of the width, so what reads right on a phone had grown to 541px by
     the time the layout was about to change back — most of it empty paper
     between the date and the sign-off. Clamped instead, so it settles. */
  /* three bands, so nothing sits on anything else: the mark and the links
     along the top, the paper under them, then the date and the sign-off each
     on a line of their own at the foot */
  #foot{aspect-ratio:auto;height:clamp(340px,90vw,400px);min-height:0;
    --sign:clamp(19px,7.2cqw,32px);--frow-y:65%}
  /* nearly the full width: the canvas is the hero's shape, so the sheets come
     out sized by it, and at 60% they were scraps */
  #foot .fplate{left:-6%;top:4%;width:96%;aspect-ratio:1.3}   /* the work hero's shape on a phone */
  /* both rows span the page here: 22% of indent is a column that is not there */
  #foot .frow{left:var(--edge);right:var(--edge)}
  /* and the sign-off hangs off the right margin rather than starting on column
     7: it is two unbroken lines, and from 67% of a phone there is not the width
     left to set them — they ran out past the edge and were clipped */
  #foot .fsay{left:auto;right:var(--edge);text-align:right}
  /* the links on column 8, as the season is on a caption row: two pairs, two rows */
  #foot .frow.links{grid-template-columns:calc(var(--gcol) * 7 + var(--ggut) * 7) 1fr;gap:3px 0}
  #foot .frow.links .flinks{grid-column:2;gap:8px;white-space:nowrap}
  /* the second pair drops to the date's line, top-aligned with it: the row is
     stretched down to --frow-y so the pair can hang off its bottom edge, on
     the same column as the first pair */
  #foot .frow.links{bottom:calc(100% - var(--frow-y));align-content:start}
  /* left:0 is column 2's edge, not the row's: an absolute child of a grid
     with grid-column set is placed in that column's area */
  #foot .frow.links .flinks:last-child{position:absolute;top:100%;left:0;
    line-height:1.2}
  /* Moving Image Archive's lead is three columns on the wide page, which on a
     phone is a thumbnail. Five and a half here, and the reel beside it widened to keep
     its 16:9 at the taller row — the strip scrolls to it. */
  .strip.cols[data-sec="mia"]{--lead:5.5 !important}
  .strip.cols[data-sec="mia"] .tile.fill{--span:11 !important}
}

/* ---------- sun: the plate's own dappled light, spilling into the corner ----
   Same shader, same clock as the hero — see SUN_GLSL. Multiplied over the page
   so the canopy reads as warm shade on paper rather than a glow laid on top. */
/* z-index 2 puts it over #page: light lands ON the nav and the copy, it does
   not sit behind them. Multiply keeps it reading as shade, not a scrim. */
#sunpatch{position:fixed;left:0;top:0;z-index:2;pointer-events:none;
  width:min(40vw,520px);height:min(40vw,520px);mix-blend-mode:multiply}

/* ---------- alignment grid ----------
   Drawn from measurements of the real boxes, not from a second copy of the
   layout's numbers: replicating them in a parallel stylesheet put the rules a
   few px off and the error compounded across the columns. Bars are painted at
   whatever #page, #rail, #col and a caption row actually report. */
/* ---------- the opening reel ----------
   A column of the words the page is built out of, set in the hero's own ghost
   type at the hero's own weight, ruled to a right edge that is past the window
   so the words run off it. It rises and slows into *creative practice, holds, comes to
   rest on the name, and lifts. */
/* The sheet is drawn off the top, carrying its type with it — the reel has been
   travelling upward the whole time, so it leaves the way it came. A curtain,
   not a dissolve: nothing has to be measured against anything, and there is no
   moment where two of the same word are on screen at different opacities. */
#splash{position:fixed;inset:0;z-index:20;background:var(--bg);overflow:hidden;
  transition:transform .86s cubic-bezier(.66,0,.18,1)}
#splash.up{transform:translateY(-101%);pointer-events:none}
/* The column lands on the page's own right margin rather than hanging past it.
   Bled 1.2vw off the edge before, which took a slice off the last letter of
   every word; --edge is where the page itself stops, and the mark opposite is
   already hung off the same number. */
#splash .win{position:absolute;left:0;right:var(--edge);top:0;bottom:0;overflow:hidden}
#splash .reel{position:absolute;left:0;right:0;top:50%;
  display:flex;flex-direction:column;align-items:flex-end;text-align:right;
  will-change:transform;
  transition:transform var(--dur,1s) var(--ease,cubic-bezier(.16,1,.3,1))}
/* the hero's ghosts, at the hero's weight — only larger */
#splash .reel > span{display:block;white-space:nowrap;
  font-family:var(--tri);font-weight:200;letter-spacing:-0.03em;line-height:1.16;
  font-size:clamp(38px,5.6vw,104px);color:var(--ghost);user-select:none}
#splash .reel > span{transition:color .16s linear}
/* the paper's purple, not the page's accent: at full accent the centre line
   jumped out of the column rather than reading as the one being looked at */
#splash .reel > span.now{color:#9488ea}   /* the one on the centre line */
/* No ink in the reel. The name is a ghost like everything else and lights the
   same purple when the line reaches it — it is only one of the words until the
   curtain goes up, and the page's own h1 behind it is the one in ink. */
#splash .reel > span.ink{color:var(--ghost);font-weight:180;letter-spacing:-0.052em}
#splash .reel > span.ink.now{color:#9488ea}
/* no transform of its own: flyTo replaces it, and a translate left in place
   here landed the wordmark half a line below the nav's */
#splash .mark{position:absolute;left:var(--edge);top:calc(50% - 8px);
  font-family:var(--tri);font-weight:180;font-size:12px;letter-spacing:0;
  color:var(--ink)}
@media (prefers-reduced-motion:reduce){ #splash{display:none} }

#grid{position:fixed;inset:0;z-index:8;pointer-events:none;display:none}
#grid.on{display:block}
/* Each bar comes in from its own side on a stagger: columns alternate up and
   down across the page, the margins slide in from the edges they mark. */
#grid b{position:absolute;top:0;bottom:0;display:block;opacity:0;
  transform:translate(var(--fx,0),var(--fy,0));
  transition:transform .62s cubic-bezier(.22,1,.36,1),opacity .52s linear;
  transition-delay:var(--d,0s)}
/* .16s in, .52s out: it is solid almost as soon as it moves, and smears as it
   leaves, so the exit trails instead of blinking off */
#grid.in b{transform:none;opacity:1;
  transition:transform .62s cubic-bezier(.22,1,.36,1),opacity .16s linear}
@media (prefers-reduced-motion:reduce){
  #grid b{transition:none;transform:none;opacity:1}
}
#grid b.col{background:rgba(74,47,208,.075)}   /* columns are tinted; the gaps
                                                  between them are the gutters */
#grid b.mar{background:rgba(214,64,40,.22)}                    /* the margin */

/* kept quiet, and sat on the work column's left edge — under the project
   titles and the footer's date. JS supplies the left; the column is measured,
   not guessed. */
#gridbtn{position:fixed;left:var(--edge);bottom:9px;z-index:9;
  font-family:var(--mono);font-size:10.5px;letter-spacing:-0.01em;text-transform:uppercase;
  color:var(--dim);background:none;border:0;padding:0;line-height:1;cursor:pointer;
  opacity:.3;transition:opacity .18s linear,color .18s linear}
#gridbtn:hover{opacity:1;color:var(--accent)}
#gridbtn[aria-pressed="true"]{opacity:1;color:var(--accent)}


/* ---------- one column ----------
   Below the breakpoint the page collapses to a single column, so every width written as a
   share of the rail (48.816% = one grid column, 51.184% = a column plus the
   gutter) is measuring the wrong box and has to be released. The JS alignment
   passes already bail at this width; the ones that don't are switched off here. */
@media (max-width:1260px){
  .head{margin-bottom:22px}
  .head nav{width:auto;gap:26px}
  .bio{width:100%}
  #rail p{max-width:62ch}
  .dirhead{flex-wrap:wrap;gap:10px}
  .dirhead .lab{width:auto}
  .filters{width:auto;gap:16px;margin-left:auto}
  #hero{height:clamp(300px,58vw,420px);margin-bottom:14px}
  #dial{left:0;top:auto;bottom:6px}
  #col{--sh:clamp(200px,56vw,380px)}
  /* the ghost words are placed off columns that no longer exist here, so they
     are laid out from GHOSTS_NARROW instead — and set smaller, since five of
     them have to scatter across a box half the width */
  #ghosts span{font-size:clamp(21px,6.1vw,34px)}
  #name{font-size:clamp(30px,7.4vw,44px)}   /* layoutGhosts sets its top here */
  /* the list is long; cap it so the work is not thirteen rows down */
  /* One column: the rail's two long blocks are desktop furniture, and stacked
     they push the hero — the thing the page opens on — most of a screen down.
     So on the home page the bio and the list go, the nav holds the top, the
     hero follows it, and the filter row travels down with the work it filters
     (placeDir moves it there). Other pages keep their rail: this is the only
     one whose hero lives inside the work column. */
  .home .bio{display:none}
  .home .dirlist{display:none}          /* placeDir moves #dir out of #rail */
  .home .dirhead .lab{display:none}        /* a count of a list that is not here */
  .home .filters{margin-left:0}            /* nothing to its left any more */
  /* the dial shares the filter row, ruled to the right margin — placeDir moves
     it in here, so it is a flex item now and not the hero's floating control */
  .home .dirhead{align-items:center}
  .home #dial{position:static;left:auto;top:auto;bottom:auto;
    margin-left:auto;width:clamp(110px,34vw,190px)}
  .home #dir{max-height:none;overflow:visible;margin:0 0 18px}
  /* fixed, not sticky: as a child of the rail the nav had a containing block
     only as tall as the rail, so it let go a few hundred px down the page */
  .home .head{position:fixed;top:0;left:0;right:0;z-index:6;
    margin:0;padding:var(--edge) var(--edge) 10px}
  .home #page{padding-top:var(--stick,58px)}
  /* the list is long; cap it so the work is not thirteen rows down */
  #dir{max-height:46vh;overflow-y:auto;overscroll-behavior:contain}
  .strip{padding-right:var(--edge)}
  /* the project's line across the top, then its tag and its season sharing the
     row under it — ruled to the two margins, the way they are on the wide
     layout, rather than stacked into three lines of their own */
  /* on the page's nine columns: the line wraps at the end of column 7, and the
     season starts on column 8 rather than hanging off the right margin */
  .caprow{grid-template-columns:calc(var(--gcol) * 7 + var(--ggut) * 7) 1fr;gap:2px 0;
    margin-right:0;padding-right:0}
  .caprow .cap:first-child{grid-column:1/-1;max-width:calc(var(--gcol) * 7 + var(--ggut) * 6);
    /* balance, not pretty: a caption is two lines at most, and pretty still
       filled the first to the limit and left a stub under it */
    text-wrap:balance;hyphens:none}
  .caprow .cap.meta{grid-column:1;justify-self:start;margin-top:2px}
  .caprow .cap.meta.end{grid-column:2;justify-self:start;margin-top:2px}
  .stackcol{margin-right:0}
  #grid,#gridbtn{display:none}
}

#err{position:fixed;z-index:9;inset:40px;display:none;white-space:pre-wrap;font:11px ui-monospace,monospace;
  color:#7a1010;background:#fff;padding:18px;overflow:auto}


/* ---------- lightbox ----------
   A plain page-coloured field, the picture as large as the window allows, and
   round controls pinned to the edges — the standard viewer, nothing between
   the reader and the work. */
#lb{position:fixed;inset:0;z-index:20;display:none;background:var(--bg)}
#lb.on{display:grid;grid-template-columns:1fr;align-items:center;
  padding:clamp(16px,3.4vw,48px) clamp(56px,6vw,96px);cursor:zoom-out}
#lb .lbmedia img,#lb .lbmedia video,#lb .lbnav,#lb .lbx{cursor:default}
#lb .lbnav,#lb .lbx{cursor:pointer}
#lb .lbstage{margin:0;display:flex;flex-direction:column;align-items:center;
  gap:9px;width:100%;min-width:0}
/* a box the size of the space available, with the picture fitted inside it, so
   a small file opens as large as the window allows instead of at its own size */
#lb .lbmedia{width:100%;display:flex;
  align-items:center;justify-content:center}
#lb .lbmedia img,#lb .lbmedia video{display:block;max-width:100%;max-height:100%;
  width:auto;height:auto}   /* JS gives it the picture's own size */
/* under the picture, its width, in the accent */
#lb .lbcount{margin:0;font-family:var(--mono);font-size:10.5px;letter-spacing:-0.01em;
  color:var(--accent);white-space:nowrap;max-width:100%}
/* round grey buttons, on the window's edges and centred down it */
#lb .lbnav,#lb .lbx{position:fixed;display:grid;place-items:center;
  width:36px;height:36px;padding:0;border:0;border-radius:50%;
  background:#e9e9e7;color:#8b8b88;font-family:var(--sim);font-weight:100;line-height:1;
  cursor:pointer;transition:background .16s linear,color .16s linear}
#lb .lbnav{top:50%;translate:0 -50%;font-size:17px}
#lb .lbprev{left:18px}
#lb .lbnext{right:18px}
#lb .lbnav:hover,#lb .lbx:hover{background:#dedede;color:var(--ink)}
#lb.solo .lbnav,#lb.solo .lbcount{visibility:hidden}
/* fixed, not absolute: an auto-placed absolute child of a grid lands in a grid
   area rather than the container's corner, which put this on the left rail */
/* literal, not calc(var(--edge)...): --edge is declared on #page and the
   lightbox is a child of body, so that calc was invalid and top/right fell back
   to auto, leaving the button in the grid's first cell. */
#lb .lbx{top:18px;right:18px;font-size:22px}
/* above the picture and exactly its width — JS sets that width */
#lb .lbcap{margin:0;display:flex;justify-content:space-between;align-items:baseline;
  gap:24px;max-width:100%;font-size:12px;letter-spacing:-0.02em;color:var(--ink)}
/* the lightbox's "View live site" wears the cursor's label: the same purple
   box, white Triplex set into its bottom right corner, no arrow */
#lb .lblive{margin-left:auto;margin-right:16px;
  display:inline-flex;align-items:flex-end;justify-content:flex-end;
  width:120px;height:27px;padding:0 3px 4px 0;background:#7c6ce8;
  font-family:var(--tri);font-weight:110;font-size:11px;line-height:1;
  letter-spacing:-0.01em;color:#fff;text-decoration:none;white-space:nowrap;
  cursor:pointer;transition:background-color .2s linear}
#lb .lblive:hover{background:#6a59df}
#lb .lblive[hidden]{display:none}
#lb .lbdate{font-family:var(--mono);font-size:10.5px;letter-spacing:-0.01em;
  text-transform:uppercase;color:var(--accent);white-space:nowrap}
#lb .lbcount{font-family:var(--mono);font-size:10.5px;letter-spacing:-0.01em;color:var(--dim)}
html.lb .card{cursor:zoom-in}   /* only where the lightbox is loaded */


/* ---------- arrange mode ----------
   Off by default, so an ordinary visit behaves exactly as before. */
#editbtn,#savebtn,#resetbtn{position:fixed;bottom:9px;z-index:9;
  font-family:var(--mono);font-size:10.5px;letter-spacing:-0.01em;text-transform:uppercase;
  color:var(--dim);background:none;border:0;padding:0;line-height:1;cursor:pointer;
  opacity:.3;transition:opacity .18s linear,color .18s linear}
#editbtn:hover,#savebtn:hover,#resetbtn:hover{opacity:1;color:var(--accent)}
#savebtn,#resetbtn{opacity:.5}
#savebtn:disabled{opacity:.28;cursor:default;color:var(--dim)}
#editbtn[aria-pressed="true"]{opacity:1;color:var(--accent)}

/* the pieces only respond while the mode is on */
/* the zoom cursor lives on .card, which is a child of .pcard — so it has to be
   overridden on the anchor itself, not just on the piece around it */
html.editing .pcard,html.editing .tiny .card{cursor:grab;user-select:none;position:relative}
html.editing .pcard .card,html.editing .tiny .card{cursor:grab}
html.editing .sel,html.editing .sel .card{cursor:grab}
html.editing .moving,html.editing .moving .card{cursor:grabbing}
html.editing .pcard .rect,html.editing .tiny .card .rect{pointer-events:none}
html.editing .pcard:hover,html.editing .tiny .card:hover{outline:1px solid rgba(74,47,208,.35);
  outline-offset:2px}
html.editing .sel{outline:2px solid var(--accent);outline-offset:2px}
/* the grab handle on the right edge */
html.editing .sel::after{content:'';position:absolute;top:50%;right:-5px;
  width:10px;height:26px;transform:translateY(-50%);border-radius:2px;
  background:var(--accent);cursor:ew-resize}
html.editing .moving{opacity:.5;cursor:grabbing}
html.editing .resizing{outline:2px solid var(--accent);outline-offset:2px}
#dropline{position:fixed;z-index:11;width:2px;background:var(--accent);
  border-radius:2px;pointer-events:none}



/* ---------- the pointer ----------
   The ball IS the cursor: a circle of the site's ink handed to the browser as
   the cursor image, rather than a div chasing the pointer a frame behind. A
   cursor image is composited by the window manager rather than by the page, so
   it cannot be multiplied into what it is over — it is simply the ink.

   Named on everything that sets a cursor of its own: the pointer, the grab and
   the zoom-in each outrank a bare selector, and any one of them left in place
   would put the arrow back. Arrange mode is the exception — its rules are
   specific enough to keep their own, which is right: a grab and a resize
   handle mean things the ball cannot say.

   The ink is #9b8aec, written into the drawing rather than taken from a custom
   property: a url() is one token to the parser, and a var() inside it is not
   substituted. */

/* The dot does not stand in for the cursor — the system one is left alone and
   this follows it a beat behind. An element rather than a url() cursor because
   a cursor image is composited by the window manager and cannot be multiplied
   into what it is over; multiply is only possible if the dot is in the page. */
#cur{position:fixed;left:0;top:0;width:15px;height:15px;border-radius:50%;
  background:#9b8aec;mix-blend-mode:multiply;pointer-events:none;z-index:99;
  /* off the pointer's shoulder rather than under it: the arrow's tip is the
     hotspot and its body runs down to the right, so a dot centred on the point
     sat inside the arrow. Held clear at its bottom right corner. */
  margin:16px 0 0 13px;will-change:transform;
  display:flex;align-items:flex-end;justify-content:flex-end;
  overflow:hidden;white-space:nowrap;padding:0;
  transition:width .36s var(--tag-e),height .36s var(--tag-e),
             border-radius .36s var(--tag-e),margin-top .36s var(--tag-e),
             padding .36s var(--tag-e),background-color .2s linear}
:root{--tag-e:cubic-bezier(.22,.9,.24,1)}
/* Over a project the dot draws itself out into its own label. Left edge and
   vertical centre both stay on the pointer, so the box grows to the right of
   where the dot already was rather than jumping somewhere else, and the text
   is hung off the far end so it arrives with the end of the box. */
#cur.tag{width:120px;height:27px;border-radius:0;background:#7c6ce8;
  margin-top:10px;padding:0 3px 4px 0}
/* set in the corner rather than centred: the box is mostly empty on purpose,
   and the words sit down in the bottom right of it */
#cur b{font-family:var(--tri);font-weight:110;font-size:11px;line-height:1;
  letter-spacing:-0.01em;color:#fff;
  opacity:0;transform:translateX(8px);
  transition:opacity .14s linear,transform .3s var(--tag-e)}
#cur.tag b{opacity:1;transform:none;transition-delay:.1s}
/* two-line labels ("full case study / coming soon"): set tight, right-aligned
   in the corner, and the box grows to hold the second line */
#cur b{text-align:right}
#cur.tag.two{height:44px}
#cur.tag.two b{line-height:.92}
/* Mid-swap, inside a box that stays open. Its own transition, and no delay:
   the .1s above is there to let the box finish opening before the word arrives
   in it, and waiting that out again on every change between two projects made
   the label feel like it was lagging the pointer. */
#cur.tag.relabel b{transition:opacity .12s linear,transform .12s var(--tag-e);
  transition-delay:0s}
#cur.tag.relabel.off b{opacity:0;transform:translateX(5px)}
@media (hover:none),(pointer:coarse){ #cur{display:none} }
a,button,[role="button"],.card,.filters a,.strip,.strip.drag,
#rail .dirlist li,#gridbtn,#dial input,
.fplate.hot,
#lb,#lb.on,#lb .lbnav,#lb .lbx,html.lb .card{cursor:pointer}
/* A card with no href goes nowhere: it opens itself in the lightbox. The glass
   is the whole affordance those get, so it has to carry the meaning on its
   own — zoom-in rather than a drawn icon, because it is the one the reader
   already knows from every other gallery, and it survives at any DPI. */
html.lb .card:not([href]){cursor:zoom-in}
/* Over a sheet. Set on the root because the plate itself is pointer-events:none
   — it is the hero underneath that the pointer is actually on — and cursor
   inherits down from there. */
html.sheet,html.sheet #hero,html.sheet #hero *{cursor:pointer}


/* Only Rivet and the Moving Image Archive go anywhere for now; the rest of the
   work is not a link, so it should not wear a hand. */
.home #col .strip a.card:not([href]),
.home #rail .dirlist li:not([data-href]){cursor:default}
/* except the strips whose pictures open large (data-cur=""): the magnifying glass */
.home #col .strip[data-cur=""] a.card{cursor:zoom-in}

/* Safari's start-playback button, shown over a muted clip it would not
   autoplay (Low Power Mode): the poster reads as a still instead */
video::-webkit-media-controls-start-playback-button{display:none!important;-webkit-appearance:none}
video::-webkit-media-controls{display:none!important}
/* hidden until it actually plays: the card's own poster shows meanwhile */
.rect video:is([autoplay],[data-autoplay]):not(.playing){opacity:0!important}

/* a light-purple tag after a project's line, in the case studies' tag style */
.cap .soon{display:inline-block;margin:6px 0 0;padding:3px 7px 2px;background:#ece9fc;color:#7c6ce8;
  font-size:11px;line-height:1.2;letter-spacing:-0.01em;font-weight:inherit;vertical-align:1px;white-space:nowrap}

/* strips with no label (data-cur="") have no hover state at all: their pictures just open large on a click */
.strip[data-cur=""] .card:hover .rect{background:none}
.strip[data-cur=""] .card{cursor:zoom-in}

/* the lightbox shows the picture alone: no caption or date over it (a live-site link still shows) */
#lb .lbtext,#lb .lbdate{display:none}
