/* =====================================================================
   VAL STUDIO — shared site stylesheet (labs / works / a work / info / 404)
   ---------------------------------------------------------------------
   index.html is NOT styled from this file — it is a standalone full-
   viewport piece with its own inline <style>. This file is for the
   plain, scrolling interior pages that share one header and one grid
   language.

   Every value below the tokens block is a var(--token). If a new color
   or size is needed, add a token first — do not hardcode a hex, px, or
   em value inside a component rule.
===================================================================== */

/* THE MARK'S GEOMETRY, THEN THE MARK — core/brand-mark.css, then
   core/mark.css. The first states the shape as custom properties and
   paints nothing; the second draws with them and owns the size. In that
   order, because a custom property must be declared before the rule that
   reads it. index.html and the labs pieces, which do not link this
   stylesheet, link both directly and in the same order.

   Imported rather than restated here so that "make the logo bigger" is
   one edit rather than five. Must stay the first rules in the file:
   @import is only honoured before any other declaration. */
@import url('/core/brand-mark.css');
@import url('/core/mark.css');

/* THE CONTACT PANEL IS ONE FILE FOR THE WHOLE SITE — core/contact.css.
   Imported here for exactly the reason mark.css is: the nineteen pages
   that link this stylesheet get it without a second tag of their own.
   index.html, which does not link this file, <link>s that one directly
   and imports core/contact.js beside it. Must stay adjacent to the
   import above — @import is only honoured before any other declaration. */
@import url('/core/contact.css');

/* ---------- tokens ---------------------------------------------------- */
:root{
  /* palette — near-monochrome, the same family as index.html */
  --void-0:   #050607;   /* page background */
  --void-1:   #0a0c0f;   /* card / panel background */
  --surface:  #0d1013;   /* raised surface (hover states) */
  --haze:     #8d98a4;   /* secondary text */
  --haze-dim: rgba(141,152,164,.46);  /* tertiary / meta text */
  --line:     rgba(141,152,164,.15); /* hairline borders, resting */
  --line-hot: rgba(216,224,231,.42); /* hairline borders, active/hover */
  --signal:   #e2e6ea;   /* primary text, headings */

  /* placeholder-media gradients — cycled across grid cards so empty
     entries read as intentional dark panels, not broken images */
  --grad-1: linear-gradient(160deg, #12151a 0%, #05070a 72%);
  --grad-2: radial-gradient(120% 130% at 18% 12%, #171b21 0%, #05070a 68%);
  --grad-3: linear-gradient(200deg, #0f1216 0%, #06080b 55%, #12161b 100%);

  /* warm tint of the home page's light source — the only non-neutral in the
     palette, reserved for the hover state so "lit" always means the same thing */
  --warm:     #fff3e6;

  /* type — Syne, drawn for a contemporary art centre: geometric but odd,
     which is the register this practice works in. One family across the
     whole site; weight and tracking carry the hierarchy instead. */
  --font-ui: 'Syne', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
  /* Titles. A monoline geometric sans, set extralight and in capitals —
     the register of the reference heading. Outfit is the nearest open
     equivalent of the reference's own face (Fort, which is commercial and
     cannot be served); Jost and Montserrat are drop-in swaps at the same
     weights if a different flavour of geometric is wanted. Aptos stays in
     the stack as a local fallback for anyone the webfont fails for. */
  --font-title: 'Outfit', 'Aptos', 'Segoe UI Variable Display', system-ui, -apple-system, sans-serif;
  --wt-title: 200;   /* extralight — the thing that makes the heading read */
  --fs-000:  10px;  /* micro labels, meta, tech tags */
  --fs-00:   12px;  /* small body, nav, card copy */
  --fs-0:    14px;  /* base body */
  --fs-1:    17px;  /* lede / summary */
  --fs-2:    22px;  /* section heading */
  --fs-3:    clamp(28px, 5vw, 44px); /* page heading */
  --lh-tight: 1.25;
  --lh-body:  1.65;
  --track-tight: .01em;
  --track-ui:    .08em;
  --track-wide:  .22em;

  /* spacing scale */
  --sp-1: 4px;
  --sp-2: 8px;
  --sp-3: 12px;
  --sp-4: 16px;
  --sp-5: 24px;
  --sp-6: 32px;
  --sp-7: 48px;
  --sp-8: 64px;
  --sp-9: 96px;

  /* grid / layout */
  --content-max: 1160px;
  --page-pad:    clamp(20px, 4vw, 48px);
  --grid-min:    260px;
  --grid-gap:    var(--sp-5);
  --chrome-pad:  clamp(18px, 3.4vmin, 34px);  /* inset of the two corners */
  /* Nothing is sticky above the content any more, so every page owes the
     mark this much room at the top: its inset, its height, and the same
     inset again as breathing space. */
  --chrome-clear: calc(var(--chrome-pad) * 2 + 62px);
  /* menu icon box — square, so it can turn 90°. The corners' shared
     height, --corner-h, not a number of its own: see core/mark.css. */
  --menu-box:    var(--corner-h);
  /* The mark's own height and tracking are NOT here — they are the CONFIG
     block of core/mark.css, imported at the top of this file, so the one
     that draws the home page and the one that draws these pages cannot
     drift apart. The same block holds --corner-h, the height all four
     corner controls are drawn at. */
  --ratio-card:  4 / 3;
  --ratio-hero:  16 / 9;
  /* The Studio panel — the shape the renders come out of. 4:3 landscape,
     the same on a desktop and on a phone, so a cover is cropped once and
     serves both. The picture is drawn with object-fit (--cover-fit,
     `cover` by default), so a render made to another shape is
     centre-cropped into it rather than letterboxed inside it. /covers
     draws its sample panel to this same token, so the tuner and the rack
     cannot end up different shapes. */
  --ratio-panel: 4 / 3;

  /* ---- the cover, as a sheet of glass -------------------------------
     The Studio rack's panels are dark glass with the render inside them
     rather than printed cards. These are the shipped values; they are
     the ONLY place any of them is written down, and core/covers.js
     writes the visitor's own over the top of them on :root. /covers is
     where those are set. See "the cover, as a sheet of glass" further
     down for what each one does to the drawing. */
  --cover-ground:  .62;    /* how solid the panel's own backing is */
  --cover-opacity: .88;    /* the picture's presence over it */
  --cover-veil:    .10;    /* a wash of the page's ground across it */
  --cover-gloss:   .18;    /* the highlight's strength */
  --cover-glide:   16%;    /* how far it travels as the panel leans */
  --cover-angle:   118deg; /* which way the highlight lies across it */
  --cover-spread:  19%;    /* how wide the band of light is */
  --cover-rim:     .26;    /* the lit top edge */
  --cover-blur:    0px;    /* frosting, as though the glass were not clear */
  --cover-sat:     .90;    /* the picture's saturation under it */
  --cover-fit:     cover;  /* cover | contain */

  /* the Studio rack */
  --bench-gap:   clamp(28px, 3.4vw, 52px);
  /* The tiles' shadow: a tight contact layer and a wide, very soft one.
     These are the fallbacks — core/studio.js writes CONFIG.SHADOW over
     them onto #studio, which is where they should be edited. */
  --tile-shadow:      0 2px 8px rgba(0,0,0,.42), 0 14px 40px -12px rgba(0,0,0,.55);
  --tile-shadow-lift: 0 4px 14px rgba(0,0,0,.48), 0 30px 72px -18px rgba(0,0,0,.72);

  /* motion — one vocabulary, shared with index.html. Exits run at roughly
     65% of entrances so dismissing never feels slower than opening. */
  --ease:     cubic-bezier(.22,.72,.2,1);
  --dur-fast: .22s;
  --dur-med:  .42s;
  --dur-slow: .62s;
  /* The Contact panel's zoom needed a fourth length — shorter than
     --dur-med and longer than --dur-fast — and it is NOT here.
     --dur-panel / --dur-panel-out are declared in the CONFIG block at the
     top of core/contact.css, because that panel runs on the home page
     too and these three tokens exist twice: once here and once inline in
     index.html. A duration that component owns must not be a value the
     two pages can disagree about. */
  /* THE RAIL'S OWN FADE — see "the rail" further down. How slow it is
     once it starts, gentler than the other durations on purpose: this is
     a dwell ending on its own, not a click dismissing something. How
     long the dwell ITSELF lasts is RAIL_LINGER_MS in core/site.js, not a
     token here — see the note over railTimer in mountChrome() for why
     that half of it is a timer and not a transition-delay. */
  --rail-fade: .6s;
  --leave:    .42s;   /* must match FADE_MS in core/site.js */

  /* misc */
  --radius: 2px;
}

/* ---------- reset ------------------------------------------------------ */
*{box-sizing:border-box}
html,body{margin:0}
html{background:var(--void-0)}
body{
  background:var(--void-0);
  color:var(--haze);
  font:400 var(--fs-0)/var(--lh-body) var(--font-ui);
  letter-spacing:var(--track-tight);
  -webkit-font-smoothing:antialiased;
  text-rendering:geometricPrecision;
  min-height:100vh;
}

/* ---------- page transition -------------------------------------------
   A veil rather than a body-wide opacity fade: the same radial the home
   page's sea fades into at distance, so moving between pages reads as the
   water going dark and coming back rather than as content blinking. The
   element is injected by core/site.js so no page has to carry the markup. */
#veil{
  position:fixed;inset:0;z-index:100;pointer-events:none;
  background:radial-gradient(130% 100% at 50% 64%,#0b111a 0%,#05080c 52%,var(--void-0) 100%);
  opacity:1;transition:opacity var(--dur-slow) var(--ease);
}
body.is-visible #veil{opacity:0}
body.is-leaving #veil{opacity:1;transition-duration:var(--leave)}

/* ---------- the veil, once the glass sheet is carrying the change ------
   core/glass.js adds .has-glass when its context is live. From then on
   the transition is the shader — the sheet seals over the page, and a
   blackout on top of it would hide the one thing worth looking at. So
   the veil stops being a blackout and becomes a thin darkening that
   agrees with the sheet's own exposure drop.

   It is still here, and still a real element, because it is what covers
   the change if WebGL dies between two pages. */
body.has-glass #veil{opacity:.5}
body.has-glass.is-visible #veil{opacity:0}
body.has-glass.is-leaving #veil{opacity:.42;transition-duration:var(--leave)}

/* ---------- the veil, while the loading plane is up --------------------
   It stands down completely. The plane (core/loader.css) is already a
   cover, the page's own content is held at opacity 0 until .is-visible,
   and the one thing left underneath both of them is the sheet holding
   its seal shut — which is exactly what the loading state is meant to be
   showing. Two scrims over it made it a rumour.

   `:not(.is-leaving)` is not tidiness: if a navigation somehow starts
   while the plane is still up, the veil is needed again immediately, and
   without this the two rules tie on specificity and source order
   decides. */
html.vl-loading body:not(.is-leaving) #veil{opacity:0}

/* Content sinks rather than blinks: it recedes and softens as the lens
   above it thickens, which is the same gesture the shader is making. The
   blur is on the corners only — #main must not become a backdrop root or
   the Studio rack's pick scrim loses what it blurs (see below). */
body.has-glass.is-leaving #main{
  opacity:0;transform:translateY(-6px) scale(.988);
  transition:opacity var(--seal-leave,.46s) var(--ease),
             transform var(--seal-leave,.46s) var(--ease);
}
body.has-glass.is-leaving .corner{
  opacity:0;filter:blur(3px);
  transition:opacity var(--dur-fast) var(--ease),
             filter var(--seal-leave,.46s) var(--ease);
}

/* Arriving, the same move played backwards: the page comes up from under
   the sheet as the seal opens. Slower than it left — surfacing should
   not be as brisk as sinking. */
body.has-glass.is-surfacing #main{
  animation:glass-surface var(--seal-arrive,.82s) var(--ease) both;
}
@keyframes glass-surface{
  from{opacity:0;transform:translateY(10px) scale(.985)}
  to  {opacity:1;transform:none}
}

@media (prefers-reduced-motion: reduce){
  body.has-glass.is-leaving #main,
  body.has-glass.is-leaving .corner{transform:none;filter:none;animation:none}
  body.has-glass.is-surfacing #main{animation:none}
}

/* The ambient background (core/ambient.js) is painted at z-index 0 across
   the whole viewport, so everything that carries text is given an explicit
   layer above it rather than relying on paint order. */
#backdrop{position:fixed;inset:0;z-index:0;pointer-events:none;background:var(--void-0)}
#backdrop canvas{display:block;width:100%;height:100%}

/* ---------- the background with no sheet in it -------------------------
   `is-flat` is core/glass.js saying the sheet is not being drawn: no
   WebGL on this machine, or a context taken away and not yet given back.
   Until now the class had no rule anywhere and what you got was
   #backdrop's flat --void-0 above — correct, and completely empty.

   A recording of the sheet stands in it instead. core/glass.js builds
   the element and holds the two paths (CONFIG.FALLBACK there); the two
   numbers it writes onto #backdrop are read below.

   THIS IS STILL A BACKGROUND. It sits behind a page of text and must not
   start competing with it, so the recording is held at --flat-dim and
   the page's own ground is washed back over the top at --flat-veil. Both
   have fallbacks here, so the rule is correct on its own if the script
   ever stops writing them.

   ONLY #backdrop. index.html hands glass.js its own canvas, so `is-flat`
   lands on that page's <body> instead — and the home page has its own
   fallback for this, #msg, which is the piece rather than a backdrop.
   One treatment per host; qualifying this rule is what keeps the home
   page from getting both. */
.flat-media{
  position:absolute;inset:0;width:100%;height:100%;
  object-fit:cover;
  opacity:0;
  transition:opacity var(--dur-slow) var(--ease);
}
#backdrop.is-flat .flat-media{opacity:var(--flat-dim,.34)}

/* The ground, back over the top. A pseudo-element rather than a filter
   on the video: the canvas is still in this box, transparent and doing
   nothing, and one layer over both of them is the honest description. */
#backdrop.is-flat::after{
  content:"";position:absolute;inset:0;
  background:color-mix(in srgb, var(--void-0) calc(var(--flat-veil,.62) * 100%), transparent);
}

