/* ============================================================================
   akakshitij.com — "Vellum & Rule"
   A drafting document. Structure is carried by hairline rules that draw
   themselves; every number is a readout that resolves.
   ========================================================================= */

/* --- fonts ---------------------------------------------------------------
   Self-hosted. Two families, both variable.
   Newsreader carries display AND body via its optical-size axis — the same
   face optically corrected at 11px and 120px. Martian Mono is the
   counterweight: positive tracking against the display's negative.
   Fallback metrics are overridden so the swap does not move the page.       */

@font-face {
  font-family: 'Newsreader';
  font-style: normal;
  font-weight: 200 700;
  font-display: swap;
  src: url('/fonts/newsreader-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Newsreader';
  font-style: normal;
  font-weight: 200 700;
  font-display: swap;
  src: url('/fonts/newsreader-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
    U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
    U+A720-A7FF;
}
@font-face {
  font-family: 'Martian Mono';
  font-style: normal;
  font-weight: 300 700;
  font-display: swap;
  src: url('/fonts/martian-mono-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Martian Mono';
  font-style: normal;
  font-weight: 300 700;
  font-display: swap;
  src: url('/fonts/martian-mono-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
    U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
    U+A720-A7FF;
}

/* Metric-matched fallbacks: hold layout still during swap (guards CLS). */
@font-face {
  font-family: 'Newsreader Fallback';
  src: local('Georgia'), local('Times New Roman'), local('serif');
  size-adjust: 96%;
  ascent-override: 96%;
  descent-override: 26%;
  line-gap-override: 0%;
}
@font-face {
  font-family: 'Martian Fallback';
  src: local('Menlo'), local('Consolas'), local('monospace');
  size-adjust: 128%;
  ascent-override: 78%;
  descent-override: 22%;
  line-gap-override: 0%;
}

/* --- tokens ------------------------------------------------------------- */

:root {
  /* Ground is cool and green-neutral — drafting film, not warm stock.
     No pure #fff, no pure #000, anywhere in the content palette. */
  --paper: #f6f7f5;
  --panel: #eceeea;
  --deep: #14171a;
  --deep-raised: #1c2023;

  --ink: #14171a;
  --muted: #5c6461;
  --ink-inv: #e4e7e2;
  --muted-inv: #9aa29d;

  /* One accent: annotation red. The colour you write ON TOP of a print.
     Verified ≥36° in hue from every chromatic swatch in the Firstcry
     client palette, which is frozen content on that page. */
  --mark: #a32d21;      /* text, links, focus — 6.61:1 on paper */
  --mark-2: #c8342a;    /* non-text marks: rules, dots, underlines */
  /* The same red, lifted for the dark ground. --mark itself measures
     2.53:1 on --deep, which is not a near miss — it is below the 3:1 floor
     for non-text, let alone the 4.5:1 for body text. A dark ground needs a
     lighter accent for the same reason a print needs a darker one; this is
     the identical hue (5.5°) with lightness 38% -> 60%.

     Saturation goes UP with it, 66% -> 82%, and that is the part worth
     writing down: lifting lightness alone gives #dc6256, which measures the
     same 5.1:1 but reads as coral rather than as the annotation red. The
     extra saturation buys back the hue identity at no contrast cost. */
  --mark-inv: #ed5545;  /* links on --deep — 5.11:1 */

  --rule: rgba(20, 23, 26, 0.14);
  --rule-strong: rgba(20, 23, 26, 0.28);
  --rule-inv: rgba(228, 231, 226, 0.16);

  /* Fluid rhythm. No hard-coded breakpoint drives spacing. */
  --gutter: clamp(20px, 5vw, 72px);
  --section: clamp(76px, 11vh, 152px);
  --measure: 68ch;

  /* Easing. Expo-out throughout — the recurring curve across the reference
     set. Never ease-in: it delays the first frame, which is the frame the
     eye is on. */
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-io: cubic-bezier(0.65, 0, 0.35, 1);

  --dur-rule: 520ms;
  --dur-wipe: 640ms;
  --dur-ui: 180ms;

  --font-display: 'Newsreader', 'Newsreader Fallback', Georgia, serif;
  --font-mono: 'Martian Mono', 'Martian Fallback', ui-monospace, monospace;
}

/* --- reset -------------------------------------------------------------- */

*,
*::before,
*::after { box-sizing: border-box; }

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
}

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-display);
  font-variation-settings: 'opsz' 16;
  font-weight: 400;
  font-size: clamp(1rem, 0.94rem + 0.28vw, 1.14rem);
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}

img { max-width: 100%; display: block; height: auto; }
h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd { margin: 0; }
ul, ol { margin: 0; padding: 0; list-style: none; }
table { border-collapse: collapse; }

/* --- type roles --------------------------------------------------------- */

.t-display {
  font-family: var(--font-display);
  font-weight: 200;
  font-variation-settings: 'opsz' 72;
  font-size: clamp(2.75rem, 1.2rem + 7.1vw, 7.5rem);
  line-height: 0.92;
  letter-spacing: -0.035em;
  text-wrap: balance;
}

.t-headline {
  font-family: var(--font-display);
  font-weight: 300;
  font-variation-settings: 'opsz' 48;
  font-size: clamp(2rem, 1.1rem + 3.6vw, 4rem);
  line-height: 1;
  letter-spacing: -0.03em;
  text-wrap: balance;
}

.t-title {
  font-family: var(--font-display);
  font-weight: 400;
  font-variation-settings: 'opsz' 32;
  font-size: clamp(1.4rem, 1.05rem + 1.5vw, 2.15rem);
  line-height: 1.15;
  letter-spacing: -0.02em;
  text-wrap: balance;
}

.t-lead {
  font-size: clamp(1.15rem, 1rem + 0.7vw, 1.5rem);
  font-weight: 300;
  font-variation-settings: 'opsz' 28;
  line-height: 1.42;
  letter-spacing: -0.012em;
  max-width: 40ch;
  text-wrap: pretty;
}

p { max-width: var(--measure); text-wrap: pretty; }

/* The annotation rule: mono is never used for reading. It labels, numbers
   and annotates. Anything read for meaning is set in Newsreader. */
.t-label {
  font-family: var(--font-mono);
  font-weight: 400;
  font-size: 0.66rem;
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}
.t-label--ink { color: var(--ink); }
.t-label--mark { color: var(--mark); }
.t-label--inv { color: var(--muted-inv); }
/* A label set inline, ahead of the sentence it introduces. The handoff fuses
   the two tag-free — the frozen string is literally "On the pageHunts for a
   fixed, high-contrast CTA…" — and the copy gate diffs the flattened text
   character for character, so a typed space would fail the build. The gap
   has to be drawn instead. Sized off the label, not the body, because it
   belongs to the annotation. */
.t-label--run { margin-right: 1.6em; }

/* --- layout ------------------------------------------------------------- */

.wrap {
  padding-inline: var(--gutter);
  max-width: 1600px;
  margin-inline: auto;
}

.section { padding-block: var(--section); }
.section--tight { padding-block: calc(var(--section) * 0.55); }

.section--deep {
  background: var(--deep);
  color: var(--ink-inv);
  --rule: var(--rule-inv);
  --muted: var(--muted-inv);
}
.section--panel { background: var(--panel); }

.stack > * + * { margin-top: var(--s, 1.25rem); }

/* Rule: the structural primitive of this system. Not a border on a box —
   a datum line laid across the page. */
.rule {
  border: 0;
  height: 1px;
  background: var(--rule);
  margin: 0;
  transform-origin: left center;
}
.rule--strong { background: var(--rule-strong); }

/* --- navigation --------------------------------------------------------- */

.skip {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  background: var(--ink);
  color: var(--paper);
  padding: 12px 18px;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  text-decoration: none;
}
.skip:focus { left: 0; }

.nav {
  position: sticky;
  top: 0;
  z-index: 50;
  background: color-mix(in srgb, var(--paper) 88%, transparent);
  backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--rule);
}
.nav__in {
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* flex-wrap is the last-resort guard only. The header now holds one row
     down to 320px because the brand shortens to "KK" below 620px — see the
     comment in shell.html. */
  flex-wrap: wrap;
  gap: 4px 1.5rem;
  padding-block: 10px;
  padding-inline: var(--gutter);
  max-width: 1600px;
  margin-inline: auto;
}
/* --- brand mark ----------------------------------------------------------
   A registration target — see shell.html for why it is not a letterform.
   Stroke-based and currentColor, so one definition serves the paper header
   and the inverted footer. Stroke width is tied to the same hairline the
   rules use; the node is the site's one accent, spent on centre, which is
   the same move the status dot makes.

   1.32em rather than the monogram's 1.15em: a circle reads smaller than a
   letterform of the same box because its corners are empty, and the four
   ticks need room to separate from it. stroke-linecap is butt, not square —
   square adds half a stroke to each tick and closed the gap the target
   depends on. */
.brand__mark {
  width: 1.32em;
  height: 1.32em;
  flex: 0 0 auto;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: butt;
  overflow: visible;
  transform-origin: 50% 50%;
  transition: transform 520ms var(--ease-out);
}
/* The one thing a registration mark does is find its position. On hover it
   does it again — a quarter turn, which lands the target back on itself, so
   the movement is legible without the mark ever being wrong. */
.nav__brand:hover .brand__mark,
.nav__brand:focus-visible .brand__mark { transform: rotate(90deg); }
.brand__node { fill: var(--mark); stroke: none; }
.nav__brand--foot .brand__node { fill: var(--mark-2); }

.nav__brand {
  display: inline-flex;
  align-items: center;
  gap: 0.62em;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink);
  text-decoration: none;
  font-weight: 500;
}
.nav__list { display: flex; flex-wrap: wrap; gap: 0 clamp(0.7rem, 2.2vw, 2rem); align-items: center; }
/* The footer wordmark keeps the full string at every width — it has the
   whole row to itself and nothing to compete with. */
@media (max-width: 619px) {
  /* The mark IS the logo here. Nothing is truncated and nothing is lost —
     the hero states "Product Designer, London" two lines below. */
  .nav .nav__brand-rest { display: none; }
  .brand__mark { width: 1.6em; height: 1.6em; }
  /* ...but dropping the wordmark also drops the target down to the mark
     itself: 17.9px square, measured. That is the home link, on the width
     where every visitor is using a thumb, and it is under even the 24x24
     of WCAG 2.5.8 — let alone the 44 a thumb wants. Grown with a
     pseudo-element rather than padding, because padding would push the
     nav row taller and move the rule under it; this changes no layout at
     all. Same technique as the Switch button. */
  .nav .nav__brand { position: relative; }
  .nav .nav__brand::after {
    content: '';
    position: absolute;
    left: 50%; top: 50%;
    transform: translate(-50%, -50%);
    width: 44px; height: 44px;
  }
}

/* --- hero title block ----------------------------------------------------
   Subject left, revision right, on the row above the sheet's border rule.
   space-between rather than a two-column grid: the left string is long and
   the right one is fixed, so at narrow widths the revision drops to its own
   line and left-aligns under the subject instead of being squeezed. */
.slug {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.5rem 1.5rem;
}
/* Boxed, because that is what a revision stamp is on a drawing. The box is
   the same hairline as every rule on the site, so it costs no new value. */
.slug__rev {
  border: 1px solid var(--rule-strong);
  padding: 0.3em 0.62em 0.24em;
  color: var(--ink);
  white-space: nowrap;
}

/* The email is the primary action on the site and the longest unbroken
   string in the copy. It must never be the thing that widens the page. */
/* The address is one line at EVERY width, by construction rather than by
   luck. Measured with the real face loaded, the string renders 8.25em wide,
   so one line requires font-size <= containerWidth / 8.25, i.e. 12.12cqi.
   11.4cqi leaves ~6% headroom for the metric-matched fallback face during
   swap. Sized against the CONTAINER, not the viewport, so it stays correct
   however the address is laid out later.

   overflow-wrap: anywhere is gone. It was the thing permitting the break —
   it let the address split mid-word the moment the line was tight, which is
   exactly the two-line render. white-space: nowrap makes wrapping
   impossible instead of unlikely. */
.email-line { container-type: inline-size; max-width: none; }
.email {
  display: inline-block;
  font-family: var(--font-display);
  font-weight: 200;
  font-variation-settings: 'opsz' 60;
  /* Fallback for engines without container query units: viewport-derived,
     using the same 12.806em maths against the wrap's 90vw inner width. */
  font-size: clamp(1rem, 6.7vw, 5.6rem);
  font-size: min(5.9rem, 7.4cqi);
  line-height: 1.05;
  letter-spacing: -0.03em;
  color: inherit;
  text-decoration: none;
  border-bottom: 2px solid var(--mark-2);
  padding-bottom: 0.08em;
  white-space: nowrap;
  max-width: 100%;
  transition: color var(--dur-ui) var(--ease-out);
}
.email:hover, .email:focus-visible { color: var(--mark); }
.nav__link {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
  text-decoration: none;
  padding: 10px 0;
  display: inline-block;
  min-height: 44px;
  display: inline-flex;
  align-items: center;
  transition: color var(--dur-ui) var(--ease-out);
}
.nav__link:hover,
.nav__link:focus-visible { color: var(--mark); }
.nav__link[aria-current='page'] {
  color: var(--ink);
  box-shadow: inset 0 -1px 0 var(--mark-2);
}

