/* =====================================================================
   VAL STUDIO — shared site stylesheet (labs / projects / project / 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, AND ITS SIZE AND LETTER SPACING, LIVE IN ONE FILE — core/mark.css.
   Imported rather than restated here so that "make the logo smaller" is
   one edit rather than five. index.html and the labs pieces, which do not
   link this stylesheet, link that one directly. Must stay the first rule
   in the file: @import is only honoured before any other declaration. */
@import url('/core/mark.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-box:    18px;   /* menu icon box — square, so it can turn 90° */
  /* 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. --menu-box is this file's because the button is. */
  --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 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%}

/* 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);
}
#brand{top:var(--chrome-pad);left:var(--chrome-pad)}
#menuZone{top:var(--chrome-pad);right:var(--chrome-pad)}

/* ---------- 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. #dock is the sound control in the bottom corner: it is
   not the header, and it is the one control on the site you should never
   have to scroll to reach.                                             */
#brand,#menuZone{
  --veil:1;
  --corner-fade:var(--chrome-fade,620ms);
}
html.chrome-gone #brand,
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 #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 #menuZone:focus-within,
html.chrome-gone body.menu-open #brand,
html.chrome-gone body.menu-open #menuZone{--veil:1}
html.chrome-gone #brand:focus-within,
html.chrome-gone #menuZone:focus-within > *,
html.chrome-gone body.menu-open #brand,
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:var(--chrome-pad);left:var(--chrome-pad)}

/* 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,#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 ------------------------------------------
   A waveform that says which state it is in BY ITS SHAPE. Silent, it is
   a flat line — the shape silence has. Playing, a wave travels through
   it left to right under a centre-weighted envelope. The two states are
   not two arrangements of the same bars, they are a line and a wave, and
   that is what makes it readable from the corner of the eye.

   scaleY on a fixed-height bar rather than an animated `height`, so the
   whole thing composites and never touches layout. --h is the envelope:
   each bar's own crest, tallest in the middle.

   Mirrored inline in index.html. Change one and change the other. */
#snd{
  display:flex;align-items:center;gap:2px;
  height:16px;padding:4px 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 b{
  display:block;width:2px;height:14px;
  background:currentColor;border-radius:1px;
  transform:scaleY(var(--flat));
  transition:transform .42s var(--ease);
}
#snd{--flat:.143}                      /* 2px of a 14px bar: the line */
#snd.on b{animation:val-wave 1.4s ease-in-out infinite}
/* the envelope, and the delays that walk the crest along it */
#snd b:nth-child(1){--h:.32;animation-delay:-1.26s}
#snd b:nth-child(2){--h:.58;animation-delay:-1.06s}
#snd b:nth-child(3){--h:.84;animation-delay:-.86s}
#snd b:nth-child(4){--h:1;  animation-delay:-.66s}
#snd b:nth-child(5){--h:.84;animation-delay:-.46s}
#snd b:nth-child(6){--h:.58;animation-delay:-.26s}
#snd b:nth-child(7){--h:.32;animation-delay:-.06s}

/* ---------- 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}}
@keyframes val-wave{
  0%,100%{transform:scaleY(var(--flat))}
  50%    {transform:scaleY(var(--h))}
}
@media (prefers-reduced-motion:reduce){
  /* the wave still has to be readable as "on", so it is held at its
     crest rather than animated flat */
  #snd.on b{animation:none;transform:scaleY(var(--h))}
}

/* 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:5px;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);
}
.burger .r1{top:1px}
.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}

#rail{
  --rail-gap:  13px;                      /* stacked: between rows */
  --rail-span: clamp(22px,2.8vw,44px);    /* ranged: between words */
  --rail-drop: 14px;                      /* 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. */
#rail a{
  display:block;padding:2px 0;
  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. */
@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-drop) 0 0;
  }
  #rail li:nth-child(5) a{transition-delay:.02s}
  #rail li:nth-child(4) a{transition-delay:.06s}
  #rail li:nth-child(3) a{transition-delay:.10s}
  #rail li:nth-child(2) a{transition-delay:.14s}
  #rail li:nth-child(1) 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 is set from the longest label: PROJECTS
   measures 6.23em with its tracking and padding, so 6.3em clears it.

   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, Projects and every 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. */
#siteFoot{
  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}

/* ---------- 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)}
.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;
}

.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 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;
}

.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}
.tile.is-picked{z-index:30}

/* 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;
}
.tile-note > *{
  opacity:0;transform:translateY(8px);
  transition:opacity var(--note-fade,520ms) var(--ease),
             transform var(--note-fade,520ms) var(--ease);
}
.tile-note > .is-in{opacity:1;transform: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);
}
/* ---------- 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)}
.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}
}