/* content settles in a beat behind the veil.

   #main deliberately carries NO `filter`, not even an identity one: any
   filter would make it a backdrop root, and the Studio rack's pick scrim
   lives inside it and has to be able to blur the backdrop canvas behind
   it. The corners get theirs in their own rule further down. */
#main,.corner{
  position:relative;z-index:1;
  opacity:0;transform:translateY(8px);
  transition:opacity var(--dur-slow) var(--ease) .12s,transform var(--dur-slow) var(--ease) .12s;
}
body.is-visible #main,body.is-visible .corner{opacity:1;transform:none}
body.is-leaving #main,body.is-leaving .corner{
  opacity:0;transition-duration:var(--dur-fast);transition-delay:0s;
}

/* ---------- cursor -----------------------------------------------------
   A precise dot with a faint ring lagging behind it, matching the home
   page. Injected by core/site.js and only ever shown on a real pointer. */
@media (hover:hover) and (pointer:fine){
  html.has-cursor,html.has-cursor *{cursor:none}
  html.has-cursor a,html.has-cursor button{cursor:none}
}
#ring,#dot{
  position:fixed;left:0;top:0;z-index:110;pointer-events:none;
  opacity:0;transition:opacity var(--dur-med) var(--ease);will-change:transform;
}
#dot i{display:block;width:4px;height:4px;margin:-2px 0 0 -2px;border-radius:50%;
  background:var(--signal);transition:transform var(--dur-fast) var(--ease)}
#ring i{display:block;width:28px;height:28px;margin:-14px 0 0 -14px;border-radius:50%;
  border:1px solid rgba(226,238,246,.22);
  transition:transform var(--dur-med) var(--ease),border-color var(--dur-fast) var(--ease)}
html.pointer-live #ring,html.pointer-live #dot{opacity:1}
html.cursor-active #dot i{transform:scale(.4)}
html.cursor-active #ring i{transform:scale(1.5);border-color:rgba(255,243,230,.5)}
html.pointer-down #ring i{transform:scale(.74);border-color:rgba(255,243,230,.62)}
@media (hover:none),(pointer:coarse){#ring,#dot{display:none}}

/* ---------- waterline — the shared hover language ----------------------
   Nothing on this site changes colour abruptly on hover. Light rises
   through it instead, the way the specular path rises on the water on the
   home page: a hairline carrying the light source's warm tint wipes in
   from the left, and the tracking opens very slightly.
     .wl  text elements
     .ic  icon-only elements, where a rule under a 16px glyph would read
          as an accident — same warmth, delivered as a bloom.            */
.wl{
  position:relative;
  transition:color var(--dur-med) var(--ease),letter-spacing var(--dur-med) var(--ease);
}
.wl::after{
  content:"";position:absolute;left:0;right:0;bottom:0;height:1px;
  background:linear-gradient(90deg,rgba(255,243,230,0),var(--warm) 42%,rgba(255,243,230,.28));
  transform:scaleX(0);transform-origin:left;opacity:0;
  transition:transform var(--dur-slow) var(--ease),opacity var(--dur-med) var(--ease);
}
.wl:hover,.wl:focus-visible{color:var(--signal)}
.wl:hover::after,.wl:focus-visible::after{transform:scaleX(1);opacity:.62}
.wl:not(:hover)::after{transition-duration:var(--dur-med),var(--dur-fast)}

.ic{transition:color var(--dur-med) var(--ease),filter var(--dur-med) var(--ease)}
.ic:hover,.ic:focus-visible{color:var(--signal);filter:drop-shadow(0 0 7px rgba(255,243,230,.35))}

@media (prefers-reduced-motion:reduce){
  *{animation-duration:.01ms!important;transition-duration:.01ms!important}
  #main,.corner{opacity:1;transform:none}
}

h1,h2,h3,p,ul,dl,dd{margin:0}
a{color:inherit;text-decoration:none}
a,button{cursor:pointer}
button{font:inherit;letter-spacing:inherit;color:inherit;background:none;border:0;padding:0}
img,video{max-width:100%;display:block}
:focus{outline:none}
:focus-visible{outline:1px solid var(--line-hot);outline-offset:4px}

/* ---------- chrome (injected by core/site.js) ---------------------------
   The same chrome index.html carries, ported to the shared stylesheet so
   the two cannot drift: the mark in one corner, the menu button in the
   other, and NOTHING behind either of them. There is no bar, no rule and
   no panel — the page's own background runs to the top edge, and the two
   controls sit on it.

   index.html's copy uses --dur-s/-m/-l and --foam; the tokens here are
   --dur-fast/-med/-slow and --warm. Same values, different names.
========================================================================= */
/* Restates the entry transition above and adds `filter` to it, which is
   what the Studio rack's picked state blurs these with. It has to be
   done here rather than up there because a later rule setting
   `transition` would own the whole shorthand — and because #main must
   not be given a filter at all (see the note on that rule). */
.corner{
  position:fixed;z-index:40;
  /* ONE filter declaration for the whole site, driven by custom
     properties rather than restated by every rule that wants to change
     it. Two things dim a corner and they have to be able to happen at
     once: the Studio rack's picked state (--pick-blur/--pick-dim, set by
     body.has-picked further down) and the header leaving with the top of
     the page (--veil, set on the two header corners below). Written as
     separate rules they would take turns owning `filter` and whichever
     came last would silently cancel the other.

     THE FALLBACKS BELOW ARE LOAD-BEARING. `blur(var(--pick-blur,0))` is
     the whole of what keeps a corner sharp while no tile is picked, and
     a fallback is only reached while the variable is undefined — so
     --pick-blur must not be set anywhere these inherit from.
     core/studio.js writes it onto #studio for exactly that reason. It
     was on :root once, and the entire header — mark, menu and sound —
     sat under a permanent 14px blur on /labs until it moved. */
  filter:blur(var(--pick-blur,0)) opacity(var(--pick-dim,1)) opacity(var(--veil,1));
  transition:opacity var(--dur-slow) var(--ease) .12s,
             transform var(--dur-slow) var(--ease) .12s,
             filter var(--corner-fade,var(--pick-fade,760ms)) var(--ease);
}
/* THE FOUR CORNERS — one grid, at one --chrome-pad, measured to the
   GLYPH. Every corner box is exactly the height of what it draws
   (--corner-h, core/mark.css) and carries no padding of its own, so
   --chrome-pad is the real distance from the frame's edge to the ink on
   all four: the mark's letters, the burger's rules, the sound's crest,
   the word CONTACT. The 44px hit areas are the ::before boxes further
   down, not padding, so taking the padding away moved no target.

   It was not always so. Each box used to carry its own few pixels —
   the mark 5px by 2, the burger 5 and a 1px nudge, the sound's line in
   the middle of a taller box, Contact's trailing tracking — and the
   four inks sat anywhere between 0 and 7px inside the grid they were
   meant to share.

   The safe area is added the same way on all four, and the same way
   index.html does it. These pages do not ask for viewport-fit=cover, so
   today every env() here is 0; the point is that the day one of them
   does, the four corners move together instead of Contact alone.   */
#brand{
  top: calc(var(--chrome-pad) + env(safe-area-inset-top, 0px));
  left:calc(var(--chrome-pad) + env(safe-area-inset-left, 0px));
}
#menuZone{
  top:  calc(var(--chrome-pad) + env(safe-area-inset-top, 0px));
  right:calc(var(--chrome-pad) + env(safe-area-inset-right, 0px));
}
/* mark.css pads the mark's link for the labs pieces; here it is on the
   grid. The burger's padding goes for the same reason. */
#brand a{padding:0}

/* THE WAY BACK, IN THE MARK'S CORNER — on a single work only (see
   buildChrome() in core/site.js). It takes the mark's place, inset and
   header behaviour, and is set as the other word-shaped corner is —
   Contact's type (core/contact.css, #contactBtn): Syne at --corner-h,
   capitals tracked out, resting in haze and lit by the waterline (.wl)
   on hover. Its line is --corner-h tall, the same as the burger's box,
   so at the same inset the word and the burger already share one centre
   line across the top. */
#back{
  top: calc(var(--chrome-pad) + env(safe-area-inset-top, 0px));
  left:calc(var(--chrome-pad) + env(safe-area-inset-left, 0px));
}
#backLink{
  position:relative;display:flex;align-items:center;gap:.9em;
  font-family:var(--font-ui);
  font-size:var(--corner-h);font-weight:500;line-height:1;
  letter-spacing:.22em;text-transform:uppercase;
  color:var(--haze);
}
/* the arrow keeps its own tracking out of the word's, and eases left a
   touch as the hover lights the word — the way back, leaning back */
.back-arrow{letter-spacing:0;transition:transform var(--dur-med) var(--ease)}
#backLink:hover .back-arrow,#backLink:focus-visible .back-arrow{transform:translateX(-3px)}
@media (prefers-reduced-motion:reduce){ #backLink .back-arrow{transform:none} }

/* ---------- the header, and where it goes -------------------------------
   THE HEADER BELONGS TO THE TOP OF THE DOCUMENT. Reading down, the mark
   and the menu button fade out and stay out; they come back when the page
   comes back to the top. core/site.js sets .chrome-gone off the scroll
   position — see THE HEADER GOES WITH THE TOP OF THE PAGE there for why
   it is a position and not a direction.

   Only these two. The two bottom corners — #dock, the sound, and
   #contactZone, Contact (core/contact.css) — are not the header, and
   they are the two controls on the site you should never have to scroll
   back up to reach.                                                     */
#brand,#back,#menuZone{
  --veil:1;
  --corner-fade:var(--chrome-fade,620ms);
}
html.chrome-gone #brand,
html.chrome-gone #back,
html.chrome-gone #menuZone{--veil:0}

/* Gone means gone: a control nobody can see must not still be taking
   the pointer. #menuZone hands its own pointer-events to its children
   (see THE RAIL below), so the button is what has to be switched off,
   not the zone. */
html.chrome-gone #brand,
html.chrome-gone #back,
html.chrome-gone #menuZone > *{pointer-events:none}

/* ...but a Tab still reaches it, and brings it back with it, so the menu
   is never unreachable from the keyboard. Same for the menu being open:
   the control that closes it cannot be the one that faded away. */