/* --- link affordance ---------------------------------------------------- */
/* No filled buttons in this system. This is the primary action everywhere. */

.link {
  display: inline-flex;
  align-items: center;
  gap: 0.55em;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink);
  text-decoration: none;
  padding: 13px 0 7px;
  border-bottom: 1px solid var(--mark-2);
  min-height: 44px;
  transition: color var(--dur-ui) var(--ease-out);
}
.link__arrow {
  display: inline-block;
  /* transform, never gap — gap is layout and reflows the row every frame */
  transition: transform 220ms var(--ease-out);
}
.link:hover,
.link:focus-visible { color: var(--mark); }

/* Directional response: the arrow leads in the direction the link goes.
   A back-link's arrow retreats left, a forward-link's advances right, so
   the affordance says WHERE as well as THAT it is interactive. */
@media (hover: hover) and (pointer: fine) {
  .link:hover .link__arrow,
  .link:focus-visible .link__arrow { transform: translateX(6px); }
  .link--back:hover .link__arrow,
  .link--back:focus-visible .link__arrow { transform: translateX(-6px); }
  .plate__frame img { transition: transform 620ms var(--ease-out); }
  .plate:hover .plate__frame img { transform: scale(1.028); }
}

/* Press: fast in, faster out. Touch gets the same feedback as pointer,
   because :active fires on tap — the hover affordance above is additive,
   never the only signal. */
.link {
  transition: color var(--dur-ui) var(--ease-out), transform 90ms var(--ease-out);
}
.link:active { transform: scale(0.972); }
.link:active .link__arrow { transition-duration: 90ms; }
.nav__link:active,
.email:active { transform: scale(0.985); }

/* Keyboard focus moves the arrow too, so a keyboard user gets the same
   directional cue a mouse user gets. */
.link:focus-visible { color: var(--mark); }

a { color: var(--mark); text-underline-offset: 3px; }
a:hover { color: var(--ink); }

/* Designed focus: a drawn bracket in the annotation colour, not the UA ring.
   Offset so it never crowds the glyph, and visible on both grounds. */
:focus-visible {
  outline: 2px solid var(--mark);
  outline-offset: 3px;
  border-radius: 1px;
}
.section--deep :focus-visible { outline-color: var(--mark-2); }
/* Links on the dark ground.
 *
 * SCOPED TO `a`, NOT TO `.link` — that scoping was the bug. The rule below
 * used to read `.section--deep .link:hover`, which fixed the Elsewhere list
 * on /contact and silently missed the four social links in the home page's
 * contact block, because those are bare <a> in a label list and never
 * carried the class. Measured on the live site, they were:
 *
 *   at rest   #a32d21 on #14171a  = 2.53:1   (AA needs 4.5:1)
 *   on hover  #14171a on #14171a  = 1:1      — the word disappeared
 *
 * The hover case is the serious one and it is not a metaphor: `a:hover`
 * sets --ink, the deep ground IS --ink, so pointing at "LinkedIn" erased
 * it. Confirmed by screenshot before fixing. These are the four outbound
 * links a recruiter actually clicks.
 *
 * Keying off the GROUND rather than a class means a link cannot opt out of
 * being legible by forgetting to opt in. --ink-inv at rest matches what
 * /contact already showed and what .email on this same ground already
 * inherits, so nothing that was correct moves. */
.section--deep a { color: var(--ink-inv); }
.section--deep a:hover,
.section--deep a:focus-visible { color: var(--paper); }

/* The Elsewhere lists are the exception, at the owner's direction: accent at
   rest, white on hover. Scoped to .elsewhere rather than to every link on
   the ground, because the one other link down here is the email address —
   display-size type and the primary action on the site, which stays --ink-inv
   and should not compete with a list of four social profiles.

   Uses --mark-inv, not --mark. --mark is the red everywhere else on the site
   but it is 2.53:1 here; this is the same hue lifted for a dark ground, at
   5.11:1. Hover to --paper at 16.74:1 means the contrast RISES on hover,
   which is the direction it has to move — the previous rule sent it to
   --ink, the ground's own colour, and the word disappeared. */
.section--deep .elsewhere a { color: var(--mark-inv); }
.section--deep .elsewhere a:hover,
.section--deep .elsewhere a:focus-visible { color: var(--paper); }

/* --- figures / readouts ------------------------------------------------- */

/* Two columns from 320px up, never one. The four hero figures are the
   highest-value content on the site and must clear the fold on every
   breakpoint — auto-fit would collapse them to a 4-row stack at 320px and
   push the last one under. */
.figures {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0;
  border-top: 1px solid var(--rule);
}
@media (min-width: 760px) {
  .figures--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .figures--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.figure {
  padding: clamp(0.9rem, 2vw, 1.4rem) 0;
  border-bottom: 1px solid var(--rule);
  padding-right: 1rem;
}
.figures--boxed .figure + .figure { border-left: 1px solid var(--rule); padding-left: 1rem; }

.figure__val {
  display: block;
  font-family: var(--font-display);
  font-weight: 300;
  font-variation-settings: 'opsz' 60;
  font-size: clamp(2.1rem, 1.1rem + 3.4vw, 3.9rem);
  line-height: 0.95;
  letter-spacing: -0.035em;
  color: var(--ink);
  font-feature-settings: 'tnum' 1;
  font-variant-numeric: tabular-nums;
}
.figure__val--mark { color: var(--mark); }
/* Figure values are short phrases as often as numbers ("1 system",
   "Caregiver loop"). Balance the wrap instead of leaving a one-word orphan. */
.figure__val { text-wrap: balance; }
/* Hero datum block — the spec sheet for the person rather than the work.
   It reuses .figures--4 wholesale so the grid, the rules and the small-phone
   fold budget are untouched; the only thing that changes is type register.
   Two annotation levels, not one: a muted 0.6rem key over a 0.78rem ink
   value. All mono, so nothing here competes with the headline the way a
   3.9rem numeral did — the strip reads as instrument annotation under the
   statement instead of as a second, louder claim beside it. */
.figures--datum .figure { display: grid; align-content: start; }
.dat__k {
  font-family: var(--font-mono);
  font-size: 0.6rem;
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}
.dat__v {
  margin-top: 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  line-height: 1.5;
  letter-spacing: 0.02em;
  color: var(--ink);
  text-wrap: pretty;
}
@media (max-width: 400px) {
  .figures--datum .dat__v { font-size: 0.7rem; line-height: 1.45; }
}

.figure__lab {
  display: block;
  margin-top: 0.7rem;
  font-family: var(--font-mono);
  font-size: 0.6rem;
  line-height: 1.5;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--muted);
  max-width: 26ch;
}

/* Digit roll. Built by JS from literal text; without JS the literal text
   is what is already in the DOM, so nothing is gated behind script. */
/* An inline-block with overflow:hidden takes its baseline from its bottom
   margin edge, not from the glyph inside — which lifted every rolled digit
   16px above the static text beside it ("4" vs "+ yrs"). A hidden ghost
   copy of the real character owns the box and therefore the baseline; the
   rolling column is taken out of flow on top of it. */
.roll {
  position: relative;
  display: inline-block;
  vertical-align: baseline;
  /* deliberately NOT overflow:hidden — that would take this element's
     baseline from its bottom margin edge and lift every rolled digit off
     the baseline of the static text beside it ("4" against "+ yrs"). */
}
/* An inline ghost of the real character owns the box and the baseline. */
.roll__ghost { visibility: hidden; }
/* The mask is what clips, and it is out of flow so it cannot affect baseline. */
.roll__mask { position: absolute; inset: 0; overflow: hidden; }
.roll__col {
  display: block;
  transform: translateY(calc(var(--to, 0) * -1em));
}
.roll__col > span {
  display: block;
  height: 1em;
  line-height: 1;
}
/* The literal character stays in the DOM beside its rolling twin — read by
   assistive tech, included in copy-paste, never painted. */
.sr-digit {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

/* --- media -------------------------------------------------------------- */

.plate { margin: 0; }

/* Standalone plates — the full-measure figures in the case studies.
   A plate that is a direct child of .wrap spans the whole 1600px measure, so
   a portrait mockup rendered 1441 x 1622: one and a half laptop screens tall,
   and upscaled well past its own 1066px source, which made it soft as well as
   huge. Five figures across three case studies were doing this.

   Cap by HEIGHT: a plate should be takeable-in at one glance, and height is
   the constraint that actually expresses that. But the cap is APPLIED as a
   width on the figure — width = height x aspect — rather than as a
   max-height on the image, and that detour is load-bearing. Capping the
   image directly needs width:auto, which overrides the presentational hints
   the width/height attributes provide; a lazy image that has not arrived yet
   then has no intrinsic size at all and lays out at 0x0. Measured: Remedi's
   two below-the-fold plates collapsed outright. Sizing the figure instead
   leaves the image at width:100%/height:auto, so the attribute box still
   reserves the space and nothing shifts when the file lands.

   --ar is the figure's own aspect, declared next to the width/height it must
   match; verify-assets.mjs fails the build if the two ever disagree.

   Opt-in by class rather than by structure (.wrap > .plate, as this first
   shipped): a plate that moves into a column — the Firstcry hero now sits
   beside the challenge cards — would silently lose the cap on the way. The
   class travels with the figure. min(100%, ...) means a narrow column still
   wins, so the same declaration is correct full-measure or in a split.

   Not applied to: the bento, which crops its cells to a composition; the
   ruled empty plates, which carry their own aspect-ratio; and the in-grid
   contact sheets, which are already column-sized. */
.plate--fit {
  width: min(100%, calc(min(72svh, 760px) * var(--ar, 99)));
  margin-inline: auto;
}

/* A plate set beside the prose instead of under it. The text column keeps
   the page's left axis and the plate takes the outer third; below 1000px
   there is not enough width for both to hold their measure, so it stacks
   back to prose-then-plate. Centred rather than top-aligned: the cards run
   taller than the plate, and hanging it from the top leaves a wedge of
   white that reads as a mistake. */
.split { display: grid; gap: clamp(1.5rem, 4vw, 3rem); }
.split > .plate { margin-top: clamp(1.5rem, 4vh, 2.5rem); }
@media (min-width: 1000px) {
  .split {
    grid-template-columns: minmax(0, 1fr) minmax(0, 0.85fr);
    align-items: center;
  }
  .split > .plate { margin-top: 0; }
}

.plate__frame {
  overflow: hidden;
  background: var(--panel);
  /* radius 0 — 10 of 13 references set media square */
}
.plate img { width: 100%; }
.plate__cap {
  margin-top: 0.85rem;
  font-family: var(--font-mono);
  font-size: 0.6rem;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--muted);
}

/* Ruled empty plate — Lumen ships with these. An honest not-yet-made
   marker that is designed rather than broken. */
.plate--empty .plate__frame {
  background: transparent;
  place-items: start;
  border: 1px solid var(--rule);
  aspect-ratio: 4 / 3;
  position: relative;
  display: grid;
  place-items: center;
}
.plate--empty .graticule { width: 100%; height: 100%; display: block; }
.plate--empty .graticule line { stroke: var(--rule); stroke-width: 1; }
.plate--empty .graticule .g-major { stroke: var(--rule-strong); }
/* Corner annotation, not a centred caption. Centred, it sat directly on the
   photon ring and neither could be read; in the corner it labels the plate
   the way every other annotation on this site does. */
/* --- Lumen static fallback ------------------------------------------------
   Sits ABOVE the canvas in stacking order (same z-index, later in the DOM)
   and fades out once the canvas reports it is actually painting. That
   direction matters: the fallback is the default state and the canvas has to
   earn its way in front, which is the opposite of how the rest of this page
   used to work and the whole point of the last two days.

   [data-on] is set by motion.js only after the GL context exists, is sized,
   and has drawn a frame. No script, no WebGL, a failed context, or reduced
   motion all leave it unset — and the plate stays drawn. */
.plate--empty .disc-fb {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  z-index: 1;
  opacity: 1;
  transition: opacity 700ms var(--ease-out);
  pointer-events: none;
}
.plate--empty .disc[data-on] ~ .disc-fb { opacity: 0; }
.disc-fb__grid line { stroke: var(--rule); stroke-width: 1; }
.disc-fb__tick line { stroke: var(--rule-strong); stroke-width: 1; }
.disc-fb__route {
  fill: none;
  stroke: var(--ink);
  stroke-width: 1.6;
  stroke-linecap: round;
  opacity: 0.55;
}
.disc-fb__node circle { fill: none; stroke: var(--ink); stroke-width: 1.6; }
.disc-fb__core, .disc-fb__origin { fill: var(--mark-2); stroke: none; }
/* Reduced motion never gets the canvas, so there is nothing to cross-fade
   from — and a 700ms fade on a thing that will never be replaced is just a
   delay before the content appears. */
@media (prefers-reduced-motion: reduce) {
  .plate--empty .disc-fb { transition: none; opacity: 1 !important; }
}

.plate--empty .plate__stamp {
  position: absolute;
  left: clamp(0.75rem, 2%, 1.25rem);
  bottom: clamp(0.75rem, 2%, 1.25rem);
  font-family: var(--font-mono);
  font-size: 0.6rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
}

/* --- tables ------------------------------------------------------------- */

.tablewrap { overflow-x: auto; min-width: 0; }
.matrix {
  width: 100%;
  min-width: 520px;
  font-size: 0.95rem;
}
.matrix th,
.matrix td {
  text-align: left;
  padding: 0.85rem 1rem 0.85rem 0;
  border-bottom: 1px solid var(--rule);
  vertical-align: baseline;
}
.matrix thead th {
  font-family: var(--font-mono);
  font-size: 0.6rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
  font-weight: 400;
  border-bottom: 1px solid var(--rule-strong);
}
.matrix tbody th { font-weight: 400; color: var(--ink); }
.matrix td { color: var(--muted); font-variant-numeric: tabular-nums; }
.mk { font-family: var(--font-mono); font-size: 0.78em; }
.mk--y { color: var(--ink); }
.mk--n { color: var(--mark); }
.mk__g { margin-right: 0.4em; }

/* --- misc components ---------------------------------------------------- */

.dl-meta {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(160px, 100%), 1fr));
  border-top: 1px solid var(--rule);
}
.dl-meta > div {
  padding: 1rem 1rem 1rem 0;
  border-bottom: 1px solid var(--rule);
}
.dl-meta dt {
  font-family: var(--font-mono);
  font-size: 0.6rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}
.dl-meta dd { margin-top: 0.4rem; font-size: 0.98rem; }

.quote {
  font-weight: 300;
  font-variation-settings: 'opsz' 36;
  font-size: clamp(1.3rem, 1rem + 1.4vw, 2rem);
  line-height: 1.28;
  letter-spacing: -0.02em;
  max-width: 30ch;
  padding-left: clamp(1rem, 3vw, 2rem);
  border-left: 2px solid var(--mark-2);
}

.dot {
  display: inline-block;
  width: 7px;
  height: 7px;
  border-radius: 99px;
  background: var(--mark-2);
  margin-right: 0.6em;
  vertical-align: 0.1em;
}

.marquee {
  overflow: hidden;
  border-block: 1px solid var(--rule);
  padding-block: 0.9rem;
}
.marquee__track {
  display: flex;
  gap: 2.5rem;
  width: max-content;
  animation: slide 44s linear infinite;
}
.marquee li { font-family: var(--font-mono); font-size: 0.66rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); white-space: nowrap; }
@keyframes slide { to { transform: translateX(-50%); } }

.grid-2 {
  display: grid;
  gap: clamp(1.5rem, 4vw, 3.5rem);
  grid-template-columns: 1fr;
}
@media (min-width: 860px) {
  .grid-2 { grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); }
  .grid-2--even { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
}

.grid-3 {
  display: grid;
  gap: clamp(1.5rem, 3vw, 2.5rem);
  grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
}

.cards { display: grid; gap: 0; border-top: 1px solid var(--rule); }
.card { padding-block: clamp(1.4rem, 3vw, 2.2rem); border-bottom: 1px solid var(--rule); }

/* IA tree — the Remedi navigation map is structured data in the copy, so it
   is drawn rather than flattened to prose. Rules do the connecting. */
.tree__root { display: grid; gap: clamp(1.5rem, 3vw, 2.5rem); grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); }
.tree__root > li { border-top: 1px solid var(--rule-strong); padding-top: 0.9rem; }
.tree__node {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--mark);
}
.tree ul ul { margin-top: 0.75rem; }
.tree ul ul li {
  position: relative;
  padding-left: 1.1rem;
  padding-block: 0.3rem;
  font-size: 0.95rem;
  color: var(--muted);
}
.tree ul ul li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.85em;
  width: 0.6rem;
  height: 1px;
  background: var(--rule-strong);
}
.tree__sub { color: var(--ink); }
.tree ul ul ul li { color: var(--muted); }

/* .pdf / .pdf__fallback removed — they styled an <object> embed-with-fallback
   for a hosted resume.pdf that was never adopted and never existed. Zero uses
   in markup. Leaving them in the sheet is what makes a reader believe the
   /resume route has a broken embed behind it; it does not. The route is a
   real HTML CV and "Download PDF" is browser print-to-PDF, driven by the
   @media print sheet further down. */

.footer { border-top: 1px solid var(--rule); padding-block: 2.5rem; }
.footer__in { display: flex; flex-wrap: wrap; gap: 1rem 2rem; justify-content: space-between; align-items: baseline; }

/* --- motion ------------------------------------------------------------- */
/* Initial states live behind .js so a no-JS visitor sees finished content. */

/* ---- FAILSAFE STATE -----------------------------------------------------
   motion.js sets [data-failsafe] on <html> when the reveal system cannot be
   trusted. This is deliberately !important AND transition:none.

   Removing .js alone was not enough, and the reason is worth recording: the
   reveal states carry a CSS transition, so dropping .js starts a 700ms fade
   from 0 to 1 — and a document that is HIDDEN does not advance transitions.
   The failsafe fired correctly and the content still measured opacity 0,
   frozen at the start of an animation that would not run until the tab was
   looked at. Since the whole point of the failsafe is the hidden-tab case,
   it cannot deliver its result through a transition. This lands the final
   value in one step, with no animation to suspend. */
[data-failsafe] [data-reveal],
[data-failsafe] [data-seq] > *,
[data-failsafe] [data-wipe] .plate__frame {
  opacity: 1 !important;
  transform: none !important;
  clip-path: none !important;
  transition: none !important;
}
[data-failsafe] [data-rule] { transform: none !important; transition: none !important; }

/* ---- UNCONDITIONAL FLOOR ------------------------------------------------
   These four rules are the last line of defence and are deliberately
   !important. Everything above is a motion system; this is the guarantee
   that the motion system cannot take the content with it when it fails.

   Reduced motion: motion.js returns before adding .js, so the hidden states
   never arm — but stating it here too means the guarantee survives someone
   later moving that early return.

   The reveal failsafe in motion.js removes .js on timeout, on a throw, and
   on a bfcache restore. This block is what makes removing .js sufficient. */
@media (prefers-reduced-motion: reduce) {
  [data-reveal], [data-seq] > *, [data-wipe] .plate__frame {
    opacity: 1 !important;
    transform: none !important;
    clip-path: none !important;
  }
  [data-rule] { transform: none !important; }
}

.js [data-rule] { transform: scaleX(0); }
.js [data-reveal] { opacity: 0; transform: translateY(22px); }

/* The clip is applied to the INNER frame, never to the observed element.
   IntersectionObserver accounts for clip-path, so an element resting at
   inset(0 0 100% 0) has zero intersection area and can never report
   isIntersecting — it would wait forever to be revealed by the very
   observer its hidden state blinds. Observed wrapper stays unclipped. */
.js [data-wipe] .plate__frame { clip-path: inset(0 0 100% 0); }

[data-rule] { transition: transform var(--dur-rule) var(--ease-out); }
[data-reveal] {
  transition: opacity 560ms var(--ease-out), transform 560ms var(--ease-out);
}
/* clip-path only — no opacity. The plate is laid down, not dissolved. */
[data-wipe] .plate__frame { transition: clip-path var(--dur-wipe) var(--ease-out); }

/* The hero headline settles over 1200ms so the figures, which land at
   ~1140ms, demonstrably finish first. Expo-out front-loads: ~85% of the
   movement is done by 300ms, so the headline is legible almost immediately
   and never holds up LCP — it simply finishes settling last. */
/* Property-split durations. LCP is gated on PAINT, so the headline's opacity
   must finish fast or the hero delays LCP — measured at 1380ms when both
   properties ran 1200ms, with the H1 as the LCP element. Opacity now settles
   in 300ms (readable almost immediately); the TRANSFORM keeps settling to
   1200ms, which is what still lets the figures finish first. Order matches
   the shorthand in [data-reveal]: opacity, then transform. */
/* The hero headline is the LCP element. It does NOT fade: it paints at full
   opacity immediately (LCP measured 1380ms when it faded over 1200ms, 84ms
   when it does not) and only its POSITION animates. The reader can read it
   from the first paint while it is still settling — which is better craft
   than a fade as well as better metrics. The 900ms slide sits at the HCI band
   ceiling and still finishes after the figures, so the numbers land first. */
.js .hero-h1[data-reveal] { opacity: 1; }
.hero-h1[data-reveal] {
  /* Longhands, explicitly. Relying on the property ORDER inherited from the
     [data-reveal] shorthand made the duration list bind to the wrong
     property and the slide ran for 0ms. */
  /* No transition: the slide is an explicit animation in motion.js, because a
     transition's start value must have been painted and that is a race at
     load. Kept here so the element is not double-driven. */
  transition-property: none;
}

.is-in[data-rule] { transform: scaleX(1); }
.is-in[data-reveal] { opacity: 1; transform: none; }
.is-in[data-wipe] .plate__frame { clip-path: inset(0 0 0 0); }

/* Total kill. Not a duration override — every initial state is dropped and
   motion.js returns before constructing a single observer or animation. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .js [data-rule],
  .js [data-reveal],
  .js [data-wipe] .plate__frame { clip-path: none; }
  [data-rule], [data-reveal], [data-wipe] .plate__frame { transition: none; }
  .marquee__track { animation: none; }
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
}

/* --- small-phone hero budget --------------------------------------------
   The four figures are required to clear the fold at every breakpoint. At
   320x568 the default rhythm put them 128px below it, so the hero — and
   only the hero — runs a compressed scale here. Measured, not guessed:
   figuresBottom must stay under the viewport height at 320x568. */
@media (max-width: 400px) {
  .section--tight { padding-block: 20px; }
  .hero-gap { margin-top: 12px !important; }
  .hero-h1 { margin-top: 5px !important; font-size: 2.2rem; }
  .figures--4 { margin-top: 13px !important; }
  /* 0.75rem, not the 0.5rem this budget originally spent: at 8px the value
     glyph sat 8px under the cell rule and read as stuck to it. Two rows at
     this width, so the extra 4px costs 16px of hero height against 64px of
     measured headroom at 320x568 — the fold requirement still holds. */
  .figures--4 .figure { padding-block: 0.75rem; padding-right: 0.7rem; }
  .figures--4 .figure__val { font-size: 1.72rem; }
  .figures--4 .figure__lab {
    margin-top: 0.4rem;
    font-size: 0.54rem;
    line-height: 1.34;
    letter-spacing: 0.05em;
  }
  .nav__in { padding-block: 7px; }
  .nav__link { min-height: 38px; }
}