html.chrome-gone #brand:focus-within,
html.chrome-gone #back:focus-within,
html.chrome-gone #menuZone:focus-within,
html.chrome-gone body.menu-open #brand,
html.chrome-gone body.menu-open #back,
html.chrome-gone body.menu-open #menuZone{--veil:1}
html.chrome-gone #brand:focus-within,
html.chrome-gone #back:focus-within,
html.chrome-gone #menuZone:focus-within > *,
html.chrome-gone body.menu-open #brand,
html.chrome-gone body.menu-open #back,
html.chrome-gone body.menu-open #menuZone > *{pointer-events:auto}
/* The sound, under the mark. See THE SOUND, IN THE FOURTH CORNER in
   core/site.js for why it is here and not where the home page keeps it. */
#dock{
  bottom:calc(var(--chrome-pad) + env(safe-area-inset-bottom, 0px));
  left:  calc(var(--chrome-pad) + env(safe-area-inset-left, 0px));
}

/* The corner controls are small on purpose, so each one carries an
   invisible hit area instead of being padded out — the glyphs stay
   exactly where they are and every target clears 44px. */
#brand a,#menuBtn,#snd{position:relative}
#brand a::before,#backLink::before,#menuBtn::before,#snd::before{
  content:"";position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
  width:max(100%,44px);height:max(100%,44px);
}

/* ---------- the sound control ------------------------------------------
   ONE WAVE, IN TWO STATES, AND THE ONLY DIFFERENCE IS WHETHER IT MOVES.
   Silent, the sine is held still at its resting frame. Playing, that very
   same sine travels left to right. Same path, same amplitude, same ink —
   nothing about the SHAPE changes between them.

   THE SHAPE IS NEVER TRADED FOR A LINE, and both earlier versions of this
   control got that wrong in the same way. Seven bars on staggered scaleY
   animations flattened to a rule when silent; the single path that
   replaced them still squashed to a near-flat ripple. Both were reaching
   for "silence is flat", and both produced the same thing on screen: a
   dead horizontal stroke that reads as a control which has failed rather
   than one that is merely off. A wave standing still is already the
   picture of sound not playing, and it keeps the control recognisable as
   the same object in both states.

   ONE CONTINUOUS CURVE, NOT SEVEN BARS. What the bars actually did was
   raise and lower a row of levels in sequence, which is an equaliser. A
   single <path> is the shape this was always described as, and at 12px
   it is less ink for more legibility.

   HOW THE TRAVEL WORKS. The path holds seven full periods and runs well
   past both ends of the viewBox, which clips it. Moving it by exactly
   one period — 13 of the box's 26 units — lands it on itself, so a
   plain linear translate loops seamlessly with no cross-fade and no
   second copy. It composites and never touches layout.

   `vector-effect:non-scaling-stroke` is not decoration. The phone rule
   widens the box and preserveAspectRatio="none" turns that into a 1.5x
   stretch along x only; without it the stroke would be scaled
   unevenly with the geometry and the hairline would thicken.

   Mirrored inline in index.html. Change one and change the other. */
/* The box is the crest: --corner-h tall with no padding, so the wave at
   its height touches the grid line the other corners' ink sits on, and
   its centre line runs through the box's middle — the same centre line
   the word CONTACT sits on across the bottom row. */
#snd{
  display:flex;align-items:center;
  height:var(--corner-h);padding:0;
  margin:0;border:0;background:none;
  color:var(--haze);
  transition:color var(--dur-med) var(--ease);
}
#snd:hover,#snd:focus-visible{color:var(--signal)}

/* --snd-w is the only thing the phone rule changes: wider, never taller.
   preserveAspectRatio="none" in the markup means the extra width becomes
   a longer wavelength rather than a taller wave. */
#snd svg{
  display:block;overflow:hidden;
  width:var(--snd-w,26px);height:var(--corner-h);
}
#snd path{
  fill:none;stroke:currentColor;stroke-width:1.5;
  stroke-linecap:round;stroke-linejoin:round;
  vector-effect:non-scaling-stroke;
}
/* the whole of the state: the same wave, moving or held */
#snd.on .snd-travel{animation:val-wave 1.9s linear infinite}

/* ---------- the volume, on hover ---------------------------------------
   The button still does one thing on a press — mute, unmute, the tick
   answering it, unchanged. Resting on it a moment longer raises a second
   control the button itself has no room for: how loud, not just on or
   off. It rises from the dock the same way the rail drops from the
   button — the same gesture, read in the opposite direction because this
   one lives at the FOOT of the page.

   Hover only, same as the rail: a finger has no hover, and the precise
   way to set a level on a touch device is /sound, which this does not
   replace. `.snd-wrap:focus-within` keeps it reachable by keyboard — Tab
   lands on the slider, arrow keys move it, the native input needs
   nothing else written for that to work. */
.snd-wrap{position:relative}
.snd-vol{
  position:absolute;left:0;bottom:calc(100% + var(--sp-3));
  width:32px;height:78px;
  display:flex;align-items:center;justify-content:center;
  opacity:0;pointer-events:none;
  transition:opacity var(--dur-med) var(--ease),transform var(--dur-med) var(--ease);
  transform:translateY(4px);
}
.snd-wrap:hover .snd-vol,.snd-wrap:focus-within .snd-vol{
  opacity:1;pointer-events:auto;transform:none;
}
.snd-vol input[type="range"]{
  -webkit-appearance:none;appearance:none;
  width:78px;height:28px;                 /* the footprint before the turn */
  transform:rotate(-90deg);
  background:transparent;margin:0;
}
.snd-vol input[type="range"]::-webkit-slider-runnable-track{
  height:2px;background:var(--line-hot);border-radius:1px;
}
.snd-vol input[type="range"]::-moz-range-track{
  height:2px;background:var(--line-hot);border-radius:1px;
}
.snd-vol input[type="range"]::-webkit-slider-thumb{
  -webkit-appearance:none;margin-top:-5px;
  width:12px;height:12px;border-radius:50%;
  background:var(--signal);
  box-shadow:0 0 0 1px var(--void-0);
  transition:background var(--dur-med) var(--ease);
}
.snd-vol input[type="range"]::-moz-range-thumb{
  width:12px;height:12px;border:0;border-radius:50%;
  background:var(--signal);
  box-shadow:0 0 0 1px var(--void-0);
}
.snd-vol input[type="range"]:hover::-webkit-slider-thumb,
.snd-vol input[type="range"]:focus-visible::-webkit-slider-thumb{background:var(--warm)}
@media (hover:none){.snd-vol{display:none}}
/* exactly one period of the 26-unit box, so the loop lands on itself */
@keyframes val-wave{to{transform:translateX(13px)}}
@media (prefers-reduced-motion:reduce){
  /* THE ONE PLACE THE TWO STATES NEED A SECOND CUE. Everywhere else the
     difference is travel against stillness; here there is no travel to
     have, so without something the button would look identical whether
     the music is on or off. The shape still does not change — the silent
     wave is simply held back in the same way the rest of the chrome
     holds an inactive control, and `aria-pressed` carries the state for
     anyone not reading the pixels at all. */
  #snd.on .snd-travel{animation:none}
  #snd:not(.on) path{opacity:.45}
}

/* The mark itself — its geometry, its tracking and what hover does to it
   — is core/mark.css, imported at the top of this file. All that is left
   here is the one thing that is this stylesheet's business rather than
   the mark's: the grey it rests at on an interior page. */
#brand a{color:var(--haze)}

/* The menu button: three rules that fold into the mark's own Λ and V.
   Each rule is two halves hinged at the icon's centre line, so the
   chevrons are literally made of the lines that were there before —
   nothing fades out and something else fades in. */
#menuBtn{display:block;padding:0;color:var(--haze)}
/* square, so the folded mark can be turned 90° without distorting */
.burger{
  display:block;position:relative;
  width:var(--menu-box);height:var(--menu-box);
  transition:transform var(--dur-slow) var(--ease);
}
.burger i{position:absolute;left:0;width:100%;height:1px;display:flex}
.burger b{
  flex:1;height:1px;background:currentColor;
  transition:transform var(--dur-slow) var(--ease),opacity var(--dur-med) var(--ease);
}
/* r1 on the box's top edge, not 1px inside it: the box is the glyph, and
   the rules sit symmetric about the centre line the fold hinges on */
.burger .r1{top:0}
.burger .r2{top:calc(50% - .5px)}
.burger .r3{bottom:0}
.burger .r1 b:first-child,.burger .r2 b:first-child,.burger .r3 b:first-child{transform-origin:100% 50%}
.burger .r1 b:last-child, .burger .r2 b:last-child, .burger .r3 b:last-child {transform-origin:0 50%}
/* returning to three rules: the outer pair flattens at once, the middle
   one comes back last, after they are out of its way */
.burger .r2 b{transition-delay:.16s}

/* THE FOLD — r1 hinges up into Λ, r3 hinges down into V, r2 retracts to
   the centre line first so the two chevrons have room to swing.
   Hovering previews the fold; opening the menu commits to it. */
#menuBtn:hover         .r1 b:first-child, #menuBtn:hover         .r3 b:last-child,
#menuBtn:focus-visible .r1 b:first-child, #menuBtn:focus-visible .r3 b:last-child,
body.menu-open         .r1 b:first-child, body.menu-open         .r3 b:last-child{
  transform:rotate(-38deg);transition-delay:.09s;
}
#menuBtn:hover         .r1 b:last-child, #menuBtn:hover         .r3 b:first-child,
#menuBtn:focus-visible .r1 b:last-child, #menuBtn:focus-visible .r3 b:first-child,
body.menu-open         .r1 b:last-child, body.menu-open         .r3 b:first-child{
  transform:rotate(38deg);transition-delay:.09s;
}
#menuBtn:hover .r2 b,#menuBtn:focus-visible .r2 b,body.menu-open .r2 b{
  transform:scaleX(0);opacity:0;transition-duration:var(--dur-fast);transition-delay:0s;
}

/* THE TURN — opening lays the folded mark on its side. Λ sits at the top
   so it swings to the right and points right; V sits at the bottom so it
   swings to the left and points left. Together: < > */
body.menu-open .burger{transform:rotate(90deg)}

/* ---------- the rail: the menu without opening the menu -----------------
   Resting on the burger drops the same routes down the right edge, in
   micro-label type. Clicking still opens the full overlay, and the two
   are deliberately in different registers: the overlay is a statement —
   huge Outfit extralight over a blurred field, one decision at a time —
   and the rail is a shortcut, small and out of the way, for somebody
   who already knows where they are going.

   Pure CSS. #menuZone holds both, so resting on either the button or
   the rail keeps it out; the rail's transparent top padding covers the
   distance between them so the pointer never crosses dead ground on the
   way down. The zone itself takes no pointer, or its empty box would
   open the rail from a corner of the screen nothing is drawn in.

   Mirrored inline in index.html, which carries its own copy of the
   chrome. Change one and change the other.                            */
#menuZone{pointer-events:none}
#menuZone > *{pointer-events:auto}

/* ONE RHYTHM FROM THE BUTTON OUT. The distance from the burger's ink to
   the first word is the distance between any two words, stacked or
   ranged, and it is DERIVED rather than restated, so the two cannot
   drift apart again. Measured to the ink, which is why each word's line
   is trimmed to its capitals below (text-box): otherwise the leading
   under one row and over the next sits between two words but not
   between the button and the first, and "equal" numbers draw unequal
   gaps. What is left over is --rail-pad, each word's own padding —
   between two rows there is one word's bottom padding that there is
   none of under the button, so the drop is the gap plus that. */
#rail{
  --rail-gap:  13px;                      /* stacked: between rows */
  --rail-pad:  2px;                       /* each word's padding, top and bottom */
  --rail-span: clamp(22px,2.8vw,44px);    /* ranged: between words */
  --rail-drop: calc(var(--rail-gap) + var(--rail-pad));  /* stacked: the bridge back to the button */
  position:absolute;top:100%;right:0;
  display:flex;flex-direction:column;align-items:flex-end;
  gap:var(--rail-gap);
  padding-top:var(--rail-drop);
  margin:0;list-style:none;
  visibility:hidden;pointer-events:none;
}
/* ONE STEP UP THE SCALE, from --fs-000 to --fs-00. The rail was set at
   the size the site uses for meta — tech tags under a tile, a year in a
   spec list — and it is not meta: it is the navigation, and the only
   navigation a visitor who never clicks the button will ever use. At
   10px uppercase it read as a caption of the menu rather than as the
   menu. 12px is where the site sets everything else you are meant to
   act on, and it is the same step the back-link and the tile title sit
   at. Nothing else about it changes — it is still small, still tracked
   out, still out of the way. */