@media print {
  .nav, .marquee { display: none; }
  body { background: #fff; }
}

/* ==========================================================================
   KINETIC LAYER
   Added after the measured base (rule-draw 520 / metric-roll 900 /
   plate-wipe 640). Nothing here overrides those three.
   ========================================================================== */

/* --- motion tokens -------------------------------------------------------
   Springs are pre-computed linear() strings, not a JS integrator: the whole
   curve runs on the compositor and costs zero runtime JS. Generated from a
   damped-harmonic solver — tactile carries ~8.5% overshoot (zeta 0.615),
   settle is critically damped (zeta 0.997). Mechanic measured on
   aanchaldesign in the reference corpus.                                   */
:root {
  --spring-tactile: linear(0 0%, 0.0598 3.33%, 0.2019 6.67%, 0.3803 10%, 0.5616 13.33%, 0.7247 16.67%, 0.8582 20%, 0.9582 23.33%, 1.0259 26.67%, 1.0658 30%, 1.0836 33.33%, 1.0856 36.67%, 1.0773 40%, 1.0635 43.33%, 1.0478 46.67%, 1.0325 50%, 1.0193 53.33%, 1.0088 56.67%, 1.0012 60%, 0.9963 63.33%, 0.9936 66.67%, 0.9926 70%, 0.9928 73.33%, 0.9937 76.67%, 0.995 80%, 0.9964 83.33%, 0.9976 86.67%, 0.9987 90%, 0.9995 93.33%, 1.0001 96.67%, 1 100%);
  --spring-settle: linear(0 0%, 0.0229 3.33%, 0.0789 6.67%, 0.1535 10%, 0.2365 13.33%, 0.3214 16.67%, 0.4039 20%, 0.4812 23.33%, 0.5521 26.67%, 0.6159 30%, 0.6725 33.33%, 0.7221 36.67%, 0.7653 40%, 0.8025 43.33%, 0.8344 46.67%, 0.8615 50%, 0.8846 53.33%, 0.904 56.67%, 0.9204 60%, 0.9341 63.33%, 0.9455 66.67%, 0.9551 70%, 0.963 73.33%, 0.9696 76.67%, 0.975 80%, 0.9795 83.33%, 0.9832 86.67%, 0.9863 90%, 0.9888 93.33%, 0.9909 96.67%, 1 100%);
  --stagger: 48ms;
  --dur-press: 140ms;
  --dur-hover: 260ms;
  --dur-seq: 600ms;
}

/* --- SYSTEM 1: orchestrated entrance -------------------------------------
   A parent orchestrator drives child timing. The parent is observed and
   carries NO hiding state; the children carry it. That inversion is what
   makes this structurally immune to the clip-path/IntersectionObserver
   deadlock found in Stage 3 — an observed element is never a hidden one.
   Timing is declarative: --i comes from nth-child, so there are no
   per-element delays scattered through JS.                                */
.js [data-seq] > * {
  opacity: 0;
  transform: translateY(38px);
}
[data-seq] > * {
  transition:
    opacity var(--dur-seq) var(--ease-out) calc(var(--i, 0) * var(--stagger)),
    transform var(--dur-seq) var(--spring-settle) calc(var(--i, 0) * var(--stagger));
}
[data-seq].is-in > * { opacity: 1; transform: none; }

/* Cap the cascade at 6. Everything past the sixth child shares the sixth
   slot, so a long list collapses to one group reveal instead of trailing
   a half-second tail. */
[data-seq] > *:nth-child(1) { --i: 0; }
[data-seq] > *:nth-child(2) { --i: 1; }
[data-seq] > *:nth-child(3) { --i: 2; }
[data-seq] > *:nth-child(4) { --i: 3; }
[data-seq] > *:nth-child(5) { --i: 4; }
[data-seq] > *:nth-child(6) { --i: 5; }
[data-seq] > *:nth-child(n + 7) { --i: 6; }

/* Horizontal variant for row-wise groups — travel comes from the gutter. */
.js [data-seq='x'] > * { transform: translateX(-34px); }

/* --- SYSTEM 2: tactile spring hover / press ------------------------------ */
.link, .nav__link, .email, .plate__frame img, .link__arrow {
  transition-timing-function: var(--spring-tactile);
}
.link { transition: color var(--dur-ui) var(--ease-out), transform var(--dur-press) var(--spring-tactile); }
.link__arrow { transition: transform var(--dur-hover) var(--spring-tactile); }

/* Exit ~65% of enter, per the duration bands. */
.link:not(:hover):not(:focus-visible) .link__arrow { transition-duration: 170ms; }

@media (hover: hover) and (pointer: fine) {
  /* differential: the card's inner elements respond at different rates, so
     the card reads as layered rather than as one flat rectangle */
  .card[data-state] .t-title { transition: transform var(--dur-hover) var(--spring-tactile); }
  .card[data-state='hover'] .t-title { transform: translateX(4px); }
  .card[data-state='hover'] .link__arrow { transform: translateX(6px); }
}

/* --- SYSTEM 4: parallax layers ------------------------------------------
   Transform is written by a single rAF subscriber into --pary; CSS consumes
   it. Amplitude is real: annotation layer runs to ±72px, plates to ±40px.
   Fine-pointer + wide viewport only; everything else stays static.        */
@media (hover: hover) and (pointer: fine) and (min-width: 901px) {
  [data-par] { will-change: auto; transform: translate3d(0, var(--pary, 0px), 0); }
  [data-par='back'] { --par-amp: 72; }
  [data-par='mid'] { --par-amp: 40; }
  [data-par='fore'] { --par-amp: -18; }
  [data-par].par-live { will-change: transform; }
}

/* Background annotation layer — the only decorative element in the system.
   It is a datum grid, drawn in the same hairline vocabulary as the rules,
   and it carries depth rather than ornament.

   Its horizontals are register TICKS in the outer 1.8% of the section, not
   full-width lines, and that is a correction rather than a style choice. A
   full-width hairline at a percentage height is indistinguishable from the
   structural rules this system uses everywhere — so wherever one happened to
   land a few pixels above a line of type, it read as a badly set rule with
   the text stuck to it. Measured: 3px above the case-study eyebrow, 8px
   above every dl-meta label on four pages, and straight THROUGH the About
   display headline and the contact email. It could not be nudged out,
   either: the layer parallaxes, so the offending gap changes with scroll.
   The verticals stay full height — nothing else in the system draws a
   vertical rule, so they carry no such ambiguity and read as column guides.
   Ticks live inside the gutter (1.8% against a gutter that never falls
   below 3.6% of the width), so they cannot reach type at any viewport. */
.datum {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
  z-index: 0;
}
.datum svg { width: 100%; height: 100%; display: block; }
/* The verticals are column guides, and a phone has no columns to guide: at
   430px they land at 108px and 323px and cut straight through the FIG.01
   line and the figure captions, which is the same misread the horizontals
   were making. The edge ticks stay — they are in the gutter at any width.
   x1 == x2 only for the two verticals, so this selects exactly them. */
@media (max-width: 699px) {
  .datum line[x1='300'],
  .datum line[x1='900'] { display: none; }
}
/* non-scaling-stroke because preserveAspectRatio="none" stretches the box:
   without it a vertical hairline paints at 1.33px on a 1600px canvas and a
   horizontal one at 0.8px, so the same "hairline" arrives at two weights. */
.datum line {
  stroke: var(--rule);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}
.section { position: relative; }
.section > .wrap { position: relative; z-index: 1; }

/* --- SYSTEM 5: explicit, inspectable states ----------------------------- */
[data-state='pressed'] { transform: scale(0.972); }
[data-loaded='false'] .plate__frame { background: var(--panel); }
[data-loaded='true'] .plate__frame { background: transparent; }
[data-roll][data-done='true'] { --roll-done: 1; }
.nav__link[aria-current='page'] { color: var(--ink); box-shadow: inset 0 -1px 0 var(--mark-2); }

/* --- SYSTEM 6: 3D coverflow — REMOVED, second time -----------------------
   Built as a faithful native port of the Originkit component, measured
   clean (p95 17.5ms, zero frames over 20ms), then removed at the owner's
   direction: the bento read as more elegant for this portfolio.

   Worth recording why, because it is not a performance verdict. A coverflow
   shows one photograph and hints at four; a bento shows all seven at once.
   For a "life beyond the canvas" gallery the whole point is breadth — the
   set IS the content — so a mechanic whose job is to pick a single subject
   was working against the section. The 13-reference corpus reached the same
   conclusion about the draggable canvas: hiding evidence behind an
   interaction costs more than the interaction returns.

   System 6 is therefore not in the build. No 3D anywhere on the site.     */

/* --- design-system spec sheet --------------------------------------------
   The Firstcry system evidence — two type specimens, the client palette and
   four component sheets — laid out as one ruled sheet rather than as loose
   blocks in a two-column grid.

   It is deliberately a SHEET and not a bento. This is reference material: a
   reader scans it for "what is in the system", and the site already has a
   vocabulary for exactly that in .figures, .dl-meta and .matrix — hairline
   rules, mono labels, top-aligned cells. Giving it interlocking boxes of
   unequal size would have made a lookup table into a mood board.

   Three tiers, and the cell spans are written so the type pair always sits
   side by side and the palette always spans the full sheet:
     >= 1100   four columns   type 2+2, palette 4, one shot per column
     >=  700   two columns    type 1+1, palette 2, shots 2x2
     <   700   one column                                                 */
.spec {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  column-gap: clamp(1.25rem, 3vw, 2.5rem);
  border-top: 1px solid var(--rule-strong);
}
.spec__cell {
  min-width: 0;
  padding-block: clamp(1.1rem, 2.4vw, 1.7rem);
  border-bottom: 1px solid var(--rule);
}
.spec__cell--full { grid-column: 1 / -1; }
@media (min-width: 700px) {
  .spec { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1100px) {
  .spec { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .spec__cell--type { grid-column: span 2; }
}

/* Specimen. Set in the display face at a size that lets you actually judge
   the letterforms — the point of a specimen — with the weights beneath as
   annotation, not as part of the specimen. */
.spec__face {
  display: block;
  margin-top: 0.55rem;
  font-family: var(--font-display);
  font-weight: 300;
  font-variation-settings: 'opsz' 48;
  font-size: clamp(1.7rem, 1rem + 2.4vw, 3.1rem);
  line-height: 1.02;
  letter-spacing: -0.025em;
  color: var(--ink);
  text-wrap: balance;
}
.spec__note {
  display: block;
  margin-top: 0.8rem;
  font-family: var(--font-mono);
  font-size: 0.6rem;
  line-height: 1.5;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--muted);
}

/* Palette. The five hex values are the CLIENT's, presented as evidence, so
   the swatch has to be big enough to actually read as that colour — the
   1.05em inline square this replaces was 11px, which reports a hex code
   rather than showing a palette. A 3:2 block roughly 270px wide at desktop
   is ~24x the area.

   Below 700px the chip turns side-on: a five-across strip of 60px columns
   cannot carry "ROYAL BLUE #1F61F1" underneath it, and stacking the label
   under a full-width band wastes the row. Chip left, name beside. */
.pal {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: clamp(0.5rem, 1.2vw, 0.9rem);
  margin-top: 0.9rem;
}
.pal__i {
  min-width: 0;
  font-family: var(--font-mono);
  font-size: 0.6rem;
  line-height: 1.45;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
}
.pal__c {
  display: block;
  aspect-ratio: 3 / 2;
  background: var(--c);
  margin-bottom: 0.6rem;
  /* Jet Black sits 8% off the ink and Xanthous is pale: an inset hairline
     keeps both from dissolving into the paper at their own edges. */
  box-shadow: inset 0 0 0 1px rgba(20, 23, 26, 0.12);
}
@media (max-width: 699px) {
  .pal { grid-template-columns: minmax(0, 1fr); gap: 0.55rem; }
  .pal__i { display: flex; align-items: center; gap: 0.9rem; }
  .pal__c { flex: 0 0 auto; width: 5.5rem; margin-bottom: 0; }
}

/* Component sheets. These render inside the panel as a mount, padded, and
   are capped at their own source width by --w: the icon library is a 236px
   PNG and a 340px column would have upscaled it 1.44x, which is how UI
   evidence ends up looking softer than the UI it documents. width:100% is
   kept so the attribute box still reserves the space before the lazy image
   lands; --w only ever narrows it. verify-assets.mjs fails the build if a
   --w and its image disagree. */
/* The four sheets are wildly different shapes (236x120 next to 438x424), so
   left to themselves the mounts end at four different heights under one
   ruled row. The cell is a column and the mount takes the leftover, which
   squares the row off and centres each sheet in its own mount — a contact
   sheet rather than four loose crops. */
.spec__cell { display: flex; flex-direction: column; }
.spec__shot { margin-top: 0.9rem; flex: 1 1 auto; }
.spec__shot .plate__frame {
  height: 100%;
  background: var(--panel);
  padding: clamp(0.85rem, 2vw, 1.4rem);
  display: grid;
  place-items: center;
}
.spec__shot img {
  display: block;
  width: 100%;
  max-width: calc(var(--w, 9999) * 1px);
  height: auto;
  margin-inline: auto;
}

/* --- custom cursor -------------------------------------------------------
   A register mark, not a novelty. The site annotates everything else with
   hairline squares and one red node, so the pointer is drawn the same way:
   a lagging hairline square for presence, an exact ink dot for precision.
   The dot never lerps — a cursor that trails its own click point is a toy,
   and this one still has to hit 44px targets.

   Armed only by the motion layer and only on a fine pointer, so touch keeps
   its native behaviour and reduced-motion readers (where motion.js returns
   before this runs) keep the system cursor entirely. */
.cur, .cur * { pointer-events: none; }
.cur {
  position: fixed;
  left: 0; top: 0;
  z-index: 200;
  opacity: 0;
  transition: opacity 200ms var(--ease-out);
}
.cur[data-on] { opacity: 1; }
.cur__ring, .cur__dot {
  position: fixed;
  left: 0; top: 0;
  will-change: transform;
}
.cur__ring {
  width: 24px; height: 24px;
  margin: -12px 0 0 -12px;
  border: 1px solid rgba(20, 23, 26, 0.38);
  transition: width 220ms var(--ease-out), height 220ms var(--ease-out),
              margin 220ms var(--ease-out), border-color 220ms var(--ease-out),
              transform 40ms linear;
}
.cur__dot {
  width: 3px; height: 3px;
  margin: -1.5px 0 0 -1.5px;
  background: var(--ink);
  transition: opacity 180ms var(--ease-out);
}
/* Over anything clickable: open up and take the accent. */
.cur[data-mode='link'] .cur__ring {
  width: 42px; height: 42px; margin: -21px 0 0 -21px;
  border-color: var(--mark-2);
}
/* Over the hero: this is a thing you can grab and turn. */
.cur[data-mode='drag'] .cur__ring {
  width: 34px; height: 34px; margin: -17px 0 0 -17px;
  border-color: var(--mark);
}
.cur[data-mode='drag'] .cur__dot { opacity: 0; }
.cur[data-press] .cur__ring { width: 18px; height: 18px; margin: -9px 0 0 -9px; }
/* On the inverted sections the hairline and dot have to invert too. */
.cur[data-inv] .cur__ring { border-color: rgba(228, 231, 226, 0.45); }
.cur[data-inv] .cur__dot { background: var(--ink-inv); }
/* Hide the native pointer only once ours exists in the DOM. */
.has-cur, .has-cur * { cursor: none; }
/* CSS floor for the cursor, which JS state cannot override. `cursor: none`
   on 457 elements with nothing painted in its place leaves the page with no
   pointer at all; on a touch device or under reduced motion there is no
   custom cursor to paint, so the native one must win regardless of what any
   script has put on the root. */
@media (pointer: coarse), (prefers-reduced-motion: reduce) {
  .has-cur, .has-cur * { cursor: auto !important; }
}

/* --- hero: grabbable, not selectable -------------------------------------
   The hero read as a block of text because the I-beam said so. It is a
   surface you turn. user-select is off so the drag never starts a
   selection, and the grab cursor is what a non-JS or touch reader still
   gets — .has-cur overrides it for everyone else. The panel is exempt:
   sliders need their own affordances and their labels stay selectable. */
/* touch-action: pan-y moved here from .field with the pointer handlers.
   The hero now captures pointerdown itself, so it is the element that has
   to tell the browser which gestures it is claiming: vertical panning stays
   with the page, horizontal drag becomes an orbit. Left on the canvas alone
   it applied to an element no touch ever reaches. */
.hero { cursor: grab; user-select: none; -webkit-user-select: none; touch-action: pan-y; }
.hero[data-grab] { cursor: grabbing; }
.hero .knobs { cursor: auto; user-select: auto; -webkit-user-select: auto; }
.hero a, .hero input, .hero button, .hero label { cursor: pointer; }

/* --- lumen: the three plates now show what they are ----------------------
   They were ruled empty boxes three across, which reads as three failures
   rather than one honest placeholder. Each now draws the thing it is named
   after, in the same hairline vocabulary: a route being traced, a charge
   curve filling, three states stepping. Pure SVG + CSS — no third WebGL
   context on a page that already has none, and nothing to initialise.
   All of it stops dead under reduced motion. */
.pl { width: 100%; height: 100%; display: block; overflow: visible; }
.pl .g { stroke: var(--rule); stroke-width: 1; fill: none; }
.pl .ink { stroke: var(--ink); stroke-width: 1.4; fill: none; opacity: 0.55; }
.pl .acc { stroke: var(--mark); stroke-width: 1.6; fill: none; }
.pl .accf { fill: var(--mark); stroke: none; }

.pl__route { stroke-dasharray: 420; stroke-dashoffset: 420; animation: pl-draw 5.2s var(--ease-out) infinite; }
.pl__pip { animation: pl-run 5.2s linear infinite; }
@keyframes pl-draw { 0% { stroke-dashoffset: 420; } 55%, 100% { stroke-dashoffset: 0; } }
@keyframes pl-run { 0% { offset-distance: 0%; opacity: 0; } 6% { opacity: 1; }
                    55% { offset-distance: 100%; opacity: 1; } 70%, 100% { offset-distance: 100%; opacity: 0; } }

.pl__fill { transform-origin: 50% 100%; animation: pl-fill 4.4s var(--ease-out) infinite; }
@keyframes pl-fill { 0% { transform: scaleY(0.06); } 60%, 100% { transform: scaleY(1); } }
.pl__ring { animation: pl-pulse 2.2s var(--ease-out) infinite; transform-origin: center; }
@keyframes pl-pulse { 0% { opacity: 0.9; transform: scale(0.9); } 70%, 100% { opacity: 0; transform: scale(1.35); } }

.pl__st { opacity: 0.18; animation: pl-step 4.8s steps(1, end) infinite; }
.pl__st:nth-of-type(1) { animation-delay: 0s; }
.pl__st:nth-of-type(2) { animation-delay: 1.1s; }
.pl__st:nth-of-type(3) { animation-delay: 2.2s; }
@keyframes pl-step { 0%, 22% { opacity: 1; } 23%, 100% { opacity: 0.18; } }

@media (prefers-reduced-motion: reduce) {
  .pl__route { animation: none; stroke-dashoffset: 0; }
  .pl__pip, .pl__ring { animation: none; }
  .pl__pip { opacity: 1; offset-distance: 62%; }
  .pl__fill { animation: none; transform: none; }
  .pl__st { animation: none; opacity: 1; }
}

/* --- about bento ---------------------------------------------------------
   Seven photographs, three cell sizes, one grid. All sources are portrait,
   so cells crop with object-fit rather than letterboxing — the cell shape
   is the composition, which is what makes a bento read as designed rather
   than as a gallery that happened to have gaps.

   Gap is tighter than the site's other grids (clamp 8-14px against
   24-40px): the cells need to read as one plate divided, not as seven
   separate plates. That is the whole difference between a bento and a
   grid of thumbnails.                                                     */
.bento {
  display: grid;
  gap: clamp(0.5rem, 1vw, 0.875rem);
}
.bento > .plate { min-width: 0; min-height: 0; }
.bento .plate__frame { height: 100%; }
.bento img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* Two columns below the fold-out. The two features go full width, so the
   phone gets a rhythm of wide-tall-tall rather than a uniform strip. */
@media (max-width: 759px) {
  .bento { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .bento > * { aspect-ratio: 3 / 4; }
  .bento > :nth-child(1),
  .bento > :nth-child(5) { grid-column: span 2; aspect-ratio: 16 / 10; }
}

/* The bento proper. Explicit areas rather than auto-placement: the
   interlock is the design, and dense auto-flow would reshuffle it at every
   breakpoint.

   SIX columns, not four, and that is the whole reason this block can now
   fill the measure. Cell units are square in both maps, so a 4x4 map is a
   square block — at the full 1441px column that is a 1441px-tall wall, and
   the earlier fix for that was a 900px cap, which left the gallery using
   62% of the width and reading as though it had failed to lay out.

   Six columns cuts the unit to a sixth of the width, so four rows of square
   units make a 3:2 block: 1441 x 961 at a 1600px viewport. Full width,
   under one screen tall, no cap. The map is built only from 2x4, 2x2 and
   1x2 cells because every photograph in this set is portrait (0.56 to 0.80)
   — a landscape cell would have to crop one of them across its subject. The
   two 2x2 squares are offset by one column (b starts at 3, f starts at 4)
   so the mosaic still interlocks rather than reading as stacked blocks.

   The mosaic takes over at 760px, not higher. Six columns of a 684px
   content width is a 114px unit, which is small — but the alternative at
   760-899 is the phone's two-column stack, and that measured 794 x 2599:
   nearly three screens of scrolling for a block that is meant to be taken
   in at once. A small mosaic beats a tall stack. */
@media (min-width: 760px) {
  .bento {
    /* Bare fr, not minmax(0, 1fr): the hover states interpolate against
       these, and a minmax() track will not interpolate against a bare fr.
       Blowout is guarded by min-width/min-height: 0 on the cells. */
    grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr;
    grid-template-rows: 1fr 1fr 1fr 1fr;
    aspect-ratio: 3 / 2;
    grid-template-areas:
      "a a b b c d"
      "a a b b c d"
      "a a e f f g"
      "a a e f f g";
  }
  /* Cell shapes: a 2x4 (0.50), b/f 2x2 (1.00), c/d/e/g 1x2 (0.50).
     Assigned so each photograph lands in the cell nearest its own ratio,
     and the strongest image — the city portrait — takes the tall hero. */
  .bento > :nth-child(1) { grid-area: a; }  /* 0.67 city portrait  -> hero */
  .bento > :nth-child(2) { grid-area: c; }  /* 0.56 desk at night  -> 0.50 */
  .bento > :nth-child(3) { grid-area: b; }  /* 0.80 hills          -> 1.00 */
  .bento > :nth-child(4) { grid-area: d; }  /* 0.75 waterfall      -> 0.50 */
  .bento > :nth-child(5) { grid-area: f; }  /* 0.80 stone steps    -> 1.00 */
  .bento > :nth-child(6) { grid-area: e; }  /* 0.56 sunflowers     -> 0.50 */
  .bento > :nth-child(7) { grid-area: g; }  /* 0.75 autumn woods   -> 0.50 */
}

/* --- bento spotlight: the grid itself rearranges -------------------------
   Hovering a photograph makes the tracks it occupies grow and the rest
   contract. Nothing fades, nothing scales, nothing overlaps — the cells
   genuinely change size and the mosaic redistributes around the one being
   looked at.

   This animates grid-template-columns/rows, which IS a layout animation.
   That is a deliberate, owner-directed exception to the transform/opacity
   rule, taken with the cost known and measured (see the commit): every
   frame relayouts the container and resamples seven images. It is confined
   to one component, one axis pair, one interaction, on desktop only.

   Interpolation requires the track COUNT and TYPE to match between states,
   so every rule below writes six plain fr values for columns and four for
   rows. minmax(0, 1fr) would not interpolate against a bare fr, so the
   blowout guard moved to min-width/min-height: 0 on the cells instead.

   Track maths — a hovered cell spanning k of n tracks boosts each to B, and
   the remaining (n - k) tracks absorb k(B-1) equally, so the total stays
   n fr and the container never changes size. B is solved for a constant
   major:minor ratio of 2.4 rather than picked, so the interaction feels
   identical whichever cell you point at:
     cols, n=6, k=1  ->  1.945 / 0.811
     cols, n=6, k=2  ->  1.636 / 0.682
     rows, n=4, k=2  ->  1.412 / 0.588
   The hero spans all four rows, so there is no row to shrink against it and
   its hover moves columns only — which is correct, not a gap: a cell that
   already owns the full height cannot grow vertically.

   2.4 was arrived at by measurement, not taste. At the arithmetically neat
   1.5/0.5 a two-track cell gives 3.01:1, which measured out as 89px slivers
   for the minor columns — dramatic, but the small photographs stop reading
   as photographs.

   The whole effect is gated on prefers-reduced-motion: no-preference, so it
   does not exist at all for a reader who asked for less. That is cleaner
   than overriding it back out further down the sheet.                     */
@media (hover: hover) and (pointer: fine) and (min-width: 760px)
   and (prefers-reduced-motion: no-preference) {
  .bento {
    transition:
      grid-template-columns 420ms var(--ease-out-soft),
      grid-template-rows 420ms var(--ease-out-soft);
  }

  /* Area map this is derived from:
       "a a b b c d"   a: cols 1-2, rows 1-4    e: col 3,    rows 3-4
       "a a b b c d"   b: cols 3-4, rows 1-2    f: cols 4-5, rows 3-4
       "a a e f f g"   c: col 5,    rows 1-2    g: col 6,    rows 3-4
       "a a e f f g"   d: col 6,    rows 1-2                            */

  /* child 1 -> a : cols 1-2, all rows */
  .bento:has(> :nth-child(1):hover) {
    grid-template-columns: 1.636fr 1.636fr 0.682fr 0.682fr 0.682fr 0.682fr;
    grid-template-rows:    1fr 1fr 1fr 1fr;
  }
  /* child 2 -> c : col 5, rows 1-2 */
  .bento:has(> :nth-child(2):hover) {
    grid-template-columns: 0.811fr 0.811fr 0.811fr 0.811fr 1.945fr 0.811fr;
    grid-template-rows:    1.412fr 1.412fr 0.588fr 0.588fr;
  }
  /* child 3 -> b : cols 3-4, rows 1-2 */
  .bento:has(> :nth-child(3):hover) {
    grid-template-columns: 0.682fr 0.682fr 1.636fr 1.636fr 0.682fr 0.682fr;
    grid-template-rows:    1.412fr 1.412fr 0.588fr 0.588fr;
  }
  /* child 4 -> d : col 6, rows 1-2 */
  .bento:has(> :nth-child(4):hover) {
    grid-template-columns: 0.811fr 0.811fr 0.811fr 0.811fr 0.811fr 1.945fr;
    grid-template-rows:    1.412fr 1.412fr 0.588fr 0.588fr;
  }
  /* child 5 -> f : cols 4-5, rows 3-4 */
  .bento:has(> :nth-child(5):hover) {
    grid-template-columns: 0.682fr 0.682fr 0.682fr 1.636fr 1.636fr 0.682fr;
    grid-template-rows:    0.588fr 0.588fr 1.412fr 1.412fr;
  }
  /* child 6 -> e : col 3, rows 3-4 */
  .bento:has(> :nth-child(6):hover) {
    grid-template-columns: 0.811fr 0.811fr 1.945fr 0.811fr 0.811fr 0.811fr;
    grid-template-rows:    0.588fr 0.588fr 1.412fr 1.412fr;
  }
  /* child 7 -> g : col 6, rows 3-4 */
  .bento:has(> :nth-child(7):hover) {
    grid-template-columns: 0.811fr 0.811fr 0.811fr 0.811fr 0.811fr 1.945fr;
    grid-template-rows:    0.588fr 0.588fr 1.412fr 1.412fr;
  }

  /* The site-wide plate hover is a 2.8% image nudge. The cell is already
     resizing under it, so suppress it — one idea per event. */
  .bento .plate:hover .plate__frame img { transform: none; }
}

/* --- hero field ----------------------------------------------------------
   The cursor-reactive background, rebuilt from the jar_vis reference.

   NOT a port. jar_vis is Three.js + EffectComposer + UnrealBloom pulling
   ~150 KB gzip off unpkg, against a 12,000 B budget and a zero-dependency
   rule — and 20,000 bloom-lit amber cones on a black void is a different
   site's aesthetic. What carried over is the IDEA: a dense field that reads
   as one continuous surface and deforms around the pointer.

   Translated into this system, that is a drafting graticule. Ink points on
   paper at the hairline weight the rules already use, spaced on a regular
   grid because this document is ruled, not organic. The pointer is a lens:
   points near it push outward, gain weight, and the closest few pick up the
   annotation red — the site's single accent, used here exactly as it is
   used everywhere else, to mark the thing being attended to.

   Absolutely positioned and pointer-events: none, so it cannot affect
   layout (CLS) or intercept a click. Painted only by the motion layer.   */
/* .datum is already position: absolute; inset: 0 — do NOT restate position
   here. Forcing it to relative put a 619px decorative overlay into flow and
   pushed the headline to the bottom edge of the viewport. Raise its layer
   only; .section > .wrap already handles the content layer. */
/* The hero owns the fold. Without this the paragraph and the figures sit in
   the first viewport anyway and moving them below it achieved nothing.
   svh, not vh: on mobile vh is the tallest-possible viewport, so the hero
   would be pushed under the browser chrome on first paint.
   Capped at 860px so a tall desktop display does not turn one line of type
   into an empty field. */
.hero {
  position: relative;
  isolation: isolate;
  /* Sized to its content, not stretched to the viewport.
     Stretching was the mistake behind both previous versions: with only two
     content blocks, "fill the viewport" means the leftover space GROWS with
     screen height, so the gap was either a void on a tall display or the
     next section peeking on a short one. There is no single stretch value
     that is right at both ends.
     So the interval is designed instead of residual — a bounded clamp on
     the rule above the figures — and min-height only guarantees the hero
     reads as a full opening screen, capped so it can never outrun the
     content inside it. */
  /* Raised alongside the doubled interval below. The gap is designed, not
     residual, so the hero has to be told to make room for it — leaving this
     at 640px would have pushed the figures out of the section instead of
     opening the space. */
  min-height: min(96svh, 880px);
  display: flex;
  flex-direction: column;
  /* Phones opt out of the guaranteed full screen. This was first justified
     by the field not running below 700px — that reason is now void, the
     field runs everywhere — but the measurement that drove it still holds:
     at 430x932 the guarantee left 174px of empty paper under the figures
     before the next section. The content block is ~700px on a phone, which
     is canvas enough for a background; padding it out to 96svh bought
     nothing but scroll. Let the content set the height. */
  padding-block: clamp(1.25rem, 3.5svh, 2.5rem) clamp(1.5rem, 4svh, 3rem);
}
/* .wrap carries margin-inline: auto, which in a flex column beats the
   default stretch and shrink-wraps it to content width — the headline lost
   the page's left axis and sat 75px in from the nav. Restore full width. */
/* The wrap becomes the distributing column: label and headline at the top,
   the rule + figures pushed to the bottom by margin-top: auto on the rule.
   Distributing here rather than on .hero, because every child lives inside
   the wrap and justify-content on .hero would only move the wrap itself. */
.hero > .wrap { width: 100%; flex: 1; display: flex; flex-direction: column; }
.hero .figures { margin-top: clamp(1rem, 2.5svh, 1.75rem) !important; }
/* The interval, bounded. Not margin-top: auto — that is what produced the
   void. It still breathes with viewport height, between real limits. */
.hero__foot { margin-top: clamp(4.5rem, 14svh, 9rem) !important; }
@media (max-width: 699px) {
  .hero { min-height: 0; }
  /* The interval stays generous — it is what stops the phone hero feeling
     cramped — but it no longer has to also fill a stretched section. */
  .hero__foot { margin-top: clamp(3.5rem, 11svh, 6rem) !important; }
}

.hero > .datum { z-index: 1; }
.field {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  display: block;
  /* Draggable, so it must accept pointer events. It sits at z-0 beneath the
     content layer (z-1), so text and links stay clickable and the canvas
     only catches what lands on empty ground. */
  pointer-events: auto;
  cursor: grab;
  touch-action: pan-y;
  /* Nothing paints until the field has points to draw. Avoids a flash of
     empty canvas box during font swap. */
  opacity: 0;
  transition: opacity 900ms var(--ease-out);
}
.field[data-on] { opacity: 1; }
.field[data-grab] { cursor: grabbing; }

/* --- first-load intro ----------------------------------------------------
   The brain takes the whole screen, revolves, then falls back to being the
   hero's ground while the page resolves behind it.

   The veil is display:none by default and only shown under .js. That is the
   safety property that matters: motion.js is what removes it, so if the
   script never runs — no JS, or reduced motion, which returns before adding
   .js — the veil is never shown in the first place and cannot strand the
   reader behind a curtain it has no way to lift.

   It does NOT gate LCP. A solid-colour div is not an LCP candidate, and
   occlusion is not part of the LCP algorithm, so the H1 painting behind the
   veil still registers at its normal time.                                */
.veil {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 60;
  background: var(--paper);
}
/* Shown ONLY once the intro is actually running. Previously this keyed off
   .js, which motion.js adds at its very first statement — but the field is
   initialised later, so the curtain went up on an empty stage and the
   reader watched blank paper until the GL context existed. The veil is now
   tied to the intro state, and that state is not set until the context is
   live, sized, and rendering. */
[data-intro='in'] .veil,
[data-intro='out'] .veil { display: block; }
/* The paper waits. The type and the mark are pulled off it first, over
   ~560ms, and only then does the sheet dissolve — so the sequence reads as
   the loader LEAVING rather than the loader and the page cross-fading into
   a moment where both are half-present and neither is legible. Measured on
   the first build: ease-out on the veil starts fast and ease-in-out on the
   type starts slow, so at 100ms the paper was 55% gone and the type had not
   visibly moved at all. */
[data-intro='out'] .veil {
  opacity: 0;
  pointer-events: none;
  transition: opacity 620ms var(--ease-out) 200ms;
}
[data-intro='done'] .veil { display: none; }

/* During the intro the canvas leaves the hero and owns the viewport. */
[data-intro='in'] .field, [data-intro='out'] .field {
  position: fixed;
  inset: 0;
  width: 100vw;
  height: 100svh;
  z-index: 61;
}
[data-intro='done'] .field {
  position: absolute;
  width: 100%;
  height: 100%;
  z-index: 0;
}

/* --- the loader ----------------------------------------------------------
   Sits at z-62, above the paper veil (60) and the full-viewport field (61),
   so the type reads against the field rather than being buried by it.

   ENTRY IS ANIMATIONS, EXIT IS REPLACEMENT ANIMATIONS — deliberately, not
   a transition. A `forwards` fill pins the final value at a higher cascade
   level than any transition can reach, so an exit written as a transition
   silently loses to the entry that just finished. Swapping the animation
   under the same selector is the one form that composes: the entry stops,
   the exit runs from wherever it was, and neither can strand the other.

   Every entry finishes by ~1410ms and the controller holds the loader for
   at least 1600ms, so the exit never interrupts an unfinished entry.

   Nothing here is load-bearing. If motion.js never runs, [data-intro] is
   never set and this whole block never matches.                          */
.load {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 62;
  pointer-events: none;
  color: var(--ink);
}
[data-intro='in'] .load,
[data-intro='out'] .load { display: block; }
[data-intro='done'] .load { display: none; }
[data-intro='out'] .load { animation: ld-veil 440ms var(--ease-out) 240ms forwards; }
@keyframes ld-veil { to { opacity: 0; } }

/* Crop marks: where the sheet gets trimmed. Two borders per corner, so the
   L is one element and there is no second layer to keep in register. */
.load__crop i {
  position: absolute;
  width: 20px; height: 20px;
  border: 0 solid var(--rule-strong);
  opacity: 0;
  animation: ld-crop 520ms var(--ease-out) forwards;
}
.load__crop i:nth-child(1) { top: var(--crop); left: var(--crop); border-top-width: 1px; border-left-width: 1px; }
.load__crop i:nth-child(2) { top: var(--crop); right: var(--crop); border-top-width: 1px; border-right-width: 1px; animation-delay: 70ms; }
.load__crop i:nth-child(3) { bottom: var(--crop); right: var(--crop); border-bottom-width: 1px; border-right-width: 1px; animation-delay: 140ms; }
.load__crop i:nth-child(4) { bottom: var(--crop); left: var(--crop); border-bottom-width: 1px; border-left-width: 1px; animation-delay: 210ms; }
.load { --crop: clamp(14px, 2.6vh, 26px); }
@keyframes ld-crop { to { opacity: 1; } }
[data-intro='out'] .load__crop i { animation: ld-fade 380ms var(--ease-out) forwards; }
@keyframes ld-fade { from { opacity: 1; } to { opacity: 0; } }

/* The sheet's two centrelines. They arrive from the middle out, which is
   the gesture the whole mark is about: finding centre. */
.load__cross-h, .load__cross-v { position: absolute; background: var(--rule); }
.load__cross-h {
  left: 0; right: 0; top: 50%; height: 1px;
  transform: scaleX(0);
  animation: ld-h 900ms var(--ease-out) 180ms forwards;
}
.load__cross-v {
  top: 0; bottom: 0; left: 50%; width: 1px;
  transform: scaleY(0);
  animation: ld-v 900ms var(--ease-out) 180ms forwards;
}
@keyframes ld-h { to { transform: scaleX(1); } }
@keyframes ld-v { to { transform: scaleY(1); } }
[data-intro='out'] .load__cross-h { animation: ld-out-h 540ms var(--ease-out) forwards; }
[data-intro='out'] .load__cross-v { animation: ld-out-v 540ms var(--ease-out) forwards; }
@keyframes ld-out-h { from { transform: scaleX(1); } to { transform: scaleX(0); } }
@keyframes ld-out-v { from { transform: scaleY(1); } to { transform: scaleY(0); } }

.load__mark {
  position: absolute;
  left: 50%; top: 50%;
  width: clamp(84px, 15vmin, 140px);
  height: auto;
  transform: translate(-50%, -50%);
  fill: none;
  stroke: var(--ink);
  stroke-width: 1.4;
  overflow: visible;
}
/* Starts at 12 o'clock so the ring closes where the top tick lands. */
.load__ring {
  transform: rotate(-90deg);
  transform-origin: 60px 60px;
  stroke-dasharray: 208;          /* 2 * pi * r, r = 33 */
  stroke-dashoffset: 208;
  animation: ld-draw 1150ms var(--ease-out) 200ms forwards;
}
/* One 120-unit pattern across four 30-unit subpaths, so the ticks strike in
   sequence — top, bottom, left, right — rather than all at once. A per-arm
   dasharray cannot do this: dash patterns run CONTINUOUSLY across subpaths,
   so `stroke-dasharray: 30` puts arms 2 and 4 permanently in the gap. */
.load__arms {
  stroke-dasharray: 120;
  stroke-dashoffset: 120;
  animation: ld-draw2 820ms var(--ease-out) 420ms forwards;
}
@keyframes ld-draw { to { stroke-dashoffset: 0; } }
@keyframes ld-draw2 { to { stroke-dashoffset: 0; } }
.load__node {
  fill: var(--mark);
  stroke: none;
  transform-origin: 60px 60px;
  transform: scale(0.3);
  opacity: 0;
  animation: ld-node 620ms var(--ease-out) 1080ms forwards;
}
/* The registration is struck, so it overshoots once and settles. */
@keyframes ld-node {
  0%   { transform: scale(0.3); opacity: 0; }
  55%  { transform: scale(1.5); opacity: 1; }
  100% { transform: scale(1); opacity: 1; }
}
[data-intro='out'] .load__mark { animation: ld-out-mark 560ms var(--ease-out) forwards; }
@keyframes ld-out-mark {
  from { transform: translate(-50%, -50%) scale(1); opacity: 1; }
  to   { transform: translate(-50%, -50%) scale(1.12); opacity: 0; }
}

/* Title block, along the bottom edge. Subject and role left, revision and
   the counter right — the same two-column relationship the hero's own slug
   row uses, so the loader is not a separate visual world. */
.load__block {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  /* Bottom padding is DERIVED from the crop inset rather than picked, so the
     readout can never sit on a corner mark. A fixed clamp collided at 375px:
     the marks sit 21-41px off the bottom edge there and the counter's
     baseline landed at 36px. */
  padding: 0 var(--gutter) calc(var(--crop) + 1.9rem);
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  gap: 1rem 2rem;
}
.load__col--r { text-align: right; }
/* overflow: hidden is the mask. The type rises from behind the block's own
   edge on the way in and is pulled up through it on the way out — a sheet
   leaving a press, not a fade. */
.load__mask { overflow: hidden; padding-block: 0.06em; }
.load__mask > span {
  display: block;
  transform: translateY(118%);
  animation: ld-rise 820ms var(--ease-out) forwards;
}
@keyframes ld-rise { to { transform: translateY(0); } }
/* ease-out, not ease-io: the pull has to START moving, because it is
   competing with the paper dissolving underneath it. */
[data-intro='out'] .load__mask > span { animation: ld-out-up 560ms var(--ease-out) forwards; }
@keyframes ld-out-up {
  from { transform: translateY(0); }
  to   { transform: translateY(-118%); }
}
.load__name > span { animation-delay: 380ms; }
.load__col .load__sub > span { animation-delay: 450ms; }
.load__col--r .load__sub > span { animation-delay: 520ms; }
.load__num > span { animation-delay: 590ms; }
[data-intro='out'] .load__mask > span { animation-delay: 0ms; }

.load__name {
  font-family: var(--font-mono);
  font-size: clamp(0.72rem, 1.5vw, 0.88rem);
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
.load__sub {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
  margin-top: 0.5rem;
}
/* The counter is the loader's one large element. Mono because it is a
   readout, tabular because a proportional 1 would make the whole block
   twitch on every frame. */
.load__num {
  font-family: var(--font-mono);
  font-size: clamp(2.4rem, 9vw, 4.6rem);
  font-weight: 400;
  line-height: 1.05;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
  margin-top: 0.3rem;
}
.load__num i { font-style: normal; }
.load__num [data-pad] { color: var(--rule-strong); }

/* Real progress, on the sheet's bottom edge. scaleX from the left, written
   once per frame by the controller — no transition, because the value it is
   given is already the eased one. */
.load__bar { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: var(--rule); }
.load__bar i {
  display: block;
  height: 100%;
  background: var(--mark-2);
  transform: scaleX(0);
  transform-origin: 0 50%;
  will-change: transform;
}
/* !important beats the inline transform the controller writes — a rule with
   !important outranks a non-important inline style, which is the only way to
   take the value back without the controller having to know about the exit. */
[data-intro='out'] .load__bar i {
  transform-origin: 100% 50%;
  transform: scaleX(0) !important;
  transition: transform 520ms var(--ease-io);
}

@media (max-width: 479px) {
  /* Stack rather than squeeze: at 375px the two columns collide, and a
     right-aligned column under a left-aligned one reads as a mistake. */
  .load__block { display: block; }
  .load__col--r { text-align: left; margin-top: 1.1rem; }
}

/* CV bullets: hanging annotation dashes rather than list markers, so the
   experience reads as a document rather than as UI. */
.cv-bul { margin-top: 0.85rem; display: grid; gap: 0.5rem; }
.cv-bul li { position: relative; padding-left: 1.2rem; max-width: var(--measure); }
.cv-bul li::before { content: '—'; position: absolute; left: 0; color: var(--mark); }

/* --- resume ---------------------------------------------------------------
   Download is browser print-to-PDF, not a hosted file. There is no
   resume.pdf in the project and generating one would mean inventing a
   document; this way the page IS the source and the PDF is always current.
   The print sheet below is what makes that output a real CV rather than a
   screenshot of a website.                                                */
.btn-dl {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--mark);
  background: none;
  border: 0;
  border-bottom: 2px solid var(--mark-2);
  padding: 0 0 0.35rem;
  cursor: pointer;
  transition: color var(--dur-ui) var(--ease-out);
}
.btn-dl:hover { color: var(--ink); }
/* Press feedback, and on touch the ONLY feedback: there is no hover state to
   inherit, so without this a tap on Switch or Download produces nothing at
   all until the thing it triggers happens. Zero duration on purpose — the
   180ms colour transition is right for a cursor arriving, and wrong for a
   finger already down, where any ramp reads as lag at the one moment the
   visitor is watching hardest. It ramps back out on release. */
.btn-dl:active { color: var(--ink); transition-duration: 0s; }
.btn-dl:focus-visible { outline: 2px solid var(--mark); outline-offset: 4px; }

@media print {
  /* Chrome and Safari strip backgrounds by default; the deep contact block
     would print as invisible light-on-white without this. */
  .section--deep { background: none !important; color: var(--ink) !important; }
  .section--deep * { color: var(--ink) !important; }
  .nav, .veil, .load, .field, .datum, .btn-dl, .marquee { display: none !important; }
  .section, .section--tight { padding-block: 0.6cm !important; }
  .card { break-inside: avoid; }
  h1, h2, h3 { break-after: avoid; }
  .t-display { font-size: 30pt !important; }
  .t-headline { font-size: 15pt !important; }
  .t-title { font-size: 11pt !important; }
  .email { font-size: 12pt !important; white-space: normal !important; }
  body { font-size: 10pt; }
  /* Reveal states never animate in print — nothing may be left hidden. */
  [data-reveal], [data-seq] > *, [data-rule] {
    opacity: 1 !important; transform: none !important; clip-path: none !important;
  }
  @page { margin: 12mm; }
}

/* Accretion disc in the ruled plate. Sits between the graticule and the
   stamp so the plate still reads as a plate: the rules show through it and
   the caption stays legible on top. */
.disc {
  position: absolute; inset: 0; width: 100%; height: 100%;
  display: block; pointer-events: none; z-index: 1;
  opacity: 0; transition: opacity 900ms var(--ease-out);
}
.disc[data-on] { opacity: 1; }
.plate--empty .plate__stamp { z-index: 2; }
@media print { .disc { display: none !important; } }

/* --- field controls -------------------------------------------------------
   The reference exposes these as a developer HUD. Here they belong to the
   reader, so they are real labelled form controls with keyboard support
   rather than a debug overlay — and they are hidden until the motion layer
   arms them, because without WebGL they would be dead inputs.

   Styled as instrument annotation: mono labels, hairline tracks, the
   annotation red on the thumb. Same vocabulary as the rest of the page. */
.knobs {
  margin-top: clamp(2.5rem, 6svh, 4rem);
  padding-top: clamp(1.8rem, 4svh, 2.8rem);
  border-top: 1px solid var(--rule);
}
.knobs__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr));
  gap: 0.5rem clamp(1rem, 2.5vw, 2rem);
  align-items: end;
  margin-top: 0.9rem;
}
.knobs__hint { color: var(--mark); }
.knob { display: grid; gap: 0.35rem; min-width: 0; }
.knob b { font-weight: 400; color: var(--ink); font-variant-numeric: tabular-nums; }
.knobs__reset { justify-self: start; align-self: end; }

/* --- what survives on a phone ---------------------------------------------
   Switch does; the four sliders and Reset do not.

   Switch is the only control here a touch visitor genuinely needs. The field
   holds each figure for six seconds and takes seven to cross to the next, so
   waiting out the full cycle to see all four is the better part of a minute
   — on desktop that is ambient, on a phone held in one hand it is a dead
   end. One tap advances it.

   The sliders go because a 9px thumb on a 1px track is not a touch target at
   any width, and no amount of enlarging makes a four-slider instrument panel
   the right thing to put under a phone hero. Reset goes with them: with
   nothing left to restore it would be a button that visibly does nothing.
   Both are `display: none`, which removes them from the tab order and the
   accessibility tree as well as the layout — hidden here means absent, not
   merely invisible.

   Gated on the pointer, not the viewport alone, because the question is
   whether this visitor can land a 9px target. A 1024px tablet cannot. */
.knob, .knobs [data-reset] { display: none; }
@media (hover: hover) and (pointer: fine) and (min-width: 701px) {
  .knob { display: grid; }
  .knobs [data-reset] { display: inline-block; }
}

/* Switch is 15px of mono type with a 2px rule under it — correct as a
   printed annotation, far under the 44px a thumb needs. Rather than pad the
   button (which would push that rule away from the word it underlines, and
   the rule IS the affordance), the hit area is grown with a pseudo-element
   that has no visual presence at all. The press is inside a <button>, so the
   hero's drag handler still ignores it — that guard tests closest('button'),
   and ::after belongs to the button. */