/* line-height:1 because this page's body is 1.65 and the rail used to
   inherit it — which set the interior pages' rows eight pixels looser
   than the home page's, whose body is 1. text-box then trims the line
   to the capitals, so the gaps above are gaps between letters. Where
   text-box is not supported the line stays 1 tall and the rhythm is
   out by the descent under the capitals, about a pixel and a half. */
#rail a{
  display:block;padding:var(--rail-pad) 0;
  line-height:1;text-box:trim-both cap alphabetic;
  font-size:var(--fs-00);font-weight:500;
  letter-spacing:var(--track-ui);text-transform:uppercase;
  color:var(--haze-dim);white-space:nowrap;
  opacity:0;transform:translateX(9px);
  transition:opacity var(--dur-med) var(--ease),
             transform var(--dur-med) var(--ease),
             color var(--dur-med) var(--ease);
}

/* Hover only, and never focus — a second copy of the same links has no
   business in the tab order.

   `body:not(.menu-open)` is load-bearing rather than tidy: the overlay
   is opened by CLICKING the button, which means the pointer is still
   resting on it afterwards, and a plain #menuZone:hover would keep the
   rail out beside the open overlay — the same four words twice on one
   screen. The condition says the real rule: the rail is what the menu
   looks like while it is shut. */
/* SHOWN while the pointer is actually on the button — `:hover` — OR
   while `.rail-hold` says it recently was. That second class is what
   lets the rail hold exactly as it was for a few seconds after the
   pointer leaves before it starts to go, so lifting off the button to
   travel down to the words themselves is not mistaken for being done
   with it. It is set and cleared by a plain JS timer in mountChrome()
   (core/site.js), not by a CSS transition-delay — a long delay-only
   transition that a quick re-hover keeps interrupting and restarting is
   exactly the shape of transition some browsers have handled
   inconsistently, and the failure mode was not a glitch but the rail
   going unresponsive until the page reloaded. A timer the mouse
   re-entering simply clears has no state of that kind to get stuck in. */
body:not(.menu-open) #menuZone:hover #rail,
body:not(.menu-open) #menuZone.rail-hold #rail{visibility:visible;pointer-events:auto}
body:not(.menu-open) #menuZone:hover #rail a,
body:not(.menu-open) #menuZone.rail-hold #rail a{opacity:1;transform:none}
/* They come out in the order they are reached, nearest the button
   first, so the movement reads as one thing unrolling rather than four
   words appearing. Stacked, that is top to bottom. */
#rail li:nth-child(1) a{transition-delay:.02s}
#rail li:nth-child(2) a{transition-delay:.06s}
#rail li:nth-child(3) a{transition-delay:.10s}
#rail li:nth-child(4) a{transition-delay:.14s}
#rail li:nth-child(5) a{transition-delay:.18s}
/* Going is one gentle fade, all at once, rather than the snap a click
   dismisses it with — and only once the hold above has genuinely been
   let go of, not the instant the pointer happens to leave.
   `body:not(.menu-open)` stays load-bearing: without it this would also
   delay the rail's exit when the OVERLAY opens, and the overlay has its
   own instant rule for that a few lines down. */
body:not(.menu-open) #menuZone:not(:hover):not(.rail-hold) #rail{
  transition:visibility 0s linear var(--rail-fade),
             pointer-events 0s linear var(--rail-fade);
}
body:not(.menu-open) #menuZone:not(:hover):not(.rail-hold) #rail a{
  transition-delay:0s;
  transition-duration:var(--rail-fade);
}

/* ---------- ranged across, at desktop width -----------------------------
   Above 900px — the same width at which the overlay stops stacking — the
   rail lays itself along the top edge instead of hanging under the
   button: one row, running LEFT from the burger, the words evenly spaced
   and sitting on the button's own centre line.

   Even GAPS rather than even slots. Setting the four in equal-width
   columns would put "Info" adrift in the middle of its share while
   "Projects" filled its own, and the eye reads the space between words,
   not the boxes around them.

   The stagger reverses with the direction: the last item is now the one
   nearest the button, so it is the one that arrives first and the row
   unrolls away from the burger rather than toward it.

   COUNTED FROM THE END, not from the start. `nth-child(5)` first was
   only correct while there were five routes — with three it left the two
   quickest delays on rows that do not exist and started the whole row a
   tenth of a second late, from the wrong end. nth-last-child(1) is the
   item nearest the button whatever the list is, which is the thing this
   rule is actually about.

   The bridge back to the button is --rail-span itself, for the same
   reason the stacked drop is the gap: the button is the row's last
   member, and the space before it is the space between any two words.
   Each word carries its trailing tracking into the gap after it, the
   one nearest the button included, so the two gaps match to the ink. */
@media (min-width:900px){
  #rail{
    top:50%;right:100%;
    transform:translateY(-50%);
    flex-direction:row;align-items:center;
    gap:var(--rail-span);
    padding:0 var(--rail-span) 0 0;
  }
  #rail li:nth-last-child(1) a{transition-delay:.02s}
  #rail li:nth-last-child(2) a{transition-delay:.06s}
  #rail li:nth-last-child(3) a{transition-delay:.10s}
  #rail li:nth-last-child(4) a{transition-delay:.14s}
  #rail li:nth-last-child(5) a{transition-delay:.18s}
}

#rail a:hover{color:var(--signal)}
#rail a[data-current="page"]{color:var(--haze)}
/* attention is exclusive here too, the same way it is in the overlay */
#rail:hover a:not(:hover){color:var(--haze-dim);opacity:.45}

/* The overlay wins: opening the menu is a bigger gesture than resting on
   the button, and having both out at once would read as a bug. */
body.menu-open #rail{visibility:hidden;pointer-events:none}
body.menu-open #rail a{opacity:0;transform:translateX(9px);transition-delay:0s}

/* A finger has no hover, so on touch the rail does not exist and the
   burger keeps its one job. */
@media (hover:none),(pointer:coarse){
  #rail{display:none}
}

/* the menu overlay */
#scrim{
  position:fixed;inset:0;z-index:20;
  background:radial-gradient(130% 100% at 50% 62%,rgba(8,12,18,.5) 0%,rgba(4,6,10,.82) 68%);
  backdrop-filter:blur(9px);-webkit-backdrop-filter:blur(9px);
  opacity:0;pointer-events:none;transition:opacity var(--dur-med) var(--ease);
}
body.menu-open #scrim{opacity:1;pointer-events:auto}

/* The panel spans the viewport to centre its items, so it must stay
   transparent to the pointer — only the links themselves take clicks.
   Otherwise every click on the page would land on the panel and the
   click-anywhere-to-close gesture could never fire. */
/* Stacked on a phone, ranged across the screen on a desktop. Three long
   words side by side on a 375px screen would either wrap mid-list or
   shrink below a comfortable tap target; stacked, each one keeps its
   size and its 44px of height. */
#menu{
  position:fixed;inset:0;z-index:25;
  display:flex;flex-direction:column;
  align-items:center;justify-content:center;
  gap:clamp(10px,2.4vh,26px);
  padding:0 var(--page-pad);
  pointer-events:none;visibility:hidden;
  transition:visibility 0s linear var(--dur-med);
}
@media (min-width:900px){
  #menu{flex-direction:row;flex-wrap:wrap;gap:clamp(20px,5vw,84px)}
}
body.menu-open #menu{visibility:visible;transition-delay:0s}

/* The wrapper takes the pointer and holds the perspective; the anchor
   inside it is what core/tilt.js turns. The entry animation therefore
   lives on the WRAPPER — if it shared `transform` with the anchor, the
   tilt would fight the arrival. */
.menu-item{
  display:block;pointer-events:auto;
  opacity:0;transform:translateY(14px);
  transition:opacity var(--dur-med) var(--ease),transform var(--dur-med) var(--ease);
}
body.menu-open .menu-item{opacity:1;transform:none}
body.menu-open .menu-item:nth-child(1){transition-delay:.06s}
body.menu-open .menu-item:nth-child(2){transition-delay:.12s}
body.menu-open .menu-item:nth-child(3){transition-delay:.18s}
body.menu-open .menu-item:nth-child(4){transition-delay:.24s}

/* THE MENU ITEM'S SIZE — one rule, one size, every item, every
   breakpoint: the clamp below is the only font-size the overlay has.
   There is no per-item size here or anywhere else, and there never was.

   EQUAL BOXES ARE NOT ENOUGH, THOUGH. All three are set at the same
   size and always were — what made STUDIO look like the small one is
   that it is a shorter word, and at the same size a six-letter word
   lays down a third less ink than an eight-letter one. Stacked and
   centred on a phone, that reads as a smaller word no matter how even
   the boxes around it are.

   So the box floor here is now only the pre-script fallback, and the
   real fit is core/menu-fit.js: it tracks each label out until all
   three span the same measure, which is the sense in which the eye was
   asking for them to be the same size. Read that file before changing
   anything below — these three declarations and that script are one
   mechanism, and the script's inline tracking wins over them.

   The floor is stated in `em` rather than px so it holds at every step
   of the font-size clamp, and was set from the longest label the menu
   has carried, PROJECTS, at 6.23em with its tracking and padding — so
   6.3em clears STUDIO, WORKS and ABOUT with room to spare.

   The half-space indent is the other half of the job: letter-spacing is
   applied after the LAST letter as well, so a centred word carries a
   trailing space inside its line box and sits half a space left of true
   centre. Half the tracking puts the ink back where the eye reads it. */
#menu a{
  display:block;padding:.2em .3em;
  min-width:6.3em;text-align:center;text-indent:.045em;
  font-family:var(--font-title);
  font-size:clamp(22px,3.6vw,44px);
  font-weight:var(--wt-title);
  text-transform:uppercase;letter-spacing:.09em;
  color:var(--haze);
  transition:color var(--dur-med) var(--ease);
  will-change:transform;
}
#menu a[aria-current="page"]{color:var(--signal)}
/* the brightness half of the gesture — the lean (core/tilt.js) was
   always here, the word itself lifting to full ink under the pointer
   was not, and a menu that only tilts under a hand reads as decoration
   rather than as something the hand is doing anything to. */
#menu a:hover,#menu a:focus-visible{color:var(--signal)}
/* attention is exclusive: reading one item recedes the others */
#menu:hover .menu-item:not(:hover),
#menu:focus-within .menu-item:not(:focus-within){opacity:.28}


/* ---------- the footer — Studio, Works and every single work -------
   The one piece of chrome that is not a fixed corner: it sits in the
   document, after whichever page put it there (see mountFooter in
   core/site.js), so scrolling to the end of the rack, the reel or a
   piece's own wall always finds it. Mounted only on those three pages —
   the labs pieces and the settings pages have nowhere for a foot to go,
   and putting one on every page would make it furniture instead of an
   ending.

   DATA-DRIVEN off content/pages.json's own "Elsewhere" section — the
   same directory the Contact page reads — so an account fills in there
   and is live here on the next reload, and one with no href yet stays
   out of both. The one line above the icons is the part that is never
   empty, so the footer itself always has something to say even before
   the first account does. Concise on purpose: a line, then a row of
   icons and nothing else, the name only as the thing a screen reader
   hears. */
/* position + z-index for the reason #main has them: the background
   (#backdrop, fixed at z-index 0) is painted over anything in the flow
   that is not positioned. The footer went without, and so its line and
   icons were drawn and then covered on every page that had one — only
   its positioned children (the sound's and Contact's own boxes) showed
   through. */
#siteFoot{
  position:relative;z-index:1;
  border-top:1px solid var(--line);
  padding:var(--sp-6) var(--page-pad) calc(var(--sp-6) + env(safe-area-inset-bottom,0px));
  text-align:center;
}
.foot-line{
  margin:0 0 var(--sp-4);
  font-size:var(--fs-00);color:var(--haze-dim);letter-spacing:var(--track-tight);
}
.foot-social{
  display:flex;justify-content:center;flex-wrap:wrap;
  gap:var(--sp-2);
  margin:0;padding:0;list-style:none;
}
/* padding rather than a shared hit-box rule, so this stays local to the
   footer: 13px around an 18px glyph clears the 44px touch target. */
.foot-social a{display:block;padding:13px;color:var(--haze)}
.foot-social svg{
  display:block;width:18px;height:18px;
  fill:none;stroke:currentColor;stroke-width:1.6;
  stroke-linecap:round;stroke-linejoin:round;
}
/* the three marks drawn as a solid glyph rather than an outline —
   GitHub's cat, Vimeo's and Behance's own logotypes read better filled */