@media (pointer: coarse) {
  .knobs__reset { position: relative; }
  .knobs__reset::after {
    content: '';
    position: absolute;
    left: 50%; top: 50%;
    transform: translate(-50%, -50%);
    width: max(100%, 44px);
    height: 44px;
  }
}

.knobs input[type='range'] {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 14px;
  background: none;
  cursor: ew-resize;
}
.knobs input[type='range']::-webkit-slider-runnable-track {
  height: 1px; background: var(--rule-strong);
}
.knobs input[type='range']::-moz-range-track {
  height: 1px; background: var(--rule-strong);
}
.knobs input[type='range']::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 9px; height: 9px; margin-top: -4px;
  background: var(--mark); border: 0; border-radius: 0;
  transition: transform var(--dur-ui) var(--ease-out);
}
.knobs input[type='range']::-moz-range-thumb {
  width: 9px; height: 9px; background: var(--mark);
  border: 0; border-radius: 0;
}
.knobs input[type='range']:hover::-webkit-slider-thumb { transform: scale(1.5); }
.knobs input[type='range']:focus-visible::-webkit-slider-thumb { transform: scale(1.5); }
.knobs input[type='range']:focus-visible { outline: 2px solid var(--mark); outline-offset: 4px; }

@media print { .knobs { display: none !important; } }

/* --- SYSTEM 3: cross-document View Transitions --------------------------
   Falls out of the MPA architecture: no JS, no router, no loader script.
   Content paints first; this animates the swap between two painted pages,
   so it cannot gate readability or delay LCP.                             */
@view-transition { navigation: auto; }
::view-transition-old(root) { animation: vt-out 200ms var(--ease-out) both; }
::view-transition-new(root) { animation: vt-in 320ms var(--ease-out) both; }
@keyframes vt-out { to { opacity: 0; transform: translateY(-10px); } }
@keyframes vt-in { from { opacity: 0; transform: translateY(14px); } }

/* The progress indicator is a drawing rule at the top edge — the site's own
   vocabulary, not a spinner. It rides the outgoing page and never covers
   text. */
.nav { view-transition-name: kk-nav; }
::view-transition-group(kk-nav) { animation-duration: 260ms; }

/* --- reduced motion: extend the existing total kill --------------------- */
@media (prefers-reduced-motion: reduce) {
  .js [data-seq] > * { opacity: 1 !important; transform: none !important; }
  [data-seq] > * { transition: none !important; }
  [data-par] { transform: none !important; will-change: auto !important; }
  /* The bento spotlight needs no override here — its whole media query
     requires prefers-reduced-motion: no-preference, so it never applies. */
  @view-transition { navigation: none; }
  ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
}

/* ---- THE CROSSING: the page cracks open --------------------------------
   motion.js builds a rig on top of the page when Labs is clicked: K Labs'
   ground at the back, two leaves in front of it that are clones of the live
   nav, main and footer clipped to either side of a fracture line, and the
   crack drawn over both. It navigates when the leaves are open.

   Timings live in motion.js and are mirrored here — 260 pause, 540 crack,
   130 hold, 660 open. They have to agree, so they are named in both places.

   The real content is hidden by motion.js the moment the leaves land, not
   faded: the leaves are pixel-identical to it, so a cross-fade between them
   would only ever show as a flicker. */
[data-departing] .cur { opacity: 0; transition: opacity 160ms linear; }

.crk {
  position: fixed;
  inset: 0;
  z-index: 9998;
  pointer-events: none;
}

/* K Labs' ground, at the very back. The frame above it covers this within
   a few hundred milliseconds, so it is really a floor: it is what shows if
   the frame is slow, blocked, or never arrives at all, and it means the gap
   between the leaves is never the wrong colour. Flagged as a pair with the
   lobby's own --paper. */
.crk__back {
  position: absolute;
  inset: 0;
  z-index: 0;
  background-color: #F2ECDB;
}

/* THE ACTUAL K LABS PAGE, live, behind the leaves. This is what the doors
   open onto, and it is the whole reason the arrival no longer needs an
   animation of its own — there is nothing to reveal afterwards because it
   has already been revealed.

   pointer-events none throughout: it is scenery until the real navigation
   lands, and a frame that could be clicked would let someone interact with
   a page that is about to be replaced underneath them. */
.crk__stage {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
  /* EXPLICIT, AND SO IS EVERY SIBLING. Document order is not enough once one
     of the children is an iframe: a same-origin frame gets a compositing
     layer of its own and paints above later siblings that have none, so the
     leaves — correct in the DOM, opaque, full width, holding the cloned page
     — were being covered by the very thing they are supposed to be hiding.
     The doors showed K Labs before the crack had even finished. */
  z-index: 1;
  pointer-events: none;
}

/* Painted --paper underneath the clones so that any part of a leaf the
   cloned page does not cover is still the ground it came from, rather than
   letting K Labs' colour show through the page before it has opened. */