.foot-social svg.is-fill{fill:currentColor;stroke:none}

/* ON A SINGLE WORK the sound and Contact come down off the screen's two
   bottom corners and end the page in the foot instead — see
   bringCornersDown() in core/site.js for why, and why it is only there.
   They keep the sides they held as corners: the sound at the left, Contact
   at the right, with the foot's own line and icons between them. On a
   phone the line and icons take the full width and the two controls
   share the row under them, still left and right.

   #siteFoot #contactZone, two ids, because core/contact.css fixes
   #contactZone by id and this has to outrank it. */
#siteFoot.has-corners{
  display:grid;
  grid-template-columns:1fr auto 1fr;
  grid-template-areas:"snd line contact" "snd social contact";
  align-items:center;column-gap:var(--sp-5);
}
#siteFoot.has-corners .foot-line{grid-area:line}
#siteFoot.has-corners .foot-social{grid-area:social}
#siteFoot.has-corners #dock{grid-area:snd;justify-self:start}
#siteFoot #contactZone{
  position:static;grid-area:contact;justify-self:end;
  margin:0;
}
@media (max-width:640px){
  #siteFoot.has-corners{
    grid-template-columns:1fr 1fr;
    grid-template-areas:"line line" "social social" "snd contact";
    row-gap:var(--sp-4);
  }
}

/* ---------- page shell -------------------------------------------------- */
.page{
  max-width:var(--content-max);
  margin:0 auto;
  padding:max(var(--sp-8), var(--chrome-clear)) var(--page-pad) var(--sp-9);
}
.page-head h1{
  font-size:var(--fs-3);font-weight:300;line-height:var(--lh-tight);
  color:var(--signal);letter-spacing:var(--track-tight);
}
.page-lede{
  margin-top:var(--sp-4);max-width:62ch;
  font-size:var(--fs-1);color:var(--haze);line-height:var(--lh-body);
}
.back-link{
  display:inline-block;font-size:var(--fs-00);font-weight:500;
  letter-spacing:var(--track-ui);text-transform:uppercase;
  color:var(--haze);margin-bottom:var(--sp-6);
}

/* ---------- the Studio rack --------------------------------------------
   /labs is one word, then two tiles to a row, then a pager down the
   outer edges. The page is wide — roughly a tenth of the viewport as
   margin on each side rather than a fixed content column — because the
   render frames are the content and they want the room.

   The whole page has one idea about hovering, applied at two scales:
   point at something and everything else steps back, so that the space
   the neighbour was using is free for what you are actually reading.
   core/studio.js drives it; the two numbers behind it (--focus-dim and
   --focus-blur) are written onto #studio from that file's CONFIG.
========================================================================= */
/* Qualified with .page on purpose: both are single-class selectors, so
   an unqualified .page-studio ties with .page and loses to any .page rule
   that comes later in the file — which is exactly what the 640px block
   below was doing to the mobile padding. */
.page.page-studio{
  max-width:none;
  padding:var(--chrome-clear) clamp(24px,10vw,200px) clamp(64px,10vh,140px);
}

/* ---------- the head ----------------------------------------------------
   One word, and the note about this section held beside it until asked
   for. The note keeps its box at rest — only its ink goes — so opening
   it never moves the rack underneath. */
.studio-head{margin-bottom:clamp(40px,6vw,88px)}
/* inline-block on purpose: this is the hover target AND the perspective
   host for the turn, so its box has to be the word rather than the full
   width of the header line. */
.studio-title{
  display:inline-block;
  font-family:var(--font-title);
  font-size:clamp(28px,3.2vw,48px);font-weight:var(--wt-title);line-height:1.04;
  text-transform:uppercase;
  color:var(--signal);
  /* capitals need the tracking opened; the tight setting a lowercase
     heading wants would close them up into a block */
  letter-spacing:.055em;
  cursor:default;
}
.studio-word{display:inline-block;will-change:transform}
.studio-word:focus-visible{outline:1px solid var(--line-hot);outline-offset:8px}

/* One line about the section, under the word. IT IS ALWAYS THERE — it
   used to be held back and faded in on hover, which meant the section
   arrived unexplained and the explanation had to be found. A single line
   of standing copy is not clutter; a page that withholds it is.

   WHAT HOVER DOES NOW IS WEIGHT IT. Pointing at the word sets the line
   in Syne bold and lifts it to the page's primary ink — the gesture
   still answers, but the answer is emphasis rather than existence.

   THE BOX MUST NOT MOVE WHEN IT DOES, because the rack is underneath it.
   Only the ink is allowed to change: the line is short enough to be
   drawn at either weight without rewrapping, and `min-height` holds the
   two lines it occupies open so a longer lede set in pages.json cannot
   quietly turn a hover into a jolt. If a lede ever does need three
   lines, raise the 2 here with it.

   `pointer-events:none` stays. The note is what the WORD says, not a
   second thing to point at — hovering the line itself must not hold open
   a state the word has already let go of. */
.studio-note{
  margin-top:var(--sp-3);
  max-width:52ch;
  min-height:calc(var(--lh-body) * 2em);
  font-size:var(--fs-00);color:var(--haze);line-height:var(--lh-body);
  letter-spacing:.02em;
  pointer-events:none;
  transition:color var(--dur-med) var(--ease);
}
/* Weight does not interpolate between two static instances of a face, so
   this lands rather than eases — which is right: it is an answer, not a
   fade. The colour under it carries the easing. */
.studio-head.is-open .studio-note{
  color:var(--signal);
  font-weight:700;
}

/* ---------- the rack ----------------------------------------------------- */
.rack{
  position:relative;          /* the note is positioned against this */
  display:grid;
  grid-template-columns:minmax(0,1fr);
  gap:clamp(40px,7vw,72px);
}
@media (min-width:900px){
  .rack{
    grid-template-columns:repeat(2,minmax(0,1fr));
    column-gap:clamp(44px,8vw,160px);
    row-gap:clamp(56px,8vw,116px);
  }
}

.grid-status{
  grid-column:1/-1;
  padding:var(--sp-7) 0;
  text-align:center;
  font-size:var(--fs-00);letter-spacing:var(--track-ui);
  color:var(--haze-dim);
}
[data-state="error"] .grid-status{color:var(--signal)}
[data-state="error"] .grid-status::before{content:"// "}

/* ---------- a tile -------------------------------------------------------
   The frame is the whole tile. Nothing is drawn on top of it, nothing
   moves when the pointer arrives — what happens instead is that the rest
   of the page leaves. */
.tile{
  position:relative;display:block;
  transition:opacity var(--dur-med) var(--ease),filter var(--dur-med) var(--ease);
}
.studio[data-focus] .tile:not(.is-focus){
  opacity:var(--focus-dim,.16);
  filter:blur(var(--focus-blur,3px));
}
.tile.is-placeholder{opacity:.72}
/* the tile comes forward on hover, so it has to be able to cross over
   its neighbour rather than be clipped behind it */
.tile.is-focus{z-index:3}

/* What actually turns. The perspective is written onto .tile by
   core/studio.js from its CONFIG; this element carries the transform, so
   nothing in this file may set `transform` on it. The caption is inside
   it, which is what makes the words turn with the frame. */
.tile-inner{
  position:relative;display:block;
  transform-style:preserve-3d;
  will-change:transform;
}
.tile-cap{will-change:transform}
.tile:focus-visible{outline:1px solid var(--line-hot);outline-offset:6px}

.media-frame{
  position:relative;
  aspect-ratio:var(--ratio-card);
  background:var(--grad-1);
  overflow:hidden;
}
/* The panel is the only thing on the page that casts one. It sits inside
   the element that turns, so the shadow turns with it — which is the
   point of it being here rather than on the tile: a shadow that stayed
   flat under a panel leaning away from you would flatten the lean. */
.tile-frame{
  aspect-ratio:var(--ratio-panel);
  box-shadow:var(--tile-shadow);
  transition:box-shadow var(--dur-med) var(--ease);
}
.tile.is-focus .tile-frame,
.tile.is-picked .tile-frame{box-shadow:var(--tile-shadow-lift)}
/* A picked tile's picture can be drawn on with a finger (core/cover.js,
   A FINGER ON A PICKED TILE). It used to keep `pan-y pinch-zoom` so a
   thumb could still scroll the page over the picture; the page behind a
   picked tile does not scroll any more, and the picture now has a
   gesture of its own to protect. See `touch-action:none` in the picked
   panel's own block below. */
.page-project .media-frame,
.media-frame.ratio-hero{aspect-ratio:var(--ratio-hero)}
.rack .tile:nth-child(3n+1) .media-frame{background:var(--grad-1)}
.rack .tile:nth-child(3n+2) .media-frame{background:var(--grad-2)}
.rack .tile:nth-child(3n+3) .media-frame{background:var(--grad-3)}

.media-frame img,.media-frame video{
  position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
  opacity:0;transition:opacity var(--dur-med) var(--ease);
  /* --fx-scale — a Studio tile's own zoom (see the Studio editor,
     core/studio-edit.js), inherited down from the frame it is set on
     rather than written onto whichever element happens to be the cover
     at the moment: what draws the cover can change under this rule and the
     zoom still holds. 1 is unset. */
  transform:scale(var(--fx-scale,1));
}
.media-frame img.media-loaded,.media-frame video.media-loaded{opacity:1}
/* A cover that isn't on disk yet must stay invisible, not fall back to its
   alt text drawn over the gradient — render.js flags it on the error event. */
.media-frame img.media-broken,.media-frame video.media-broken{display:none}
.media-frame .media-badge{
  position:absolute;left:var(--sp-4);bottom:var(--sp-4);z-index:6;
  font-size:var(--fs-000);letter-spacing:var(--track-wide);text-transform:uppercase;
  color:var(--haze-dim);
}

/* ---------- the render preview -------------------------------------------
   The clip at /assets/preview/<slug>.mp4, mounted by core/studio.js. It IS
   the tile's cover: it comes up paused on one frame of itself as soon as
   that frame has decoded, runs while the tile is pointed at, and is PAUSED
   rather than rewound when the pointer leaves — so the frame it was left
   on becomes the cover in turn. */
.media-frame .tile-preview{
  position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
  z-index:2;opacity:0;
  transition:opacity var(--dur-med) var(--ease);
}
.media-frame.has-preview .tile-preview{opacity:1}

/* ---------- the drawn cover ----------------------------------------------
   core/cover.js draws a small generative picture of what each piece is
   ABOUT into this canvas: one ruled hairline field put through a different
   transform per piece, resting as a still frame and setting itself in
   motion under the pointer. It is the cover — there is no file behind a
   Studio tile any more, which is why nothing here waits on one.

   The clip that used to take this slot is turned off in core/studio.js
   (PREVIEW.ENABLED); the rule below is what would happen if it were
   turned back on — the drawing fades out under the clip's own frame and
   is then removed outright, so one tile never carries two ideas of what
   it is. */
.media-frame canvas.cover-art{
  position:absolute;inset:0;z-index:1;
  width:100%;height:100%;display:block;
  pointer-events:none;
  /* the same --fx-scale the picture takes, for the same reason: what
     draws a cover can change under this rule and the tile's own zoom
     still holds */
  transform:scale(var(--fx-scale,1));
  transition:opacity var(--dur-med) var(--ease);
}
.media-frame.has-preview canvas.cover-art{opacity:0}
/* A cover image, where a tile still names one, sits far back under the
   drawing rather than competing with it. */
.media-frame.has-cover img.media-loaded{opacity:.16}
.tile.is-focus .media-frame.has-cover img.media-loaded{opacity:.06}

/* ---------- the cover, as a sheet of glass --------------------------------
   Only in the rack. A project's cover anywhere else is a picture and is
   left alone; here it is the FACE OF A PANEL, and that difference is the
   whole of this block.

   Four layers, back to front:

     the backing   the panel's own dark gradient, at --cover-ground. Below
                   1 the glass sheet running behind the page comes
                   through it, which is what makes a cover read as a
                   window onto the same material the rest of the site is
                   made of rather than as a card lying on top of it.
     the picture   set a little back — --cover-opacity, and a touch of
                   saturation taken out, the way glass takes colour out.
     the veil      a wash of the page's own ground over the picture, so a
                   bright render settles into the page without the file
                   itself having to be darkened.
     the gloss     one highlight, which TRAVELS. --tilt-x and --tilt-y are
                   written by core/tilt.js off the same spring that turns
                   the panel — see GLINT there — so the reflection moves
                   with the surface rather than after it. A highlight that
                   only brightens reads as a lamp; one that moves reads as
                   glass.

   Every value is a custom property whose shipped value is in the tokens
   block at the top of this file, and core/covers.js writes the visitor's
   own over them. Nothing here states a number that is not a fallback. */