.crk__leaf {
  position: absolute;
  inset: 0;
  overflow: hidden;
  background-color: var(--paper);
  z-index: 2;
  /* Forces a layer of the leaf's own, so it is composited against the frame
     below rather than left for the compositor to order however it likes. */
  transform: translateZ(0);
  backface-visibility: hidden;
  /* Heavy, and in no hurry. Eased at BOTH ends rather than leaving at
     once: doors this size take a moment to get going and a moment to
     settle, and the previous snap-open curve was most of why the whole
     thing felt rushed. */
  transition: transform 1150ms cubic-bezier(0.62, 0, 0.24, 1);
}
/* SLIDING, AND ALL THE WAY OFF. The hinged version rotated 32 degrees and
   shifted 7%, which never cleared the viewport — so the doors were still
   most of the way shut when the document swapped, and what you actually saw
   was a slight tilt followed by the arrival fading in. The opening has to
   finish, and be the thing that does the revealing.

   101% rather than 100% so no sliver of leaf can survive a subpixel
   rounding at the edge. */
.crk[data-open] .crk__leaf--l { transform: translate3d(-101%, 0, 0); }
.crk[data-open] .crk__leaf--r { transform: translate3d(101%, 0, 0); }

.crk__page { position: absolute; left: 0; top: 0; width: 100%; }

/* The crack, revealed top to bottom by uncovering it rather than by
   stroke-dash: the viewBox is stretched to the viewport, so a dash length in
   user units would be wrong by the aspect ratio, and wrong differently at
   every window size. An inset clip has no such problem. */
/* LAST CHILD OF THE RIG, so it paints over both leaves and everything
   cloned inside them. Everything here is z-index auto, so paint order is
   document order — which is why this element is built last rather than
   given a z-index that would have to be kept ahead of the leaves' own
   stacking contexts. */
.crk__line {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 3;
  clip-path: inset(0 0 100% 0);
  transition: opacity 160ms linear;
}
.crk__line polyline {
  fill: none;
  stroke: var(--ink);
  /* Heavier and nearly opaque. The geometry stays where it was asked to
     stay — same spread, same wander — this is weight only. */
  stroke-width: 2.2;
  stroke-linejoin: round;
  stroke-linecap: round;
  opacity: 0.88;
}

/* NOT A TRANSITION — A STEPPED KEYFRAME RUN.
   A single eased transition draws the line at one continuous speed, which
   is a pen moving, not ground failing. Rock runs, jams, builds and runs
   again, so each pair of stops below is a burst followed by a near-hold:
   9% to 16% travels almost nothing, then 16% to 27% covers a fifth of the
   page. Linear between the stops on purpose, so the rhythm is exactly what
   is written here and not something an easing curve does on top of it. */
@keyframes crk-split {
  0%   { clip-path: inset(0 0 100% 0); }
  9%   { clip-path: inset(0 0 88% 0); }
  16%  { clip-path: inset(0 0 86% 0); }
  27%  { clip-path: inset(0 0 66% 0); }
  35%  { clip-path: inset(0 0 64% 0); }
  48%  { clip-path: inset(0 0 43% 0); }
  56%  { clip-path: inset(0 0 41% 0); }
  70%  { clip-path: inset(0 0 21% 0); }
  77%  { clip-path: inset(0 0 19% 0); }
  90%  { clip-path: inset(0 0 4% 0); }
  100% { clip-path: inset(0 0 0 0); }
}
.crk[data-cracked] .crk__line { animation: crk-split 1050ms linear forwards; }

/* The room moves with it. Two or three pixels — this should register as a
   jolt you felt rather than a wobble you watched — and every offset lands
   on one of the bursts above, so the page kicks exactly when the crack
   runs and is still while it jams. Ends on zero, so nothing snaps back
   when the animation is done. */
@keyframes crk-quake {
  0%, 100% { transform: translate3d(0, 0, 0); }
  9%   { transform: translate3d(-2px, 1px, 0); }
  12%  { transform: translate3d(2px, -1px, 0); }
  16%  { transform: translate3d(0, 0, 0); }
  27%  { transform: translate3d(3px, -1px, 0); }
  31%  { transform: translate3d(-2px, 1px, 0); }
  35%  { transform: translate3d(0, 0, 0); }
  48%  { transform: translate3d(-3px, 1px, 0); }
  52%  { transform: translate3d(2px, -1px, 0); }
  56%  { transform: translate3d(0, 0, 0); }
  70%  { transform: translate3d(2px, 1px, 0); }
  74%  { transform: translate3d(-2px, -1px, 0); }
  78%  { transform: translate3d(0, 0, 0); }
  90%  { transform: translate3d(-2px, 0, 0); }
  94%  { transform: translate3d(1px, 0, 0); }
}
.crk[data-cracked] { animation: crk-quake 1050ms linear; }
/* Gone by the time the leaves are apart — a line hanging in the gap between
   two open doors belongs to neither of them. */
.crk[data-open] .crk__line { opacity: 0; }

/* Reduced motion never reaches the handler — motion.js returns before it
   binds — so there is deliberately no reduced-motion branch here. */

/* ---- K LABS PORTAL -----------------------------------------------------
   Pointing at the door throws the WHOLE SITE into K Labs and letting go
   throws it back.

   IT INVERTS. Ground goes to K Labs' ink, type goes to its cream, and the
   contact band inverts the other way so it still reads as a band. The gentle
   version of this — paper to a warmer paper — was correct and forgettable:
   a site that changes by six percent has not changed. The reference is not
   quiet either, and reading its stylesheet is what settled the argument:
   forty-eight keyframe animations, named screenFlicker, scanlineMove,
   ledBlink, errorShake, pixel-glow, marqueeSlide. Its hover does not recolour
   text, it re-renders it 39% LARGER — the base canvas is 284x32 and the hover
   canvas is 396x46. That is the register being asked for.

   THIRTEEN PAIRS MEASURED BEFORE ANY OF IT WAS WRITTEN, and loud is where
   contrast normally dies. Lowest is 4.50:1, on the contact band's links;
   ink on ground is 13.87:1. Nothing here is a guess.

   THE FACE STILL NEVER CHANGES. font-family has no interpolation and lands
   as a snap. font-SIZE does interpolate, which is exactly why the reference
   animates size and not family, and it is the loudest move available that
   still arrives smoothly. */
.labs-world {
  --paper: #241F17;
  --panel: #2E2820;
  --deep: #F2ECDB;
  --deep-raised: #E9E0C9;

  --ink: #F2ECDB;
  --muted: #B3A891;
  --ink-inv: #241F17;
  --muted-inv: #6B6153;

  --mark: #E8705C;
  --mark-2: #F08A78;
  --mark-inv: #B93227;

  --rule: rgba(242, 236, 219, 0.20);
  --rule-strong: rgba(242, 236, 219, 0.38);
  --rule-inv: rgba(36, 31, 23, 0.20);
}

/* 260ms, and a curve with a hard front. The quiet version used 300ms ease,
   which is the reference's speed for a CARD; a whole site turning over wants
   to arrive faster than one of its own components, or it reads as sluggish
   simply for being bigger. cubic-bezier(.2,.8,.2,1) puts most of the
   distance in the first third — the change lands, then settles. */
.labs-swaps body,
.labs-swaps .section,
.labs-swaps .card,
.labs-swaps .rule,
.labs-swaps .nav,
.labs-swaps .footer,
.labs-swaps a,
.labs-swaps p,
.labs-swaps h1, .labs-swaps h2, .labs-swaps h3,
.labs-swaps .t-label,
.labs-swaps .labs__door {
  transition: background-color 260ms cubic-bezier(.2, .8, .2, 1) var(--swap-delay, 0ms),
              color 260ms cubic-bezier(.2, .8, .2, 1) var(--swap-delay, 0ms),
              border-color 200ms ease var(--swap-delay, 0ms),
              box-shadow 260ms cubic-bezier(.2, .8, .2, 1) var(--swap-delay, 0ms);
}

/* The sections MOVE as the wave reaches them. Six pixels, and it is the
   difference between a palette changing and a place changing — colour alone
   is a light switch however fast it is. Rides the same per-section delay, so
   the shift travels out from the door with the colour rather than alongside
   it. */
.labs-swaps .section {
  transition: background-color 260ms cubic-bezier(.2, .8, .2, 1) var(--swap-delay, 0ms),
              color 260ms cubic-bezier(.2, .8, .2, 1) var(--swap-delay, 0ms),
              transform 380ms cubic-bezier(.2, .8, .2, 1) var(--swap-delay, 0ms);
}
.labs-world .section { transform: translate3d(0, -6px, 0); }

/* ---- the door itself --------------------------------------------------- */
.labs__door {
  position: relative;
  display: block;
  padding: clamp(1.6rem, 4vw, 2.6rem);
  text-decoration: none;
  color: var(--ink);
  box-shadow: inset 0 0 0 1px var(--rule-strong);
}
/* The reference's card fades a background layer in from 0 to 1 on hover and
   thickens its outline; both are what stop the card reading as the same box
   in a different colour. Same here — the door fills to the raised panel and
   the frame goes to 3px, so the thing you are pointing at is unmistakably
   the source of what just happened to the page. */
.labs-world .labs__door {
  background-color: var(--panel);
  box-shadow: inset 0 0 0 3px var(--mark);
}
.labs__in { display: block; max-width: 62ch; }

/* THE FLIP. Two faces of a block that turns on its X axis: one rotates away
   as the other rotates in, like a split-flap board.

   NOT ONE PIXEL OF OPACITY IS INVOLVED. That is the whole difference from
   the version this replaces — crossfading two copies is a dissolve however
   it is timed, because at every moment in the middle you are looking at two
   half-transparent things. Here you are always looking at exactly one solid
   face; the other is edge-on or behind. backface-visibility hides the reverse
   of each face so the two never show through one another.

   The perspective is on the container, so both faces turn in the same
   camera. 800px is deliberately shallow — deep perspective on a wordmark
   this wide bows the outer letters and it stops reading as type. */
.labs__flip {
  display: grid;
  perspective: 800px;
  transform-style: preserve-3d;
}
.labs__flip-a,
.labs__flip-b {
  grid-area: 1 / 1;
  align-self: center;
  font-family: 'Newsreader', 'Newsreader Fallback', Georgia, serif;
  font-size: clamp(2.6rem, 7vw, 5rem);
  line-height: .94;
  letter-spacing: -.02em;
  transform-origin: 50% 50%;
  backface-visibility: hidden;
  transition: transform 560ms cubic-bezier(.2, .8, .2, 1),
              color 300ms ease;
}
/* At rest: A faces you, B is stood on its edge above. */
.labs__flip-a { transform: rotateX(0deg); }
.labs__flip-b { transform: rotateX(90deg); color: #F5B23E; }
/* Loud: A turns away downward, B turns down into place. Same direction, so
   the block reads as one object rolling forward rather than two things
   swapping. */
.labs-world .labs__flip-a { transform: rotateX(-90deg); }
.labs-world .labs__flip-b { transform: rotateX(0deg); }

.labs__body { display: block; margin-top: clamp(1.2rem, 3vh, 1.8rem); color: var(--muted); }
.labs__cta {
  display: inline-block;
  margin-top: clamp(1.2rem, 3vh, 1.8rem);
  font-family: 'Martian Mono', 'Martian Fallback', ui-monospace, monospace;
  font-size: .72rem;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--mark);
}
.labs__door:hover .link__arrow { transform: translateX(.25em); }
.link__arrow { transition: transform 200ms ease; }

/* ONE FLICKER ON THE WAY IN, and only on the way in. Two frames of the
   ground dipping, borrowed from the reference's screenFlicker — the thing
   that stops a big change feeling like a CSS transition and makes it feel
   like something switched on. Deliberately not on the way out: a flicker
   when you leave reads as a fault rather than as a arrival. */
@keyframes labs-strike {
  0%   { opacity: 1; }
  22%  { opacity: .82; }
  34%  { opacity: 1; }
  48%  { opacity: .9; }
  100% { opacity: 1; }
}
.labs-world .nav, .labs-world #main, .labs-world .footer { animation: labs-strike 260ms steps(1, end); }

/* Loud is a choice about presentation, not about whether the information
   arrives, so reduced motion still gets the whole palette — instantly, with
   no stagger to read as lag, no movement and no flicker. */
@media (prefers-reduced-motion: reduce) {
  .labs-swaps body, .labs-swaps .section, .labs-swaps .card, .labs-swaps .rule,
  .labs-swaps .nav, .labs-swaps .footer, .labs-swaps a, .labs-swaps p,
  .labs-swaps h1, .labs-swaps h2, .labs-swaps h3, .labs-swaps .t-label,
  .labs-swaps .labs__door, .labs__roll-a, .labs__roll-b, .link__arrow {
    transition-duration: 0s !important;
    transition-delay: 0s !important;
  }
  .labs-world .section { transform: none; }
  .labs-world .nav, .labs-world #main, .labs-world .footer { animation: none; }
}