/* The panel's backing: the same three gradients the rest of the site
   uses, with --cover-ground as their alpha.

   WRITTEN OUT HERE RATHER THAN BY REDEFINING --grad-1..3 ON .rack, which
   was the first version of this and was quietly wrong. A custom property
   whose value contains var() has that var substituted ON THE ELEMENT
   THAT DECLARES IT, not where the token is finally used — so a --grad-1
   declared on .rack baked in .rack's own --cover-ground and every panel
   below it drew with that one number. It looks right until two panels on
   one page are supposed to differ, which is exactly what /covers puts
   side by side.

   Same specificity as the three rules above and later in the file, so
   these win inside a rack and every other page keeps the opaque ones. */
.rack .tile:nth-child(3n+1) .media-frame{
  background:linear-gradient(160deg,
    rgba(18,21,26,var(--cover-ground)) 0%,
    rgba(5,7,10,var(--cover-ground)) 72%);
}
.rack .tile:nth-child(3n+2) .media-frame{
  background:radial-gradient(120% 130% at 18% 12%,
    rgba(23,27,33,var(--cover-ground)) 0%,
    rgba(5,7,10,var(--cover-ground)) 68%);
}
.rack .tile:nth-child(3n+3) .media-frame{
  background:linear-gradient(200deg,
    rgba(15,18,22,var(--cover-ground)) 0%,
    rgba(6,8,11,var(--cover-ground)) 55%,
    rgba(18,22,27,var(--cover-ground)) 100%);
}

/* The picture. :not(.has-cover) keeps the SHARED GLASS LOOK off the tiles
   showing a drawn cover — those already hold their image at a deliberate
   .16 and this would fight it. */
.rack .tile-frame:not(.has-cover) img.media-loaded,
.rack .tile-frame.has-preview .tile-preview{
  opacity:var(--cover-opacity);
  /* --fx-filter — a Studio tile's own blur/brightness/contrast (see
     core/studio-edit.js). On the picture ITSELF, appended after the
     shared glass look rather than replacing it, so it is clipped by the
     frame's own overflow:hidden the same way the picture always was and
     can never bleed past the frame's edge or reach the badge text sitting
     beside it in the same frame — a filter on the frame, rather than on
     what it is framing, is exactly what did both of those. Empty string
     by default, which appends nothing. */
  filter:saturate(var(--cover-sat)) blur(var(--cover-blur)) var(--fx-filter,);
}
/* --fx-filter ON A DRAWN COVER, which the rule above cannot carry: every
   Studio tile is .has-cover, so its :not() took the editor's Blur,
   Brightness and Contrast off precisely the covers they were built for
   and the three sliders moved nothing at all.

   Applied to the drawing AND to the picture behind it, because on these
   tiles those two together ARE the cover — blurring one and not the
   other would leave a sharp drawing over a soft ground, which is the one
   result nobody reaching for a blur wants. */
.rack .tile-frame.has-cover canvas.cover-art,
.rack .tile-frame.has-cover img.media-loaded{
  filter:var(--fx-filter,);
}

/* THE INCOMING WORD. A tile deliberately held back is not a tile with a
   small note in its corner — it is a tile whose whole face says so. Only
   .is-incoming takes this: "Coming soon" is a state a tile passes
   through on its way to being finished and keeps the corner label, while
   "Incoming" is a decision, and a decision belongs on the cover. */
.rack .tile.is-incoming .media-frame .media-badge{
  left:0;right:0;bottom:auto;top:50%;
  transform:translateY(-50%);
  display:flex;align-items:center;justify-content:center;
  padding:0 var(--sp-4);
  text-align:center;
  font-family:var(--font-title);
  font-size:clamp(22px,5.2vw,40px);
  font-weight:300;
  line-height:1;
  letter-spacing:var(--track-wide);
  color:var(--warm);
  opacity:.86;
  pointer-events:none;
}

/* A DRAFT — `draft: true` in content/labs.json. A visitor never sees
   one: core/render.js does not build the tile off this machine. Served
   locally it is built, so the editor can find it and turn it back on,
   and it is dimmed and badged so that nobody reads it as published —
   the same treatment the reel editor gives a drafted project
   (.reel-slide[data-draft] in core/reel.css). The badge is its own
   class and not .media-badge: that one belongs to Incoming and Coming
   soon, and the editor rewrites it whenever either changes. */
/* the drawing only — a picture behind a drawn cover already sits at .16,
   and naming it here as well would out-rank that and brighten it; and
   not once a render clip has taken the tile, where the drawing is at 0 */
.rack .tile.is-draft .tile-frame:not(.has-preview) canvas.cover-art{opacity:.45}
.rack .tile.is-draft .tile-cap{opacity:.55}
.tile-frame .tile-draft-badge{
  position:absolute;left:var(--sp-4);top:var(--sp-4);z-index:6;
  font-size:var(--fs-000);letter-spacing:var(--track-wide);text-transform:uppercase;
  color:var(--warm);
  pointer-events:none;
}

.rack .tile-frame img,
.rack .tile-frame video{object-fit:var(--cover-fit)}

/* THE FRAME CONTAINS ITS OWN LIGHT. `isolation` makes it a stacking
   context, which is what keeps the gloss's `screen` blend below from
   reaching past the picture — the thing behind a tile on this site is a
   live shader, and a highlight blending with THAT rather than with the
   render is how a lit edge turns into a smear across the page. */
.rack .tile-frame{isolation:isolate}

/* The veil and the gloss are both inert to the pointer: the panel is one
   link, and neither may take a press or a hover off it. Both are pinned
   to the frame and neither may be given a size or a transform of its
   own — see THE GLOSS below for what that cost the first time. */
.rack .tile-frame::before,
.rack .tile-frame::after{
  content:"";position:absolute;inset:0;
  pointer-events:none;
}
.rack .tile-frame::before{
  z-index:3;
  background:rgba(5,6,7,var(--cover-veil));
}

/* THE GLOSS — a band of light crossing the glass as the panel leans.

   IT IS A BACKGROUND THAT MOVES, NOT A BOX THAT MOVES, and that is the
   whole of this rule. The first version made the overlay half again as
   wide as the panel and sild it with a transform: correct on paper, and
   wrong in a browser. The panel it sits in is inside a preserve-3d
   subtree being rotated on the compositor, and an oversized child with
   its own transform and a mix-blend-mode is exactly the case where the
   parent's overflow clip stops being applied — so on a lean the
   highlight walked out past the edge of the picture, and because its
   transform is a main-thread style write while the panel's rotation is
   composited, it arrived a frame or two after the surface it is
   supposed to be lying on. A reflection that leaves the mirror and then
   lags behind it is worse than no reflection.

   Nothing moves now. The element is pinned to the frame at inset:0 and
   can never be anywhere else; what travels is `background-position` over
   a gradient drawn wider than its box. There is nothing to clip, so
   nothing can escape, and position and rotation are written in the same
   style pass.

   `isolation` on the frame above keeps the screen blend inside the
   picture — without it the blend reaches whatever is behind the tile,
   which on this site is a live shader.

   The travel is the same distance it always was: a 260%-wide gradient
   moved through `--cover-glide` percentage points covers 1.6 × that many
   percent of the panel, which at 16% is the 25.6% the old transform
   gave. */
.rack .tile-frame::after{
  z-index:4;
  mix-blend-mode:screen;
  opacity:calc(1 + var(--tilt-lift, 0) * .5);
  transition:opacity var(--dur-med) var(--ease);
  background-repeat:no-repeat;
  background-size:100% 100%, 260% 170%;
  background-position:
    0 0,
    calc(50% - var(--tilt-x, 0) * var(--cover-glide))
    calc(50% + var(--tilt-y, 0) * var(--cover-glide) * .4);
  background-image:
    /* the lit top edge, which is what gives the panel a thickness */
    linear-gradient(to bottom,
      rgba(255,243,230,var(--cover-rim)) 0,
      rgba(255,243,230,0) 1px),
    /* the highlight itself */
    linear-gradient(var(--cover-angle),
      rgba(255,255,255,0)                              calc(50% - var(--cover-spread)),
      rgba(255,243,230,var(--cover-gloss))             calc(50% - var(--cover-spread) * .28),
      rgba(216,224,231,calc(var(--cover-gloss) * .45)) calc(50% + var(--cover-spread) * .12),
      rgba(255,255,255,0)                              calc(50% + var(--cover-spread)));
}

/* A phone has no hover, so a panel at rest never catches the light at
   all — and the rack's covers would read as flat until something was
   pressed. The lit edge stays; the highlight is parked just off centre
   so every panel is caught at the same angle, the way a row of framed
   prints on one wall is. */
@media (hover: none){
  .rack .tile-frame::after{
    background-position:0 0, calc(50% + var(--cover-glide) * .55) 50%;
  }
}

/* ---------- the caption --------------------------------------------------
   Title and two or three keywords, centred under the frame. That is the
   whole tile — everything else waits to be asked for. */
.tile-cap{margin-top:clamp(20px,2.2vw,38px);text-align:center}
.tile-title{
  font-family:var(--font-title);
  font-size:13px;font-weight:600;
  letter-spacing:.13em;text-transform:uppercase;
  color:var(--signal);line-height:var(--lh-tight);
}
.tile-keys{
  margin-top:var(--sp-3);
  font-size:13px;color:var(--haze-dim);letter-spacing:.02em;
}

/* THE SLOT under the title: the keywords and, on a tile that goes
   somewhere, the "Tap to open" button — stacked in ONE grid cell, so the
   caption is as tall as the taller of the two whichever is showing and
   swapping them moves nothing. core/render.js builds it; core/studio.js
   puts the button in. */
.tile-slot{
  display:grid;justify-items:center;align-items:center;
  margin-top:var(--sp-3);
}
.tile-slot > *{grid-area:1 / 1}
.tile-slot .tile-keys{margin-top:0}

/* THE BUTTON, drawn. One look for the pointer's span and the touch
   panel's: the site's control shape (.set-control in core/settings.css)
   — a hairline box, no fill, the micro-label register — in the hot line
   and full ink, because this is the one thing on the tile to press. */
.tile-btn{
  display:inline-block;
  padding:var(--sp-2) var(--sp-3);
  border:1px solid var(--line-hot);
  border-radius:var(--radius);
  background:transparent;
  font:500 var(--fs-000)/1 var(--font-ui);
  letter-spacing:var(--track-ui);text-transform:uppercase;
  color:var(--signal);
  white-space:nowrap;
}

/* WITH A POINTER: the keywords give way to the button while the tile has
   focus (hover, or a keyboard's :focus-visible — both set .is-focus),
   and come back when it lets go. A crossfade in place, nothing moves. */
.tile-slot .tile-keys,
.tile-open{transition:opacity var(--dur-fast) var(--ease)}
.tile-open{
  opacity:0;
  transition-property:opacity,border-color;
}
/* the button goes in AHEAD of the keywords, so only a tile that has one
   loses its keywords — a placeholder under the pointer keeps them */
.tile.is-focus .tile-open ~ .tile-keys{opacity:0}
.tile.is-focus .tile-open{opacity:1}
/* the one quiet answer to the pointer being on the button itself */
.tile-open:hover{border-color:var(--signal)}

/* ---------- the picked tile (touch) --------------------------------------
   On a device with no hover, tapping a tile lifts it out of the rack:
   one scrim closes over everything else on the screen and the tile rises
   through it, with its note underneath. core/studio.js builds the scrim
   and writes the three numbers behind it from its own CONFIG.TOUCH.

   Deliberately NOT inside the 899px block. The touch path is chosen by
   `(hover: hover)`, not by width, so a tablet with no mouse gets this at
   any size the rack happens to be laid out at.

   Everything here rests on one fact about stacking: the scrim is a child
   of #studio, so it and the tiles share #main's context and a tile can
   out-rank it. The chrome cannot — it is fixed at z-index 40, outside
   #main entirely — so it is blurred directly instead. */
/* --pick-fade rather than --dur-med: this is a whole screen going out of
   focus, not a control changing state, and at the page's ordinary
   duration it lands as a slam. It runs at the same length in both
   directions — leaving is the one moment on this page that is not
   allowed to feel quicker than arriving, because the rack coming back
   into focus is the thing being watched. */
/* THE PICKED TILE'S CLIP, ON ITS OWN LAYER. The scrim below carries a
   backdrop-filter across the whole viewport, and the picked tile rises
   through it. A mobile compositor asked to keep a playing video in step
   with a backdrop it is also blurring will, on iOS in particular, stop
   updating the video's layer altogether: the clip runs, its currentTime
   advances, and what stays on screen is the frame it was on when the
   scrim came up. Promoting it takes it out of that negotiation.

   This is the drawing half of the bug; core/studio.js fixes the playback
   half — see RETRY_MS and the playsinline attributes there. */
.tile.is-picked .tile-preview{
  transform:translateZ(0);
  will-change:transform;
}

/* AND THE DRAWN COVER, for the same reason. The clip above is not the
   only thing that ends up in that negotiation — the cover is a canvas
   being repainted every frame, under the same scrim, and the tap that
   raises the scrim is the same tap that hands the cover a pointer. So
   the one moment the cover has something new to show is the one moment
   the compositor is most likely to stop showing it: the loop runs, the
   pointer eases toward the tap, the frame count climbs, and what stays
   on screen is the still it was on when the scrim came up.

   That is exactly how it was reported from a phone — three times, and
   never once reproduced under touch emulation on a laptop, which is
   what a compositor bug looks like from the other side.

   The clip got this in the same commit the bug was found in; the cover
   arrived afterwards and never got it. ?debug=cover&promote=0 takes it
   back off, so one device can watch the same tile with and without it
   (core/cover-debug.js). */
.tile.is-picked .cover-art{
  transform:translateZ(0);
  will-change:transform;
}

.pick-scrim{
  position:fixed;inset:0;z-index:20;
  opacity:0;pointer-events:none;
  background:rgba(5,6,7,var(--pick-tint,.52));
  -webkit-backdrop-filter:blur(var(--pick-blur,14px));
  backdrop-filter:blur(var(--pick-blur,14px));
  transition:opacity var(--pick-fade,760ms) var(--ease);
}
body.has-picked .pick-scrim{opacity:1;pointer-events:auto}

/* THE OPEN PANEL. The tile is pinned — core/studio.js holds the page
   still the moment pick()'s centring scroll lands — so everything here
   is about making the tile FIT the screen it is pinned to, rather than
   about what happens when it does not.

   One flex column from .tile-inner down. The picture, the title, the
   keywords and the open line all take their natural height and keep it;
   the description is the single flexible child and is handed whatever is
   left. min-height:0 on every link in that chain is what makes "whatever
   is left" mean anything at all — a flex item's floor is its content by
   default, so without it the description would refuse to be smaller than
   its text and push the picture off the top of the screen instead.

   Measured by the stylesheet against the viewport rather than by script
   against a rect: a phone with a different chrome height, or one turned
   sideways, is then a different answer to the same rule rather than a
   number that needs taking again. 100dvh, so the browser's own
   disappearing toolbar is already in the figure. */
.tile.is-picked{
  z-index:30;
  transform:translate3d(0,var(--pick-drag,0px),0);
  /* EVERY GESTURE ON AN OPEN TILE IS THE PANEL'S. Without this the
     browser claims a downward drag for itself the moment it decides the
     touch is a pan — and a claimed gesture arrives as pointercancel, so
     the swipe that should have closed the panel simply stopped halfway
     with nothing to show for it. There is nothing behind the tile to
     pan anyway: the page is pinned. The one exception is the
     description, which gets pan-y back below. */
  touch-action:none;
}
/* The spring back from a swipe that did not go far enough. Only while
   NOT dragging, so the panel tracks the finger exactly on the way down
   and eases only on the way home. */
.tile.is-picked:not(.is-dragging){
  transition:transform var(--dur-med) var(--ease);
}

.tile.is-picked .tile-inner{
  /* the air the panel keeps off the top and bottom of the screen, plus
     whatever the phone itself claims for a notch or a home bar */
  --pick-gutter:20px;
  --pick-safe:calc(env(safe-area-inset-top, 0px) + env(safe-area-inset-bottom, 0px));
  display:flex;flex-direction:column;
  max-height:calc(100vh  - var(--pick-gutter) * 2 - var(--pick-safe));
  max-height:calc(100dvh - var(--pick-gutter) * 2 - var(--pick-safe));
}
.tile.is-picked .tile-frame{flex:0 1 auto;min-height:0}
.tile.is-picked .tile-cap{
  display:flex;flex-direction:column;
  flex:1 1 auto;min-height:0;
}
.tile.is-picked .tile-title,
.tile.is-picked .tile-slot{flex:0 0 auto}
.tile.is-picked .tile-note{
  flex:1 1 auto;min-height:0;
  display:flex;flex-direction:column;
}
.tile.is-picked .note-scroll{
  flex:1 1 auto;min-height:0;
  overflow-y:auto;
  /* a drag that reaches the end of this one stops here rather than
     handing itself to the page behind it */
  overscroll-behavior:contain;
  /* the description is the one thing on the tile that scrolls, and it
     scrolls in one direction */
  touch-action:pan-y;
  -webkit-overflow-scrolling:touch;
}
/* The picture takes no gesture from the browser at all: a drag on it
   belongs to the cover (engage()), a quick tap on it opens the piece,
   and the browser's own double-tap-to-zoom would land on top of both —
   touch-action is the only way to turn that off that iOS honours. It
   replaces `pan-y pinch-zoom` from when the page behind a picked tile
   still scrolled. */
.tile.is-picked .tile-frame{touch-action:none}

/* THE WAY IN, SAID IN THE TYPE. Nothing about a title says it can be
   tapped, and on a touch screen there is no hover to find it out with.
   A hairline under it, in the same ink as the rules everywhere else on
   the site, is the site's own way of saying a thing is a link — it is
   not a border, it is the underline the type already had somewhere
   else. The keywords no longer get one: on a live tile the button has
   taken their place. */
.tile.is-picked .tile-title{
  text-decoration:underline;
  text-decoration-thickness:1px;
  text-underline-offset:.34em;
  text-decoration-color:var(--line-hot);
}
/* THE OPEN BUTTON, in the keywords' slot directly under the title and
   pinned above the description. The <p> is the hit area — min-height
   takes it to the 44px a fingertip needs while the .tile-btn drawn
   inside it stays the size it is with a pointer. The keywords it
   replaces come after it in the slot, so this is what hides them, and
   only on a tile that has a button. */
.tile.is-picked .note-run ~ .tile-keys{display:none}
.tile.is-picked .note-run{
  margin:var(--sp-1) 0 var(--sp-4);
  min-height:44px;
  display:flex;align-items:center;justify-content:center;
  opacity:0;transform:translateY(8px);
  transition:opacity var(--note-fade,520ms) var(--ease),
             transform var(--note-fade,520ms) var(--ease);
}
.tile.is-picked .note-run.is-in{opacity:1;transform:none}

/* Reduced motion: the keywords and the button swap outright, on both
   paths — no fade, no rise. */
@media (prefers-reduced-motion:reduce){
  .tile-slot .tile-keys,
  .tile-open,
  .tile.is-picked .note-run{transition:none}
  .tile.is-picked .note-run{transform:none}
}
.tile.is-picked .tile-note{margin-top:0}

/* THE WAY BACK — THE MARK'S CORNER, LENT OUT AGAIN.

   A single work already does this: #back takes #brand's place, inset and
   header behaviour, because the step wanted from that corner is back out
   rather than home. An open panel is the same shape of thing one step
   further in, so while a tile is up the mark steps aside for an arrow in
   the same slot, and comes back when the panel closes.

   It used to be a disc over the picture's own top corner. Two things
   were wrong with that: the disc was furniture this site does not have
   anywhere else, and the picture is the one part of a picked tile a
   finger is supposed to be able to put itself anywhere on — a control
   sitting on it is a hole in the one region that had to be whole.

   Bare glyph, no shape behind it. It is in the chrome now, over the page
   rather than over a cover, and the chrome is already legible there. */
.pick-back{
  display:none;
  margin:0;padding:0;
  border:0;background:none;
  font:inherit;color:inherit;
  line-height:1;
  cursor:pointer;
}
body.has-picked #brand .ic{display:none}
body.has-picked .pick-back{display:block}
.pick-back:focus-visible{outline:1px solid var(--line-hot);outline-offset:6px}

/* The corner is faded out by the time any of this matters: the header
   leaves with the top of the page, and a picked tile is usually half a
   rack down. body.menu-open is already an exception to that for the same
   reason — the control that closes a thing cannot be the one that faded
   away — and this is the second. It also opts out of the blur and dim
   every OTHER corner takes while a tile is picked, since this one is not
   background, it is the way out.

   Both selectors carry the same weight as the rules they answer, so they
   win on being further down the file. Do not move them above the header
   block. */
body.has-picked #brand{
  --veil:1;
  --pick-blur:0px;
  --pick-dim:1;
  pointer-events:auto;
}
html.chrome-gone body.has-picked #brand{--veil:1;pointer-events:auto}

/* The rest of the rack stays where it is and goes out of focus behind
   the scrim, rather than disappearing — what is behind the picked tile
   should still read as the rack it came out of. */
.studio[data-picked] .tile:not(.is-picked){
  opacity:var(--pick-dim,.55);
  pointer-events:none;
}
.tile:not(.is-picked){
  transition:opacity var(--pick-fade,760ms) var(--ease),
             filter var(--dur-med) var(--ease);
}
/* The mark and the menu button, blurred in place. Untappable while a
   tile is up, so that a tap anywhere but the tile means the same thing
   everywhere on the screen: put the rack back.

   Both the blur AND the dim are inside `filter` rather than one of them
   being the `opacity` property. The corners already transition opacity
   for the page's entry animation, and a second rule for it here would
   have to restate that transition — and would then own it. Written as
   filter functions, this is one property the entry animation does not
   use.

   These are the NUMBERS ONLY. The declaration they feed is the single
   `filter` on .corner further up, which also carries the header's veil;
   restating `filter` here would drop that half of it and the header
   would come back the moment a tile was picked. */
body.has-picked .corner{
  --pick-blur:6px;
  --pick-dim:.4;
  pointer-events:none;
}

/* The note under a picked tile: the same blocks the note beside a tile
   carries, ranged left under a centred caption because three lines of
   centred body copy is three lines nobody finishes. */
.tile-note{
  margin-top:var(--sp-5);
  text-align:left;
}
/* EACH BLOCK ARRIVES ON ITS OWN. core/studio.js hands out .is-in a beat
   apart; this is the state each block waits in until it does.

   WRITTEN FOR BOTH DEPTHS ON PURPOSE. The pointer's note holds its
   blocks directly, and the touch panel holds them one level down inside
   .note-scroll so the description can scroll under a pinned header.
   When that wrapper was added, `.tile-note > *` stopped meaning "the
   blocks" and started meaning "the scroller" — which then sat at
   opacity 0 for ever, because the stagger was marking the blocks inside
   it. The whole description was in the DOM, correctly laid out, and
   completely invisible.

   So the selector names the blocks at either depth, and the scroller is
   given back the visibility it never wanted to be part of: it is a box
   the blocks arrive INSIDE, not a block that arrives. */
.tile-note > *,
.note-scroll > *{
  opacity:0;transform:translateY(8px);
  transition:opacity var(--note-fade,520ms) var(--ease),
             transform var(--note-fade,520ms) var(--ease);
}
.tile-note > .is-in,
.note-scroll > .is-in{opacity:1;transform:none}
.tile-note > .note-scroll{opacity:1;transform:none;transition:none}
/* The one line that is an instruction rather than a description, given
   the rule that says so. */
.tile-note .note-run{
  margin-top:var(--sp-5);padding-top:var(--sp-4);
  border-top:1px solid var(--line);
  /* A 10px line is a hard thing to hit with a thumb, and now a miss
     costs something: the note's words just above it put the rack back
     (core/studio.js). The padding under it takes its box past 44px, the
     size a fingertip needs, without moving a pixel of what is drawn. */
  padding-bottom:var(--sp-4);
}
/* ---------- the note beside the tile -------------------------------------
   A grid item like the tiles are, placed by core/studio.js into the cell
   next to whichever tile is being read — right of a left-hand tile, left
   of a right-hand one. It is deliberately narrow and quiet: this is an
   introduction, not the piece's page, and the one thing asked of it is
   that it never reads as clutter.

   Each block inside arrives on its own, a beat after the one before it;
   studio.js adds .is-in to them in turn. */
.rack-notes{
  position:absolute;
  display:flex;flex-direction:column;justify-content:center;
  z-index:5;
  padding-inline:clamp(8px,1.6vw,28px);
  opacity:0;visibility:hidden;pointer-events:none;
  /* `transform` is deliberately absent: core/tilt.js writes it every
     frame to lean this panel as the mirror of the tile it belongs to,
     and a transition on it would fight the springs doing that. */
  will-change:transform;
  transition:opacity var(--dur-med) var(--ease),
             visibility 0s linear var(--dur-med);
}
/* Never takes pointer events, open or closed. The note belongs to the
   tile: if it could be hovered itself it would hold a focus the tile had
   already lost, and moving toward it would keep alive a panel about a
   piece the pointer had left. */
.rack-notes.is-open{
  opacity:1;visibility:visible;
  transition-delay:0s;
}
/* sitting to the LEFT of its tile, it ranges right, so its edge and the
   tile's edge are the same line */
.rack-notes.is-left{text-align:right;align-items:flex-end}
.rack-notes > *{max-width:34ch}
.rack-notes > *{
  opacity:0;transform:translateY(8px);
  transition:opacity var(--note-fade,520ms) var(--ease),
             transform var(--note-fade,520ms) var(--ease);
}
.rack-notes > .is-in{opacity:1;transform:none}

.note-lead{font-size:var(--fs-1);color:var(--signal);line-height:1.45;letter-spacing:.005em}
.note-body{margin-top:var(--sp-4);font-size:var(--fs-00);color:var(--haze);line-height:var(--lh-body)}
/* the equation line — the description's own size and face, one step
   quieter. pre-wrap keeps the two spaces that part an equation from its
   condition, and still wraps on a narrow panel.

   Syne draws every glyph in both lines but two: ∂ and ∇. Left to the
   browser those fall to a math face — Cambria Math on Windows — whose
   italic serif ∂ sits in a line of geometric sans like a typo. The stack
   after Syne only ever supplies those two, and names upright sans faces
   that carry them: Segoe UI Symbol on Windows, Apple Symbols on a Mac,
   Noto Sans Math on Linux and Android. */
.note-eq{
  font-family:'Syne','Segoe UI Symbol','Apple Symbols','Noto Sans Math',var(--font-ui);
  margin-top:var(--sp-2);font-size:var(--fs-00);color:var(--haze-dim);
  line-height:var(--lh-body);white-space:pre-wrap;
}
.note-tools{margin-top:var(--sp-6)}
.note-label{
  font-size:var(--fs-000);letter-spacing:var(--track-wide);text-transform:uppercase;
  color:var(--haze-dim);
}
.note-tools ul{
  list-style:none;padding:0;margin-top:var(--sp-3);
  display:flex;flex-direction:column;gap:var(--sp-1);
  font-size:var(--fs-00);color:var(--haze);
}
.note-run{
  margin-top:var(--sp-6);
  font-size:var(--fs-000);font-weight:500;
  letter-spacing:var(--track-ui);text-transform:uppercase;
  color:var(--signal);
}

/* ---------- the pager ----------------------------------------------------
   The marks are the V and the A of the VAL logo, used as CSS masks so
   they inherit currentColor like any other glyph. The A is drawn as a
   bare apex — it already points up, and the V already points down, so
   neither is rotated and neither is an arrow glyph borrowed from
   somewhere else. */
.pager{
  position:fixed;top:50%;z-index:12;
  transform:translateY(-50%);
  width:46px;height:46px;
  display:grid;place-items:center;
  color:var(--haze-dim);
  transition:color var(--dur-med) var(--ease),opacity var(--dur-med) var(--ease);
}
.pager-up{left:clamp(6px,2.2vw,46px)}
.pager-down{right:clamp(6px,2.2vw,46px)}
.pager:hover,.pager:focus-visible{color:var(--signal)}
.pager[disabled]{opacity:.18;pointer-events:none}
.pager[hidden]{display:none}
.pager-mark{
  display:block;width:21px;height:19px;background:currentColor;
  -webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;
  -webkit-mask-position:center;mask-position:center;
  -webkit-mask-size:contain;mask-size:contain;
}
.pager-mark.v{-webkit-mask-image:url(/assets/val-v.svg);mask-image:url(/assets/val-v.svg)}
.pager-mark.a{-webkit-mask-image:url(/assets/val-a.svg);mask-image:url(/assets/val-a.svg)}

@media (max-width:899px){
  .pager{display:none}

  /* One column, and the word centred over it with the same air above and
     below. The note under it is always open — there is no hover here to
     open it with. */
  .page.page-studio{
    padding:calc(var(--chrome-pad) + 74px) var(--sp-5) var(--sp-9);
  }
  .studio-head{
    margin-bottom:var(--sp-8);
    text-align:center;
  }
  /* Centred under the word, and narrower — which is three lines rather
     than two, so the reserved height goes up with it. There is nothing
     to reveal here and no hover to weight it with: the note is standing
     copy, and that is the whole of it. */
  .studio-note{
    margin-inline:auto;
    max-width:34ch;
    min-height:calc(var(--lh-body) * 3em);
  }
  .rack{gap:var(--sp-8)}
  .rack-notes{display:none}

  /* A picked tile is centred in the viewport rather than pinned to the
     top of it, and core/studio.js scrolls to a position it works out
     itself rather than through scrollIntoView — so there is no scroll
     margin here for either of them to read. */
  .tile.is-picked .tile-cap{padding-bottom:var(--sp-6)}
}

/* ---------- project detail ---------------------------------------------- */
.project-head{margin-top:var(--sp-6)}
.project-meta{
  margin-top:var(--sp-2);
  font-size:var(--fs-000);letter-spacing:var(--track-wide);text-transform:uppercase;color:var(--haze-dim);
  display:flex;gap:var(--sp-3);
}
.project-summary{margin-top:var(--sp-4);max-width:62ch;font-size:var(--fs-1);color:var(--haze)}
.project-description{margin-top:var(--sp-5);max-width:68ch;font-size:var(--fs-0);line-height:1.75;color:var(--haze)}

.project-media{
  display:grid;grid-template-columns:repeat(auto-fill, minmax(280px,1fr));
  gap:var(--grid-gap);margin-top:var(--sp-7);
}
.project-media .media-frame:nth-child(3n+1){background:var(--grad-1)}
.project-media .media-frame:nth-child(3n+2){background:var(--grad-2)}
.project-media .media-frame:nth-child(3n+3){background:var(--grad-3)}

.project-specs{
  margin-top:var(--sp-7);padding-top:var(--sp-5);border-top:1px solid var(--line);
  display:grid;grid-template-columns:repeat(auto-fill, minmax(180px,1fr));gap:var(--sp-4);
}
.project-specs dt{font-size:var(--fs-000);letter-spacing:var(--track-wide);text-transform:uppercase;color:var(--haze-dim)}
.project-specs dd{margin-top:var(--sp-1);font-size:var(--fs-00);color:var(--signal)}

.project-credits{margin-top:var(--sp-6)}
.plain-list{list-style:none;padding:0;display:flex;flex-direction:column;gap:var(--sp-2)}
.plain-list li{font-size:var(--fs-00);color:var(--haze);padding-bottom:var(--sp-2);border-bottom:1px solid var(--line)}

/* ---------- info page ---------------------------------------------------- */
.info-section{margin-top:var(--sp-7)}
.info-section h2{font-size:var(--fs-2);font-weight:300;color:var(--signal)}
.info-section p{margin-top:var(--sp-3);max-width:62ch}
.info-section .plain-list{margin-top:var(--sp-3)}
.info-section a{color:var(--signal);border-bottom:1px solid var(--line-hot);
  transition:border-color var(--dur-med) var(--ease)}
.info-section a:hover,.info-section a:focus-visible{border-bottom-color:var(--warm)}

/* ---------- contact ------------------------------------------------------
   A DIRECTORY, NOT A ROW OF BUTTONS. Every entry here is the same kind
   of thing — a name and an address you can copy or follow — so they are
   set as a list with a rule under each one rather than as tiles or
   pills, which would imply a difference between them that isn't there.

   The rule is where the whole interaction happens, and it is the site's
   own waterline (see DESIGN.md): resting on a row, the hairline under
   it goes warm and wipes in from the left, the address brightens, and
   the arrow at the end reaches. Nothing changes colour abruptly and
   nothing moves — the same gesture the rest of the site makes, applied
   to a row instead of to a word.

   The channels themselves are in content/pages.json; a channel with an
   empty href never reaches the page (see fillCopy in core/site.js), so
   that file can list every account and this page only shows the ones
   with somewhere to go.                                                */
/* Held to the same measure as the paragraphs above it. Run the rules the
   full width of the page and a single email address sits at one end of a
   1300px line with an arrow marooned at the other — a directory of four
   addresses is a small object, and it should look like one. */
.channels{list-style:none;padding:0;margin:var(--sp-4) 0 0;max-width:52ch}

.channels a{
  position:relative;display:grid;align-items:baseline;
  grid-template-columns:minmax(90px,7em) 1fr auto;
  gap:var(--sp-4);
  padding:var(--sp-4) 0;
  border:0;                 /* the .info-section underline is not this */
  color:var(--haze);
}
/* the rule, resting */
.channels a::before{
  content:"";position:absolute;left:0;right:0;bottom:0;height:1px;
  background:var(--line);
}
/* and the warmth that wipes across it */
.channels a::after{
  content:"";position:absolute;left:0;right:0;bottom:0;height:1px;
  background:var(--warm);
  transform:scaleX(0);transform-origin:left center;opacity:0;
  transition:transform var(--dur-slow) var(--ease),opacity var(--dur-med) var(--ease);
}
.channels a:hover::after,.channels a:focus-visible::after{transform:scaleX(1);opacity:.62}
.channels a:not(:hover)::after{transition-duration:var(--dur-med),var(--dur-fast)}

.ch-name{
  font-size:var(--fs-000);letter-spacing:var(--track-wide);text-transform:uppercase;
  color:var(--haze-dim);
  transition:color var(--dur-med) var(--ease);
}
.ch-handle{
  font-size:var(--fs-0);color:var(--signal);
  overflow-wrap:anywhere;
  transition:letter-spacing var(--dur-slow) var(--ease);
}
.channels a:hover .ch-name,.channels a:focus-visible .ch-name{color:var(--haze)}
.channels a:hover .ch-handle,.channels a:focus-visible .ch-handle{letter-spacing:.012em}

/* the arrow reaches rather than slides, so nothing on the row moves */
.ch-arrow{
  align-self:center;display:block;width:18px;height:1px;
  background:var(--haze-dim);
  transition:width var(--dur-med) var(--ease),background var(--dur-med) var(--ease);
}
.channels a:hover .ch-arrow,.channels a:focus-visible .ch-arrow{width:30px;background:var(--warm)}

/* On a phone the name and the address stack: 90px of label beside a long
   handle leaves the handle nothing, and an address that wraps mid-word
   is harder to read than one on its own line. */
@media (max-width:560px){
  .channels a{grid-template-columns:1fr auto;gap:var(--sp-2) var(--sp-3)}
  .ch-name{grid-column:1 / -1}
  .ch-handle{font-size:var(--fs-00)}
}

@media (prefers-reduced-motion:reduce){
  .channels a::after{transition:none}
}

/* ---------- 404 ------------------------------------------------------------ */
.page-404{padding-top:var(--sp-9)}

/* ---------- responsive -------------------------------------------------- */
@media (max-width:640px){
  /* The top padding keeps the --chrome-clear floor. It used to be a flat
     --sp-6 here, which is 32px — less than the mark in the corner is
     tall — so on every interior page at phone width the page's own
     heading was drawn straight through the VAL mark. The sides and the
     foot still tighten; only the clearance the chrome needs is not
     something a narrow screen gets to negotiate away. */
  .page{padding:max(var(--sp-6), var(--chrome-clear)) var(--sp-4) var(--sp-8)}
  .project-specs{grid-template-columns:1fr 1fr}
}
