@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,300..800;1,14..32,300..800&family=Instrument+Serif:ital@0;1&family=Swanky+and+Moo+Moo&display=swap');

/* ==========================================================================
   1. TOKENS
   ========================================================================== */

:root {
  /* the surface tone under the nav; dark surfaces override it */
  --tone: light;

  /* ink */
  --ink: #14100c;
  --ink-2: #3a352f;
  --ink-dim: #b9b4ad;
  --ink-ghost: #d8d4ce;

  /* paper */
  --paper: #f7f6f3;
  --paper-2: #fffefc;
  --line: #e7e4de;
  --line-soft: #efece6;

  /* sky — overwritten live by the time-of-day slider */
  --sky-1: #0b1533;
  --sky-2: #1c2c5c;
  --sky-3: #43578f;

  /* type */
  /* ONE family across the whole project; hierarchy comes from weight, size and
     tracking, not from switching typeface.

     ONE TYPEFACE, SELF-HOSTED. Geist, in three variable files under
     assets/fonts — see the @font-face block below section 0 for why they are
     variable and why they are local rather than fetched. The tail of each stack
     is still the old system list, because a stack with nothing after the
     webfont is a blank page while the woff2 is in flight.

     There used to be no webfont here at all, and the tokens named Inter, which
     almost nobody has installed. What everyone actually saw was the second
     entry in the list — a different face on every machine. That was the real
     cause of the mismatched look, and it is what this fixes. */
  --sans: 'Geist', 'Geist Fallback', ui-sans-serif, -apple-system,
          BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  /* kept for the numbered code block only, where proportional type would break
     the alignment that makes code readable */
  --mono: 'Geist Mono', 'Geist Mono Fallback', ui-monospace, SFMono-Regular,
          'SF Mono', Menlo, Consolas, monospace;
  /* Every prose measure inside a case study reads from this. It is deliberately
     one variable: the section used to cap headings at 34rem and body at 44rem,
     which left the right side of a 1136px column empty. Set it to a rem value
     (e.g. 46rem) to bring the measure back in. */
  --measure: 100%;

  /* The dotted field. One definition, read by the hero canvas AND the footer, so
     the two surfaces are the same grid rather than two grids that happen to look
     alike — change it once and both follow. Measured off the reference at 24px. */
  --dots: radial-gradient(circle at 1px 1px, rgb(205, 203, 204) 1px, transparent 0);
  --dots-step: 24px 24px;

  /* --- the safe area --------------------------------------------------------
     Read once, here, so nothing downstream has to remember the env() syntax or
     the fallback. THE FALLBACK IS NOT OPTIONAL: `env()` with no second argument
     is invalid in a browser that does not implement it, and an invalid value
     drops the whole declaration — so a padding written without one does not
     degrade to zero, it disappears, taking the padding with it. */
  --sa-top: env(safe-area-inset-top, 0px);
  --sa-right: env(safe-area-inset-right, 0px);
  --sa-bottom: env(safe-area-inset-bottom, 0px);
  --sa-left: env(safe-area-inset-left, 0px);

  /* --- the breakpoints ------------------------------------------------------
     Four tiers, written down so the next person picks from them instead of
     inventing a thirteenth number. These are the query values themselves —
     `@media (max-width: 48rem)` — because a media query cannot read a custom
     property; the tokens exist to be quoted, not interpolated.

       --bp-desktop   90rem   1440px and up
       --bp-laptop    80rem   1280–1439
       --bp-tablet    64rem   1024–1279   (the wide half of the tablet tier)
       --bp-portrait  48rem    768        mobile ceiling / tablet floor
       --bp-phone     30rem    480
       --bp-small     22.5rem  360        the narrowest screen designed for  */
  --bp-desktop: 90rem;
  --bp-laptop: 80rem;
  --bp-tablet: 64rem;
  --bp-portrait: 48rem;
  --bp-phone: 30rem;
  --bp-small: 22.5rem;

  --step--1: clamp(0.75rem, 0.73rem + 0.1vw, 0.8125rem);
  --step-0: clamp(0.875rem, 0.85rem + 0.12vw, 0.9375rem);
  /* Between body and the first heading step. Without it, anything wanting mild
     emphasis — a pull-out callout, say — had to jump from 15px to 29px and read
     as a heading instead of as emphasised prose. */
  --step-05: clamp(1rem, 0.96rem + 0.2vw, 1.0625rem);
  --step-1: clamp(1.4375rem, 1.18rem + 1.25vw, 1.8125rem);
  --step-2: clamp(1.75rem, 1.4rem + 1.6vw, 2.375rem);
  --step-3: clamp(2.25rem, 1.6rem + 3vw, 3.5rem);

  /* --- vertical rhythm ------------------------------------------------------
     One scale, in rem, on a fixed ratio. It deliberately does NOT use vh.
     Every gap in the case-study stack used to be its own `clamp(a, N vh, b)`
     with a different N — 5vh here, 8vh there, 11vh somewhere else — so the gaps
     did not scale together. Between an 800px and a 1020px tall window the small
     gaps sat pinned at their maximum while the large ones kept growing, and the
     ratio between levels wandered from 1:1.9:2.6 to 1:2.2:2.8. That is why the
     spacing read as wrong on some screens and fine on others, and why the same
     fault turned up in every section rather than in one place.

     Vertical rhythm should not depend on how tall the window is. It scales with
     the root font size instead, and the media query at the end of this file
     shrinks the whole scale on narrow screens with the ratios locked.

     The steps are a 1.5 ratio, rounded so each lands on a whole pixel at a 16px
     root. My first attempt at this scale had ratios of 1.67, 1.60, 1.25, 1.40 —
     picked by eye, which is the same undisciplined spacing it was meant to
     replace, just gathered into one place. */
  --sp-1: 0.375rem;   /*   6px  within a line of text          */
  --sp-2: 0.5625rem;  /*   9px  a label to the thing it labels */
  --sp-3: 0.875rem;   /*  14px  a heading to its own body copy */
  --sp-4: 1.3125rem;  /*  21px  blocks inside one thought      */
  --sp-5: 2rem;       /*  32px  between related thoughts       */
  --sp-6: 3rem;       /*  48px  a new sub-topic                */
  --sp-7: 4.5rem;     /*  72px  a new major heading            */
  --sp-8: 6.75rem;    /* 108px  a new section                  */

  /* The drawer's top padding. The dark head's top padding and the pull-up that
     lets the band cover that padding must both equal it exactly, or a pale strip
     reappears above the band. Three literal copies of one number is how that
     drifts, so it lives here. */
  --drawer-top: clamp(1.5rem, 5vh, 2.75rem);

  /* Where a section comes to rest under the drawer's top edge: breathing space
     plus the one line of eyebrow that sits above the heading. Composed from the
     scale rather than picked, so it moves with the rest of the rhythm. */
  --nav-land: calc(var(--sp-5) + 1.4em);

  /* rhythm */
  --col: 38.5rem;
  --gut: clamp(1.25rem, 4vw, 2.5rem);
  --sheet-inset: 0px;
  --sheet-round: 0px;

  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-io: cubic-bezier(0.65, 0, 0.35, 1);
  /* springs — these overshoot slightly and settle, never linear */
  --spring: cubic-bezier(0.34, 1.46, 0.64, 1);
  --spring-soft: cubic-bezier(0.22, 1.18, 0.36, 1);
  --spring-snap: cubic-bezier(0.16, 1.32, 0.3, 1);

  /* avatar-group hover (transitions.dev) */
  --avatar-lift: -4px;
  --avatar-dur: 320ms;
  --avatar-scale: 1.05;
  --avatar-falloff: 0.45;
  --avatar-ease-in: cubic-bezier(0.22, 1, 0.36, 1);
  --avatar-ease-out: cubic-bezier(0.34, 3.85, 0.64, 1);
}

/* ==========================================================================
   2. RESET
   ========================================================================== */

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

/* ==========================================================================
   0b. TYPE
   Geist, self-hosted. No CDN, no `<link>` in six HTML files, nothing to go down
   — the same reason the rest of this site has no build step and no framework.

   VARIABLE, NOT NINE STATIC CUTS. One 68KB file covers 100–900, which is less
   than the three static weights this page uses would have been, and it means a
   weight can be changed in CSS without shipping another download. The italic is
   a real second file rather than a synthesised slant: the headline sets one word
   in italic and a faux oblique on a geometric sans shears the letterforms into
   something Geist does not contain.

   `font-display: swap` — the page is text on paper and text is the point. Better
   to read it in the fallback for 100ms than to look at nothing.
   ========================================================================== */

@font-face {
  font-family: 'Geist';
  src: url('../fonts/Geist-Variable.woff2') format('woff2-variations'),
       url('../fonts/Geist-Variable.woff2') format('woff2');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Geist';
  src: url('../fonts/Geist-Italic-Variable.woff2') format('woff2-variations'),
       url('../fonts/Geist-Italic-Variable.woff2') format('woff2');
  font-weight: 100 900;
  font-style: italic;
  font-display: swap;
}

@font-face {
  font-family: 'Geist Mono';
  src: url('../fonts/GeistMono-Variable.woff2') format('woff2-variations'),
       url('../fonts/GeistMono-Variable.woff2') format('woff2');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

/* THE FALLBACK IS NOT A DIFFERENT FONT, IT IS ARIAL WEARING GEIST'S MEASUREMENTS.
   `swap` means the first paint is in the fallback and the second is in Geist, and
   if the two set text at different widths and heights the whole page jumps at
   the swap — the layout shift every webfont is accused of.

   So the fallback is declared, not inherited. It is local Arial with Geist's
   metrics forced onto it: the overrides below are computed, not guessed —
   size-adjust is the ratio of the two fonts' average character widths
   (Geist 581/1000 against Arial 1091/2048), and the ascent and descent are
   Geist's own hhea values divided back through that ratio so a line box is the
   same height either way. Text set in the fallback occupies very nearly the same
   box as the same text in Geist, and the swap is a change of shape rather than a
   reflow. */

@font-face {
  font-family: 'Geist Fallback';
  src: local('Arial'), local('Helvetica'), local('Liberation Sans');
  size-adjust: 109.11%;
  ascent-override: 92.11%;
  descent-override: 27.04%;
  line-gap-override: 0%;
}

@font-face {
  font-family: 'Geist Mono Fallback';
  src: local('Courier New'), local('Liberation Mono');
  size-adjust: 112.68%;
  ascent-override: 89.19%;
  descent-override: 26.18%;
  line-gap-override: 0%;
}


/* NEITHER OF THESE SCROLLS ANY MORE — `.app` does. See section 38: a rounded,
   scaled window has to be clipped to the viewport, and a viewport-sized scroll
   container clips itself with a border-radius instead of needing its clip
   rewritten in document coordinates on every frame. */
html { -webkit-text-size-adjust: 100%; overflow: hidden; height: 100%; }

body {
  margin: 0;
  font-family: var(--sans);
  font-size: var(--step-0);
  color: var(--ink);
  background: var(--sky-2);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow: hidden;
  height: 100%;
}

img, svg, video { display: block; max-width: 100%; }
button { font: inherit; color: inherit; border: 0; background: none; cursor: pointer; }
a { color: inherit; text-decoration: none; }
ul { margin: 0; padding: 0; list-style: none; }
/* Figures default to `margin: 1em 40px` in every UA, which quietly inset the
   ticker 40px on both sides. .blk-frame had a local workaround for the same
   thing; the reset is where it belongs. */
figure, figcaption { margin: 0; }
h1, h2, h3, p { margin: 0; font-weight: inherit; }

::selection { background: #14100c; color: #f7f6f3; }

:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
  border-radius: 4px;
}

.sr {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ==========================================================================
   3. THE SHEET  (the paper the site is printed on; sky sits behind it)
   ========================================================================== */

.sky-root {
  position: fixed;
  inset: 0;
  z-index: 0;
  background:
    radial-gradient(120% 80% at 50% 100%, var(--sky-3) 0%, transparent 60%),
    linear-gradient(to bottom, var(--sky-1) 0%, var(--sky-2) 55%, var(--sky-3) 100%);
  transition: background 900ms linear;
}

.sky-root .stars {
  position: absolute;
  /* oversized so the slow drift never exposes an empty edge */
  top: -4%; left: -6%;
  width: 112%; height: 108%;
  opacity: var(--star-opacity, 0);
  transition: opacity 1400ms linear;
  animation: starDrift 240s linear infinite alternate;
  will-change: transform;
}

/* barely perceptible — about 40px of travel over four minutes */
@keyframes starDrift {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(2.4%, -1.4%, 0); }
}

/* a slow, rare shooting star — one every ~40s, only at night */
.sky-root .shoot {
  position: absolute;
  top: 12%; left: 8%;
  width: 88px; height: 1.4px;
  border-radius: 2px;
  background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.85));
  opacity: 0;
  filter: blur(0.3px);
}
.sky-root .shoot.go {
  animation: shoot 1500ms cubic-bezier(0.3, 0.1, 0.5, 1) forwards;
}
@keyframes shoot {
  0%   { opacity: 0;   transform: translate3d(0, 0, 0) rotate(18deg) scaleX(0.2); }
  12%  { opacity: 0.9; }
  70%  { opacity: 0.7; }
  100% { opacity: 0;   transform: translate3d(46vw, 30vh, 0) rotate(18deg) scaleX(1); }
}

/* Daytime clouds. Soft blurred blobs drifting at different speeds so the
   footer feels like weather rather than a gradient. */
.sky-root .clouds {
  position: absolute;
  inset: 0;
  overflow: hidden;
  opacity: var(--cloud-opacity, 0);
  transition: opacity 1400ms linear;
}

.sky-root .clouds i {
  position: absolute;
  display: block;
  left: 0;
  width: var(--w, 30rem);
  height: var(--h, 9rem);
  top: var(--t, 40%);
  will-change: transform;
  animation: drift var(--dur, 120s) linear var(--delay, 0s) infinite;
}

/* the lobed silhouette — a flat base with stacked billows on top */
.sky-root .clouds i span {
  display: block;
  width: 100%;
  height: 100%;
  background-repeat: no-repeat;
  background-position: center;
  background-size: 100% 100%;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 240 104'%3E%3Cg fill='%23ffffff'%3E%3Crect x='24' y='60' width='188' height='30' rx='15'/%3E%3Ccircle cx='70' cy='54' r='30'/%3E%3Ccircle cx='110' cy='42' r='36'/%3E%3Ccircle cx='152' cy='54' r='26'/%3E%3Ccircle cx='188' cy='64' r='20'/%3E%3Ccircle cx='44' cy='66' r='21'/%3E%3C/g%3E%3C/svg%3E");
  /* Blur derives from the cloud's own width. Measured by eye against renders:
     ~2.6% keeps the lobed silhouette while still looking soft — 1.6% reads as
     a sticker, 4% turns it to mush. */
  filter: blur(calc(var(--w, 30rem) * 0.026));
  opacity: var(--o, 0.85);
  /* a slow vertical breath, so they don't slide in a dead straight line */
  animation: bob var(--bob, 26s) ease-in-out var(--delay, 0s) infinite alternate;
}

@keyframes drift {
  from { transform: translate3d(-45vw, 0, 0); }
  to   { transform: translate3d(135vw, 0, 0); }
}

@keyframes bob {
  from { transform: translate3d(0, calc(var(--bob-y, 8px) * -1), 0) scale(1); }
  to   { transform: translate3d(0, var(--bob-y, 8px), 0) scale(1.05); }
}

@media (prefers-reduced-motion: reduce) {
  .sky-root .clouds i { animation: none; transform: translate3d(30vw, 0, 0); }
  .sky-root .clouds i span { animation: none; }
  .sky-root .stars { animation: none; }
  .sky-root .shoot { display: none; }
}

/* same reason as .drawer__scroll: a 128vw strip must not create page scroll */
.proj, .sheet { overflow-x: clip; }

.sheet {
  position: relative;
  z-index: 1;
  background: var(--paper);
  border-radius: 0 0 var(--sheet-round) var(--sheet-round);
  margin: 0 var(--sheet-inset);
  overflow: clip;
  transition: none;
  /* the paper lifts off the sky as it detaches — measured 32px inset, 24px radius */
  box-shadow:
    0 0 0 1px rgba(20, 16, 12, 0.04),
    0 calc(var(--sheet-lift, 0) * 18px) calc(var(--sheet-lift, 0) * 46px)
      calc(var(--sheet-lift, 0) * -18px) rgba(20, 16, 12, calc(var(--sheet-lift, 0) * 0.28));
}

.col {
  width: min(var(--col), calc(100% - var(--gut) * 2));
  margin-inline: auto;
}

.col--wide {
  width: min(58rem, calc(100% - var(--gut) * 2));
  margin-inline: auto;
}

/* ==========================================================================
   4. NAV
   ========================================================================== */

/* The nav sits over whatever scrolls beneath it, and some of the work previews
   are near-black or saturated. The first attempt was a soft paper scrim across
   the top — it failed for a reason worth recording: a light veil wide enough to
   back the links also veils the card underneath it, so a black preview got a
   grey band across its full width and lost its own content. No amount of
   softening fixes that, because the veil *is* the problem.

   So the nav adapts instead of the background. Dark surfaces declare
   `--tone: dark`; custom properties inherit, so anything nested inside one
   reports dark too, and JS only has to sample a single element under the nav
   (see Nav.tick). Nothing is covered, and the links stay legible on both. */


.nav {
  position: fixed;
  /* The inset is ADDED to the measured offset rather than replacing it, so on
     any screen without a notch this resolves to exactly the value it had. */
  top: calc(clamp(1.25rem, 3vh, 2rem) + var(--sa-top));
  /* The paper insets 32px at the footer, so anything closer to the edge than
     that ends up hanging over the sky once the page detaches. Deliberately a
     flat value, not a vw one: at ~900px wide, 3vw is only 27px and the nav
     would overhang again. The reference measures 40px, which leaves only 8px
     of paper to its left and still looks pinned to the edge — so this sits a
     little further in at 56px, for 24px of clearance. */
  left: 3.5rem;
  z-index: 40;
  /* was serif italic; the sans needs a touch more weight to hold the same
     presence at this size */
  font-family: var(--sans);
  font-weight: 500;
  font-size: 1rem;
  letter-spacing: -0.006em;
  line-height: 1.55;
}

.nav a {
  display: block;
  color: var(--ink-dim);
  transition: color 260ms var(--ease), transform 520ms var(--spring);
  transform-origin: left center;
  width: max-content;
}

.nav a:hover { color: var(--ink-2); transform: translateX(3px); }
.nav a[aria-current='page'] { color: var(--ink); }

.nav a[aria-current='page']::after {
  content: '';
  display: block;
  height: 1px;
  background: var(--ink);
  transform-origin: left;
  animation: rule 600ms var(--ease) both;
}

@keyframes rule { from { transform: scaleX(0); } to { transform: scaleX(1); } }

/* On the hero the introduction owns the top-left, so the nav moves to the
   top-right — it was overlapping the headline. */
body[data-page='home'] .nav {
  left: auto;
  right: 3.5rem;                   /* was 2.5rem — matches the left inset */
  display: flex;
  gap: 1.1rem;
  line-height: 1;
}
body[data-page='home'] .nav a[aria-current='page']::after { display: none; }

/* Over a dark surface the links flip to light ink. Only the colour changes, so
   the existing 260ms colour transition carries it — nothing snaps as a card
   edge passes under the nav. (This is also why .nav no longer uses
   mix-blend-mode: multiply: white multiplied by near-black is near-black, which
   would erase the text outright.) */
.nav[data-ink='light'] a { color: rgba(255, 255, 255, 0.66); }
.nav[data-ink='light'] a:hover { color: #fff; }
.nav[data-ink='light'] a[aria-current='page'] { color: #fff; }
.nav[data-ink='light'] a[aria-current='page']::after { background: #fff; }

/* A whisper of shade under the glyphs, so a mid-tone or busy photo can never
   land the text at zero contrast. Invisible on flat colour. */
.nav[data-ink='light'] a { text-shadow: 0 1px 12px rgba(0, 0, 0, 0.55); }
.nav[data-ink='dark'] a { text-shadow: 0 1px 12px rgba(255, 255, 255, 0.6); }

.nav-toggle { display: none; }

/* ==========================================================================
   5. HERO SHELL
   The home hero is now the Figma canvas (section 26). This just keeps the CTA
   row and the scroll hint that sit on top of it.
   ========================================================================== */

/* --- THE CTA ROW -------------------------------------------------------------
   Three steps down from the headline and one step below the tags, which is the
   hierarchy: the sentence, then the facts, then the thing to do. The gap
   between the two buttons is wider than the gap between two tags on purpose —
   these are decisions, and decisions get room. */
.canvas__cta {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.625rem;
  flex-wrap: wrap;
  margin-top: clamp(1.35rem, 2.4vh, 1.9rem);
  pointer-events: auto;
}

/* THE DOMINANT ONE. Bigger than a button in a paragraph because it is the only
   thing on this page asking to be pressed — same black pill, same spring, same
   ripple, just sized to the composition it now sits in. */
.canvas__cta .btn {
  padding: 0.6rem 1.15rem;
  font-size: var(--step-0);
}

/* AND THE SECONDARY ONE IS STILL LEGIBLE. `--btn-fg: var(--ink-dim)` is right
   for a ghost button in a dense row where the pair reads as one control; out
   here on open paper, over a dot grid, with bricks passing behind it, it was a
   label you had to look for. Same ghost treatment, one step of ink darker. */
.canvas__cta .btn--ghost {
  --btn-fg: var(--ink-2);
  --btn-bg: rgba(20, 16, 12, 0.028);
}
.canvas__cta .btn--ghost:hover { --btn-fg: var(--ink); }

/* --- THE MARGIN NOTE ---------------------------------------------------------
   Absolutely positioned out of flow, deliberately: `Bricks.zone()` measures
   `.canvas__intro`'s `offsetHeight` to work out which part of the canvas is
   spoken for, and a decoration that is only on screen while the pointer is on
   the button must not claim seventy pixels of the room for the whole session.

   It is never the thing under the cursor. See the note in `Pages.home()`. */
.nb {
  position: absolute;
  top: calc(100% + 2px);
  left: 0.4rem;
  width: 206px;
  height: 78px;
  color: #7c3aed;
  pointer-events: none;
  opacity: 0;
  transform: translateY(-4px);
  transition: opacity 220ms var(--ease), transform 420ms var(--spring-soft, var(--ease));
}
.nb svg { display: block; width: 100%; height: 100%; overflow: visible; }

/* THE HAND. 260 is comfortably past the longest path here, so one number
   covers every stroke and no glyph is left half-drawn. The arrow goes first
   and the words trail it, which is the order somebody actually writes in. */
.nb path {
  stroke-dasharray: 260;
  stroke-dashoffset: 260;
}
.canvas__cta:hover .nb,
.canvas__cta:focus-within .nb { opacity: 1; transform: none; }

.canvas__cta:hover .nb path,
.canvas__cta:focus-within .nb path {
  animation: nbDraw 520ms var(--ease-io, ease-in-out) forwards;
}
.canvas__cta:hover .nb .nb__w,
.canvas__cta:focus-within .nb .nb__w { animation-delay: 190ms; animation-duration: 420ms; }

@keyframes nbDraw { to { stroke-dashoffset: 0; } }

/* the second arrow path is the head, and it is short — it should snap on at
   the end of the shaft rather than crawl */
.nb__a + .nb__a { animation-delay: 260ms; animation-duration: 200ms; }

.hero__hint {
  position: absolute;
  z-index: 2;
  left: 50%; bottom: 2.25rem;
  transform: translateX(-50%);
  font-size: var(--step--1);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-ghost);
  animation: bob 2.6s ease-in-out infinite;
  pointer-events: none;
}

@keyframes bob {
  0%, 100% { opacity: 0.4; transform: translate(-50%, 0); }
  50%      { opacity: 1;   transform: translate(-50%, 4px); }
}

/* ==========================================================================
   6. BUTTONS
   ========================================================================== */

.btn {
  --btn-bg: var(--ink);
  --btn-fg: #fbfaf8;
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.5rem 0.95rem;
  border-radius: 999px;
  background: var(--btn-bg);
  color: var(--btn-fg);
  font-size: var(--step--1);
  font-weight: 500;
  letter-spacing: 0.005em;
  overflow: hidden;
  transition: transform 520ms var(--spring), box-shadow 400ms var(--ease), background 240ms ease;
  box-shadow: 0 1px 2px rgba(20, 16, 12, 0.14), 0 6px 16px -8px rgba(20, 16, 12, 0.3);
}

.btn:hover { transform: translateY(-2px); box-shadow: 0 2px 4px rgba(20, 16, 12, 0.16), 0 12px 24px -10px rgba(20, 16, 12, 0.38); }

.btn--ghost {
  --btn-bg: rgba(20, 16, 12, 0.035);
  --btn-fg: var(--ink-dim);
  box-shadow: inset 0 0 0 1px var(--line);
}
.btn--ghost:hover { --btn-bg: rgba(20, 16, 12, 0.06); --btn-fg: var(--ink-2); box-shadow: inset 0 0 0 1px #ddd9d2; }

.btn__label { position: relative; display: block; }

/* label swap on copy */
.btn.is-done .btn__label { animation: swap 420ms var(--ease); }
@keyframes swap {
  0%   { transform: translateY(0); opacity: 1; }
  40%  { transform: translateY(-120%); opacity: 0; }
  41%  { transform: translateY(120%); opacity: 0; }
  100% { transform: translateY(0); opacity: 1; }
}

/* ripple */
.btn__ink {
  position: absolute;
  width: 12px; height: 12px;
  border-radius: 50%;
  background: currentColor;
  opacity: 0.22;
  transform: translate(-50%, -50%) scale(0);
  animation: ripple 620ms var(--ease) forwards;
  pointer-events: none;
}
@keyframes ripple { to { transform: translate(-50%, -50%) scale(14); opacity: 0; } }

/* ==========================================================================
   7. SCROLL-REVEAL PROSE
   ========================================================================== */

.story { padding: clamp(3rem, 12vh, 8rem) 0; }

/* Measured: the prose column's centre sits ~38px left of the viewport centre —
   consistent at 858px and 1470px wide. Reserving 76px on the right reproduces
   that offset exactly. (In the reference this space holds a progress rail.) */
@media (min-width: 48rem) {
  .story,
  .closing,
  .index { padding-right: 4.75rem; }
}

.block {
  font-size: var(--step-1);
  line-height: 1.35;
  letter-spacing: -0.019em;
  margin-bottom: clamp(2.75rem, 9.6vh, 5.1rem);   /* measured ~80px */
  text-wrap: pretty;
  color: var(--ink-ghost);
}

.block:last-child { margin-bottom: 0; }

/* one authored line per span — deliberate breaks, not browser wrapping */
.line { display: block; }

/* below the column width, let the authored lines reflow rather than overflow */
@media (max-width: 44rem) {
  .line { display: inline; }
  .line::after { content: ' '; }
}

/* words stay atomic so the per-character spans can't break mid-word */
.w { white-space: nowrap; }

/* One span per character. The boundary is not a hard edge — four characters
   ahead of it carry intermediate ink, which is what makes "co|rners" read as a
   gradient rather than a cut. */
.c {
  color: var(--ink-ghost);
  transition: color 240ms cubic-bezier(0.4, 0, 0.2, 1);
}

.c.b1 { color: #cdc8c1; }
.c.b2 { color: #b0aaa2; }
.c.b3 { color: #8d8780; }
.c.b4 { color: #625d56; }
.c.is-lit { color: var(--ink-2); }

/* words that carry emphasis sit at full ink the moment they're reached */
.block b .c.is-lit,
.block strong .c.is-lit { color: var(--ink); }
.block b, .block strong { font-weight: 500; }

.block b, .block strong { font-weight: 600; }

/* "corners." — bold italic with a faint emboss, so it sits slightly proud of
   the paper rather than just leaning over */
.block .it {
  font-style: italic;
  font-weight: 600;
  letter-spacing: -0.005em;
  text-shadow:
    0.5px 0.5px 0 rgba(255, 255, 255, 0.95),
   -0.5px -0.5px 0 rgba(20, 16, 12, 0.16);
}

/* italic hairline box */
.block em {
  font-style: italic;
  padding: 0.02em 0.3em;
  margin-inline: 0.05em;
  border: 1px solid var(--ink-ghost);
  border-radius: 3px;
  transition: border-color 500ms var(--ease), background 500ms var(--ease);
}
.block em:has(.is-lit) { border-color: #a9a49c; background: rgba(20, 16, 12, 0.02); }

/* soft underline */
.block u {
  text-decoration: none;
  background-image: linear-gradient(to right, var(--ink-ghost), var(--ink-ghost));
  background-repeat: no-repeat;
  background-position: 0 92%;
  background-size: 0% 1.5px;
  transition: background-size 700ms var(--ease), background-image 500ms ease;
  padding-bottom: 0.06em;
}
.block u:has(.is-lit) { background-size: 100% 1.5px; background-image: linear-gradient(to right, #9d978e, #9d978e); }

/* mark, base: no browser yellow, effects layer on via .band / .rule */
.block mark { background: transparent; color: inherit; }

/* "blur" — actually blurred. Hovering sharpens it, which is the only way to
   read the word, and is the whole point of the gag. */
.block mark.blurred {
  filter: blur(0px);
  transition: filter 520ms var(--ease);
  cursor: help;
}
.block mark.blurred:has(.is-lit) { filter: blur(2.6px); }
:root:not(.hover-cold) .block mark.blurred:hover { filter: blur(0px); }

/* "consequence" — a hand-drawn highlighter swash: slightly skewed, rough ends,
   sitting over the text rather than behind a tidy rectangle */
.block mark.band {
  position: relative;
  padding: 0.04em 0.2em;
  margin-inline: -0.2em;
}
.block mark.band::before {
  content: '';
  position: absolute;
  inset: -0.1em -0.24em -0.16em -0.2em;
  z-index: -1;
  background: rgba(20, 16, 12, 0.19);
  border-radius: 3px 10px 4px 8px;
  transform: skewX(-9deg) scaleX(0);
  transform-origin: left center;
  transition: transform 620ms var(--ease);
}
.block mark.band:has(.is-lit)::before { transform: skewX(-9deg) scaleX(1); }

/* underline that draws itself in — "people", "email", "X" */
.block mark.rule {
  background-image: linear-gradient(to right, rgba(20, 16, 12, 0.72), rgba(20, 16, 12, 0.72));
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 0% 1.5px;
  padding-bottom: 0.08em;
  transition: background-size 700ms var(--ease);
}
.block mark.rule:has(.is-lit) { background-size: 100% 1.5px; }

/* the ".." that trails "consequence" */
.block .dots { font-style: normal; letter-spacing: 0.04em; }

/* "desirable" — serif italic sitting inside what looks like a design-tool
   selection: grey fill, hairline border, and four little corner handles */
.block .tag {
  position: relative;
  font-family: var(--sans);
  font-weight: 500;
  font-size: 1em;
  letter-spacing: -0.008em;
  padding: 0.04em 0.34em;
  margin-inline: 0.1em;
  background: rgba(20, 16, 12, 0.055);
  outline: 1px solid transparent;
  transition: outline-color 480ms var(--ease), background 480ms var(--ease);
}
.block .tag:has(.is-lit) { outline-color: rgba(20, 16, 12, 0.55); }

/* All four handles drawn by one pseudo-element: the same little square
   painted at each corner. Cheaper and cleaner than four separate nodes. */
.block .tag::after {
  --handle: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='6'%3E%3Crect x='.6' y='.6' width='4.8' height='4.8' fill='%23f7f6f3' stroke='%2314100c' stroke-width='1.1'/%3E%3C/svg%3E");
  content: '';
  position: absolute;
  inset: -3px;
  background-image: var(--handle), var(--handle), var(--handle), var(--handle);
  background-size: 6px 6px;
  background-repeat: no-repeat;
  background-position: left top, right top, left bottom, right bottom;
  opacity: 0;
  transform: scale(0.8);
  transition: opacity 380ms var(--ease) 140ms, transform 520ms var(--spring) 140ms;
  pointer-events: none;
}
.block .tag:has(.is-lit)::after { opacity: 1; transform: none; }

/* the little hand-drawn scribble that sits before "complexity" */
.block .scribble {
  display: inline-block;
  width: 1.15em;
  height: 0.82em;
  margin-right: 0.2em;
  vertical-align: -0.02em;
  background-repeat: no-repeat;
  background-position: center;
  background-size: contain;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 44 30'%3E%3Cg fill='none' stroke='%2314100c' stroke-width='1.2' stroke-linecap='round'%3E%3Cpath d='M6 20c2-9 9-14 15-11 5 3 4 11-2 13-7 2-12-3-10-9 2-7 12-10 18-5 6 4 6 14-1 17-8 4-17-2-16-11 1-8 11-13 19-9'/%3E%3Cpath d='M14 9c5-4 13-3 17 2M10 24c6 4 15 3 20-2M20 6c-4 6-4 14 1 19M28 8c3 6 3 13-1 18'/%3E%3C/g%3E%3C/svg%3E");
  opacity: 0;
  transform: translateY(2px) rotate(-4deg);
  transition: opacity 500ms ease, transform 620ms var(--ease);
}
.block .scribble.is-lit { opacity: 0.85; transform: none; }

.block a {
  background-image: linear-gradient(to right, currentColor, currentColor);
  background-repeat: no-repeat;
  background-position: 0 94%;
  background-size: 100% 1px;
  transition: background-size 320ms var(--ease);
}
.block a:hover { background-size: 0% 1px; }

/* inline chip */
.chip {
  display: inline-grid;
  place-items: center;
  width: 1.15em; height: 1.15em;
  border-radius: 50%;
  background: var(--chip, #cfcac2);
  color: #fff;
  font-style: normal;
  font-size: 0.62em;
  font-weight: 600;
  vertical-align: 0.06em;
  margin-inline: 0.12em;
  transform: scale(0.6) rotate(-18deg);
  opacity: 0;
  transition: transform 620ms var(--ease), opacity 480ms ease;
  box-shadow: 0 1px 3px rgba(20, 16, 12, 0.22);
}
.chip.is-lit { transform: scale(1) rotate(0deg); opacity: 1; }

.dim { color: var(--ink-dim); }

/* ==========================================================================
   8. WORK SHOWCASE
   A two-column grid of cards, each a live preview panel over a title/meta row.
   Measured off the reference: ~703px cards, 15px gutter, 1.45 media ratio,
   24px page margins. Cards scale up as they enter — JS writes --in (0 -> 1).
   ========================================================================== */

.showcase { padding: clamp(2rem, 8vh, 5rem) 0 clamp(2rem, 6vh, 4rem); }

.showcase__grid {
  width: calc(100% - 3rem);
  margin-inline: auto;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 2.5rem 0.9375rem;          /* 15px gutter, measured */
}

@media (max-width: 52rem) {
  .showcase__grid { grid-template-columns: 1fr; gap: 1.75rem; }
}

/* --- the card ------------------------------------------------------------ */

.wcard {
  display: block;
  /* --in is the scroll-driven entrance: small and low, then full size */
  opacity: calc(0.15 + var(--in, 1) * 0.85);
  transform:
    translate3d(0, calc((1 - var(--in, 1)) * 26px), 0)
    scale(calc(0.9 + var(--in, 1) * 0.1));
  transform-origin: 50% 70%;
  will-change: transform, opacity;
}

.wcard__media {
  position: relative;
  aspect-ratio: 1.45;
  border-radius: 4px;
  overflow: hidden;
  background: var(--pv-bg, #eceae6);
  transition:
    transform 720ms var(--spring-soft),
    box-shadow 520ms var(--ease);
  box-shadow: 0 0 0 1px rgba(20, 16, 12, 0.05);
}

:root:not(.hover-cold) .wcard:hover .wcard__media {
  transform: translateY(-3px) scale(1.006);
  box-shadow:
    0 0 0 1px rgba(20, 16, 12, 0.07),
    0 22px 46px -22px rgba(20, 16, 12, 0.3);
}

.wcard__meta {
  display: flex;
  align-items: baseline;
  gap: 1rem;
  padding: 0.7rem 0.1rem 0;
  font-size: var(--step-0);
}

.wcard__title {
  font-weight: 500;
  color: var(--ink);
  transition: transform 480ms var(--spring);
}
:root:not(.hover-cold) .wcard:hover .wcard__title { transform: translateX(3px); }

.wcard__tags {
  margin-left: auto;
  color: var(--ink-dim);
  text-align: right;
  transition: color 320ms ease;
}
:root:not(.hover-cold) .wcard:hover .wcard__tags { color: var(--ink-2); }

/* --- drop-in thumbnails ---------------------------------------------------
   A file at assets/img/thumbs/<slug>.<ext> paints over the CSS preview. It
   sits ABOVE the preview rather than replacing it, which is what makes a
   missing or broken file harmless — the panel underneath is still the real
   one. It fades in on load so a slow image never pops.

   Note the absence of pointer-events: none anywhere below. The nav samples
   `--tone` with elementsFromPoint, which skips non-hit-testable nodes, so a
   dark thumbnail has to stay in the hit stack to be seen. The card is an <a>,
   so clicks pass to the link regardless. */
.wcard__thumb { display: block; }

/* cover: the artwork fills the panel, cropped to it */
.wcard__thumb--cover {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transform: scale(1.02);
  transition:
    opacity 620ms var(--ease),
    transform 720ms var(--spring-soft);
  z-index: 2;
}
.wcard__thumb--cover.is-on { opacity: 1; transform: none; }
:root:not(.hover-cold) .wcard:hover .wcard__thumb--cover.is-on { transform: scale(1.035); }

/* sheet: the artwork as a poster on a field, pushing forward on hover.

   This is the `search` preview's move (see 1 below) done with a real image
   instead of a drawn sheet, so the widths, the scale and the 900ms spring are
   deliberately the same numbers — the two cards are meant to read as one
   gesture, and a card that springs at a different rate from its neighbour
   looks like a bug rather than a variation.

   --field is written by JS from the artwork's own edges. The value here is
   only what shows if that read ever fails. */
.wcard__poster {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  overflow: hidden;
  background: var(--field, #101820);
  opacity: 0;
  transition: opacity 620ms var(--ease);
  z-index: 2;
}
.wcard__poster.is-on { opacity: 1; }

.wcard__thumb--sheet {
  position: relative;
  width: 62%;
  height: auto;
  border-radius: 2px;
  box-shadow: 0 10px 30px -14px rgba(0, 0, 20, 0.5);
  transition:
    width 900ms var(--spring-soft),
    transform 900ms var(--spring-soft);
}
:root:not(.hover-cold) .wcard:hover .wcard__thumb--sheet { width: 84%; transform: scale(1.06); }

/* every preview fills its panel */
.pv { position: absolute; inset: 0; display: grid; place-items: center; overflow: hidden; }

/* THE PREVIEW GETS OUT OF THE WAY ONCE ARTWORK IS UP.

   Artwork is painted OVER its CSS preview so that a missing or broken file
   leaves the card exactly as it was. The cost is that the reveal is a
   crossfade: for its whole duration the artwork is semi-transparent and the
   preview shows THROUGH it. On the X0 card that meant 620ms of the search
   panel's saturated blue bleeding through a near-black video — read as a blue
   flash on every load, which is what it was.

   So the preview is cut the moment the layer above it is confirmed. Not faded
   in sympathy, which would only swap one blend for another — cut, because by
   then something opaque is already on top of it. `is-covered` is set by JS at
   the same time as `is-on`, and only ever after a file has actually arrived,
   so the fallback path is untouched: a thumbnail that fails is removed before
   this class is ever added. */
.wcard__media.is-covered .pv { opacity: 0; }

/* The reveal itself, skipped. A cached poster — the common case, since
   index.html preloads it — is present before the card has painted once, and
   fading in from nothing is a transition the eye reads as a flicker. This
   class is added for one frame around the class change and then dropped, so
   the transition is only suppressed for that first paint. */
.wcard__media.is-instant .pv,
.wcard__thumb.is-instant,
.wcard__poster.is-instant { transition: none !important; }

/* --- 1. search: an inner sheet that pushes toward you on hover ----------- */

.pv-search { background: #2439f5; --tone: dark; }

.pv-search__sheet {
  width: 62%;
  aspect-ratio: 1.62;
  border-radius: 2px;
  background: rgba(255, 255, 255, 0.97);
  display: grid;
  place-items: center;
  box-shadow: 0 10px 30px -14px rgba(0, 0, 20, 0.5);
  transition: transform 900ms var(--spring-soft), width 900ms var(--spring-soft);
}
.pv-search__sheet::before {
  content: '';
  position: absolute;
  inset: 12% 8%;
  background:
    repeating-radial-gradient(circle at 22% 78%, rgba(36,57,245,.16) 0 1px, transparent 1px 9px),
    repeating-radial-gradient(circle at 82% 66%, rgba(36,57,245,.13) 0 1px, transparent 1px 11px);
  pointer-events: none;
}
.pv-search__label {
  position: relative;
  text-align: center;
  color: #2439f5;
  font-size: 0.72rem;
  line-height: 1.3;
  letter-spacing: -0.01em;
  transition: font-size 900ms var(--spring-soft), color 600ms ease;
}
:root:not(.hover-cold) .wcard:hover .pv-search__sheet { width: 84%; transform: scale(1.06); }
:root:not(.hover-cold) .wcard:hover .pv-search__label { font-size: 1.28rem; }

/* --- 2. words: the project name drifting across a tinted field ----------- */

.pv-words { background: #f1ecfb; }

.pv-words i {
  position: absolute;
  left: var(--x); top: var(--y);
  font-style: normal;
  font-size: 0.8rem;
  font-weight: 500;
  color: #7c4bd8;
  opacity: 0.7;
  white-space: nowrap;
  transform: rotate(var(--r));
  animation: wordFloat var(--d) ease-in-out var(--o) infinite alternate;
}

@keyframes wordFloat {
  from { transform: rotate(var(--r)) translate3d(0, 0, 0); }
  to   { transform: rotate(var(--r)) translate3d(var(--dx), var(--dy), 0); }
}

:root:not(.hover-cold) .wcard:hover .pv-words i { animation-duration: calc(var(--d) * 0.42); }

/* --- 3. fan: overlapping posters that spread on hover -------------------- */

.pv-fan { background: #e8e6e2; }

.pv-fan__deck { position: relative; width: 82%; height: 82%; }

.pv-fan__card {
  position: absolute;
  top: 50%; left: 50%;
  width: 26%;
  aspect-ratio: 0.62;
  border-radius: 3px;
  background: var(--c);
  box-shadow: 0 6px 18px -8px rgba(20, 16, 12, 0.45);
  transform:
    translate(-50%, -50%)
    translateX(calc(var(--i) * 26%))
    rotate(calc(var(--i) * 3.6deg));
  transition: transform 820ms var(--spring-soft);
}

:root:not(.hover-cold) .wcard:hover .pv-fan__card {
  transform:
    translate(-50%, -50%)
    translateX(calc(var(--i) * 62%))
    translateY(calc(var(--i) * var(--i) * 2.4%))
    rotate(calc(var(--i) * 1.4deg));
}

/* --- 4. ring: a progress dial over a dark panel -------------------------- */

.pv-ring { background: #111110; color: #fff; --tone: dark; }

.pv-ring__pod {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  padding: 0.7rem 1.1rem 0.7rem 0.8rem;
  border-radius: 10px;
  background: #1e1e1c;
  box-shadow: 0 12px 30px -14px rgba(0, 0, 0, 0.8);
  transform: translateY(18%);
  transition: transform 780ms var(--spring-soft);
}
:root:not(.hover-cold) .wcard:hover .pv-ring__pod { transform: translateY(0); }

.pv-ring__dial { width: 2.1rem; height: 2.1rem; flex: none; }
.pv-ring__dial circle { fill: none; stroke-width: 3.4; }
.pv-ring__dial .track { stroke: rgba(255, 255, 255, 0.14); }
.pv-ring__dial .fill {
  stroke: #22d67d;
  stroke-linecap: round;
  stroke-dasharray: 100;
  stroke-dashoffset: 74;
  transform: rotate(-90deg);
  transform-origin: 50% 50%;
  transition: stroke-dashoffset 1400ms var(--ease);
}
:root:not(.hover-cold) .wcard:hover .pv-ring__dial .fill { stroke-dashoffset: 18; }

.pv-ring__copy { font-size: 0.7rem; line-height: 1.45; }
.pv-ring__copy b { display: block; font-weight: 600; font-size: 0.8rem; }
.pv-ring__copy span { color: rgba(255, 255, 255, 0.55); }

/* --- 5. bloom: a slow radial bloom with rays ----------------------------- */

.pv-bloom { background: linear-gradient(135deg, #dff3fb 0%, #efe3fb 55%, #fbe6f4 100%); }

.pv-bloom::before {
  content: '';
  position: absolute;
  inset: -20%;
  background: conic-gradient(
    from 0deg,
    rgba(150, 90, 220, 0.11) 0deg 1.2deg, transparent 1.2deg 9deg);
  /* without this the rays run edge to edge and swamp the panel */
  mask-image: radial-gradient(circle at 50% 50%,
    transparent 16%, #000 34%, #000 58%, transparent 86%);
  -webkit-mask-image: radial-gradient(circle at 50% 50%,
    transparent 16%, #000 34%, #000 58%, transparent 86%);
  animation: bloomSpin 46s linear infinite;
}
@keyframes bloomSpin { to { transform: rotate(360deg); } }

.pv-bloom::after {
  content: '';
  position: absolute;
  inset: 18% 26%;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(255,255,255,.92) 0%, rgba(214,180,246,.5) 52%, transparent 74%);
  filter: blur(6px);
  transition: transform 1100ms var(--spring-soft);
}
:root:not(.hover-cold) .wcard:hover .pv-bloom::after { transform: scale(1.16); }

.pv-bloom__word {
  position: relative;
  font-size: 1.5rem;
  font-weight: 500;
  letter-spacing: -0.03em;
  color: #5b3a8e;
  transition: transform 900ms var(--spring-soft);
}
:root:not(.hover-cold) .wcard:hover .pv-bloom__word { transform: scale(1.09); }

/* --- 6. list: rows that tick in one after another ------------------------ */

.pv-list { background: #fbfaf8; align-items: start; padding: 9% 8%; }

.pv-list__rows { width: 100%; display: grid; gap: 0.55rem; }

.pv-list__row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.72rem;
  color: var(--ink-2);
  opacity: 0.25;
  transform: translateX(-6px);
  transition:
    opacity 520ms var(--ease) var(--sd),
    transform 620ms var(--spring-soft) var(--sd);
}
:root:not(.hover-cold) .wcard:hover .pv-list__row { opacity: 1; transform: none; }

.pv-list__row::before {
  content: '';
  width: 0.55rem; height: 0.55rem;
  flex: none;
  border-radius: 50%;
  background: var(--line);
  transition: background 420ms ease var(--sd);
}
:root:not(.hover-cold) .wcard:hover .pv-list__row::before { background: #22c07d; }

@media (prefers-reduced-motion: reduce) {
  .wcard { opacity: 1; transform: none; }
  .pv-words i, .pv-bloom::before { animation: none; }
  .pv-list__row { opacity: 1; transform: none; }
  .wcard__thumb--cover { transition: opacity 200ms linear; transform: none; }
  :root:not(.hover-cold) .wcard:hover .wcard__thumb--cover.is-on { transform: none; }
  /* the poster still widens on hover — that is the affordance, and it is a
     size change rather than motion — but it arrives without the spring */
  .wcard__thumb--sheet { transition: width 200ms linear; transform: none; }
  :root:not(.hover-cold) .wcard:hover .wcard__thumb--sheet { transform: none; }
}

/* ==========================================================================
   9. INDEX TABLE
   ========================================================================== */

.index { padding: clamp(2rem, 8vh, 5rem) 0 clamp(3rem, 10vh, 6rem); }

.tabs {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding-bottom: 0.85rem;
  border-bottom: 1px solid var(--line);
  font-size: var(--step-0);
}

.tab {
  position: relative;
  color: var(--ink-dim);
  padding: 0.1rem 0;
  transition: color 260ms ease;
}
.tab:hover { color: var(--ink-2); }
.tab[aria-selected='true'] { color: var(--ink); font-weight: 500; }

.tabs__dot { color: var(--ink-ghost); }

.rows { display: grid; }

.row {
  display: grid;
  /* 6rem, not the 4.5 it was: the first column holds "Since 2025" now, and at
     4.5 it wrapped onto two lines and took the row's height with it. */
  grid-template-columns: 6rem 1fr auto;
  gap: 1rem;
  align-items: baseline;
  padding: 0.72rem 0;
  border-bottom: 1px solid var(--line-soft);
  font-size: var(--step-0);
  opacity: 0;
  transform: translateY(6px);
  animation: rowIn 520ms var(--ease) forwards;
}

@keyframes rowIn { to { opacity: 1; transform: none; } }

/* no hover styling until the pointer has actually moved (clicks still work) */
:root.hover-cold .row:hover,
:root.hover-cold .project:hover,
:root.hover-cold .post:hover,
:root.hover-cold .person:hover { background: none; transform: none; }

.row { transition: background 320ms ease, transform 520ms var(--spring); }
.row:hover { transform: translateX(3px); background: linear-gradient(to right, transparent, rgba(20, 16, 12, 0.022) 12%, rgba(20, 16, 12, 0.022) 88%, transparent); }
.row__year { color: var(--ink-dim); font-variant-numeric: tabular-nums; font-size: var(--step--1); }
.row__name { font-weight: 500; }
.row__meta { color: var(--ink-dim); text-align: right; }

/* Rows that link out (award certificates). Every row already lifts and tints
   on hover, so that shared feedback says nothing about whether a row does
   anything — the arrow is what separates the three rows that open a
   certificate from the one that doesn't. It leans on the row's own hover
   rather than adding a second background. */
.row--link .row__name::after {
  content: '↗';
  display: inline-block;
  margin-left: 0.35em;
  font-size: 0.82em;
  color: var(--ink-dim);
  transition: color 320ms ease, transform 320ms var(--spring);
}
:root:not(.hover-cold) .row--link:hover .row__name::after {
  color: var(--ink);
  transform: translate(1px, -1px);
}

@media (max-width: 40rem) {
  .row { grid-template-columns: 3.5rem 1fr; }
  .row__meta { grid-column: 2; text-align: left; font-size: var(--step--1); }
}

/* ==========================================================================
   10. SECTION HEADS (inner pages)
   ========================================================================== */

.page-head { padding: clamp(6rem, 18vh, 11rem) 0 clamp(2rem, 6vh, 3.5rem); }

.page-head h1 {
  font-family: var(--sans);
  font-weight: 600;
  font-size: var(--step-3);
  letter-spacing: -0.03em;
  line-height: 1.04;
  letter-spacing: -0.015em;
}

.page-head p {
  margin-top: 1.1rem;
  max-width: 32rem;
  color: var(--ink-dim);
  font-size: var(--step-1);
  line-height: 1.35;
  letter-spacing: -0.017em;
}

.reveal {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity 760ms var(--ease), transform 760ms var(--ease);
}
.reveal.is-in { opacity: 1; transform: none; }

/* ==========================================================================
   11. WORK CARDS
   ========================================================================== */

.projects { display: grid; gap: 1px; background: var(--line); border-top: 1px solid var(--line); }

.project {
  --accent: var(--ink);
  position: relative;
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.45rem;
  padding: clamp(1.4rem, 3.5vh, 2.2rem) clamp(1rem, 3vw, 1.6rem);
  background: var(--paper);
  transition: background 360ms ease, transform 560ms var(--spring);
}

.project::before {
  content: '';
  position: absolute;
  left: 0; top: 0; bottom: 0;
  width: 2px;
  background: var(--accent);
  transform: scaleY(0);
  transform-origin: top;
  transition: transform 460ms var(--ease);
}

.project:hover { background: var(--paper-2); }
.project:hover::before { transform: scaleY(1); }

.project__top { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.6rem; }
.project__title { font-size: var(--step-2); line-height: 1.1; letter-spacing: -0.024em; font-weight: 500; }
.project__year { color: var(--ink-dim); font-size: var(--step--1); font-variant-numeric: tabular-nums; margin-left: auto; }
.project__role { color: var(--accent); font-size: var(--step--1); font-weight: 500; }
.project__summary { color: var(--ink-2); max-width: 34rem; line-height: 1.5; }

.tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.4rem; }
.tag {
  font-size: 0.6875rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-dim);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 0.16rem 0.5rem;
}

/* ==========================================================================
   12. PEOPLE
   ========================================================================== */

.group { margin-top: clamp(2rem, 6vh, 3.5rem); }

.group__label {
  font-size: var(--step--1);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-dim);
  padding-bottom: 0.7rem;
  border-bottom: 1px solid var(--line);
}

.person {
  display: grid;
  grid-template-columns: minmax(8rem, 12rem) 1fr;
  gap: 1rem;
  align-items: baseline;
  padding: 0.9rem 0;
  border-bottom: 1px solid var(--line-soft);
  transition: transform 560ms var(--spring);
}
.person:hover { transform: translateX(5px); }
.person__name { font-weight: 500; }
.person__note { color: var(--ink-dim); line-height: 1.5; }

@media (max-width: 40rem) {
  .person { grid-template-columns: 1fr; gap: 0.2rem; }
}

/* ==========================================================================
   13. WRITING
   ========================================================================== */

.posts { border-top: 1px solid var(--line); }

.post {
  display: block;
  padding: clamp(1.2rem, 3vh, 1.9rem) 0;
  border-bottom: 1px solid var(--line-soft);
  transition: transform 560ms var(--spring);
}
.post:hover { transform: translateX(5px); }

.post__meta {
  display: flex; gap: 0.75rem; align-items: center;
  font-size: var(--step--1);
  color: var(--ink-dim);
  font-variant-numeric: tabular-nums;
}
.post__title {
  font-size: var(--step-2);
  line-height: 1.14;
  letter-spacing: -0.024em;
  font-weight: 500;
  margin: 0.3rem 0 0.45rem;
  max-width: 30rem;
}
.post:hover .post__title { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px; }
.post__excerpt { color: var(--ink-dim); max-width: 34rem; line-height: 1.55; }

/* ==========================================================================
   14. FOOTER
   ========================================================================== */

.closing { padding: clamp(2rem, 8vh, 5rem) 0 clamp(3rem, 10vh, 6rem); }

/* --- the footer -----------------------------------------------------------
   Written to answer the header rather than to close the page off.

   Three things make the two ends read as one system, and none of them is a
   coincidence: the dotted field is the SAME --dots token the hero canvas uses,
   the Pages column is generated from the same `nav` array the header is built
   from, and the links take the header's exact type and hover behaviour. What was
   here before — a signature, an inline dot-separated nav and three social glyphs
   — shared nothing with the top of the page except a border.

   The email is the headline, the way the name is in the hero. It is the largest
   thing on the surface because it is the only thing the page is asking for. */
.foot {
  position: relative;
  overflow: hidden;                /* the oversized dot field is clipped to it */
  isolation: isolate;
  border-top: 1px solid var(--line);
  background: var(--paper-2);
  padding: clamp(2.75rem, 8vw, 4.75rem) var(--gut)
           calc(clamp(1.75rem, 4vw, 2.5rem) + var(--sa-bottom));
  display: grid;
  /* the said column takes what it needs; the two link columns hug their content
     and sit together on the right, as in the reference */
  grid-template-columns: minmax(0, 1fr) auto auto;
  gap: clamp(1.75rem, 6vw, 5rem);
  align-items: start;
}

.foot__dots {
  position: absolute;
  inset: 0;
  z-index: -1;
  background-image: var(--dots);
  background-size: var(--dots-step);
  pointer-events: none;
}

.foot__said { display: grid; justify-items: start; }
.foot__mail {
  font-size: clamp(1.375rem, 0.85rem + 1.9vw, 2.25rem);
  font-weight: 600;
  letter-spacing: -0.025em;
  line-height: 1.05;
  color: var(--ink);
  /* the underline only on hover: at this size a permanent rule competes with the
     address itself, and the mailto is obvious from the shape of the text */
  text-decoration: none;
  transition: opacity 240ms var(--ease);
  word-break: break-word;          /* it must not overflow on a narrow screen */
}
.foot__mail:hover { text-decoration: underline; text-underline-offset: 0.14em; }

.foot__note {
  margin: 0.9rem 0 0;
  max-width: 30ch;                 /* two lines, as in the reference */
  font-size: var(--step-0);
  line-height: 1.5;
  color: var(--ink-2);
}
.foot__fine {
  margin: clamp(1.5rem, 4vw, 2.5rem) 0 0;
  font-size: var(--step--1);
  color: var(--ink-dim);
}

.foot__colt {
  margin: 0 0 0.85rem;
  font-size: var(--step-0);
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--ink);
}
.foot__col ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.55rem; }
/* the header's own link treatment, so a link reads the same at either end */
.foot__col a {
  display: block;
  width: max-content;
  font-size: var(--step-0);
  color: var(--ink-dim);
  transition: color 260ms var(--ease), transform 520ms var(--spring);
  transform-origin: left center;
}
.foot__col a:hover { color: var(--ink-2); transform: translateX(3px); }
.foot__col a[aria-current='page'] { color: var(--ink); }

/* --- and the same link with a glyph in place of the word -------------------
   The word is still there, still laid out, still setting the height of the row —
   it is only invisible, with the mark placed over it. That is what keeps this
   column in step with Pages beside it without a single hand-set dimension: the
   pitch is whatever the type scale says a label row is, at every width.
   `visibility` rather than `opacity` or a clip: it takes the word out of the
   accessibility tree as well as off the screen, and the anchor's aria-label —
   the same string — is what gets announced instead. */
.foot__ico { position: relative; }
.foot__ico__w { visibility: hidden; }
.foot__ico svg {
  position: absolute;
  left: 0;
  top: 50%;
  /* 16 rather than 18: three of these four marks are solid fills, and at 18 the
     column carried visibly more ink than the words beside it. 16 is also the
     cap height of the label it replaced, so the two columns share a baseline
     rhythm as well as a pitch. */
  width: 16px;
  height: 16px;
  transform: translateY(-50%);
  /* A glyph carries less ink than a word, so at --ink-dim it reads lighter than
     the labels next to it AND sits under 2:1 against the paper. This is the
     tone that puts it at 3:1 — the floor for something that is not text — and
     lands it at about the apparent weight of the words in the other column. */
  color: #8f8a82;
}
.foot__ico:hover svg { color: var(--ink-2); }

@media (max-width: 52rem) {
  .foot {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    row-gap: clamp(2rem, 7vw, 3rem);
  }
  /* the address spans both columns rather than being squeezed into one */
  .foot__said { grid-column: 1 / -1; }
}

@media (prefers-reduced-motion: reduce) {
  .foot__col a:hover { transform: none; }
}

/* the bit that floats on the sky, below the sheet */
.outro {
  position: relative;
  z-index: 1;
  /* measured: ~107px above the text, ~186px below it */
  padding: clamp(3.5rem, 13vh, 6.7rem) var(--gut) clamp(5rem, 22vh, 11.6rem);
  text-align: center;
  /* Written by the sky module: dark ink on a light sky, light ink on a dark
     one. Hard-coding this dark made the footer unreadable at night. */
  color: var(--outro-ink, #33302c);
  transition: color 900ms linear;
}

/* THE HAZE MOVED TO THE SKY. It is a legibility device — it keeps the local
   background off mid-luminance at dawn and dusk, where neither dark nor light
   ink clears 4.5:1 — and it used to be painted by the outro, inside the moving
   shell.

   That was fine while the shell was the whole screen. With the deck out it was
   the thing that gave the game away: measured across the window's edge at the
   footer, the sky inside was twenty levels lighter than the sky six pixels to
   its left. One environment, painted two ways, with a hard step down the seam —
   which is exactly the "two identities" it is supposed not to be.

   So it is painted once, on the sky, at viewport coordinates. Both sides of the
   window's edge get the same wash because there is only one wash, and the outro
   still sits in the middle of it. */
.pane::after {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(78% 46% at 50% 86%, var(--outro-scrim, transparent) 0%, transparent 74%);
  transition: background 900ms linear;
  pointer-events: none;
}

.outro p { position: relative; }

.outro p {
  width: min(34rem, 100%);
  margin-inline: auto;
  font-size: var(--step-0);
  line-height: 1.62;
}
.outro strong { font-weight: 600; color: var(--outro-ink-strong, #1c1a17); }
/* one flowing sentence — the fine print is part of it, not a separate line */
.outro .fine { opacity: 0.72; }

/* ==========================================================================
   15. CONTROLS  (time slider + mute)
   ========================================================================== */

.controls,
.mute {
  /* Both pods are hidden until the paper starts detaching from the sky, then
     they rise into place. --end is written by the scroll loop, 0 → 1. */
  opacity: var(--end, 0);
  transform: translateY(calc((1 - var(--end, 0)) * 14px)) scale(calc(0.9 + var(--end, 0) * 0.1));
  pointer-events: var(--end-events, none);
  transition: opacity 420ms var(--ease), transform 620ms var(--spring);
}

.controls {
  position: fixed;
  z-index: 50;
  bottom: 20px;                     /* measured */
  left: 20px;
}

.timepod {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  height: 2rem;                     /* measured 32px */
  padding: 0 0.6rem 0 0.5rem;
  border-radius: 999px;
  /* measured: a flat black scrim at 37% over whatever the sky is doing */
  background: rgba(8, 6, 4, 0.37);
  backdrop-filter: blur(10px) saturate(1.15);
  -webkit-backdrop-filter: blur(10px) saturate(1.15);
  box-shadow: 0 1px 2px rgba(8, 6, 4, 0.18), 0 8px 22px -10px rgba(8, 6, 4, 0.45);
  color: #fff;
  transition: background 700ms linear;
}

.timepod__orb {
  width: 0.95rem; height: 0.95rem;
  flex: none;
  display: grid;
  place-items: center;
  color: #fff;
}
.timepod__orb svg { width: 100%; height: 100%; display: block; }

.timepod__track {
  position: relative;
  display: grid;
  place-items: center;
  width: 5.75rem;                   /* measured pod 146px total */
}

.timepod__ticks {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  height: 0.9rem;
  pointer-events: none;
}
.timepod__ticks i {
  display: block;
  width: 1px;
  height: 0.34rem;
  background: rgba(255, 255, 255, 0.45);
  border-radius: 1px;
  transition: height 420ms var(--spring), background 300ms ease, width 300ms ease;
}
.timepod__ticks i.on {
  height: 0.85rem;
  width: 2px;
  background: #fff;
}

.timepod input[type='range'] {
  position: absolute;
  inset: -0.55rem -0.4rem;
  width: calc(100% + 0.8rem);
  margin: 0;
  opacity: 0;
  /* `grab` rather than `ew-resize`: resize is what you do to a window's edge,
     and this is a thing you pick up and pull. It is the only cursor in the pod,
     so it is also the pod's whole explanation of itself. */
  cursor: grab;
}
.timepod input[type='range']:active { cursor: grabbing; }
.timepod:active { cursor: grabbing; }

.timepod__label {
  font-size: 0.625rem;
  letter-spacing: 0.09em;
  color: rgba(255, 255, 255, 0.9);
  font-variant-numeric: tabular-nums;
  opacity: 0;
  max-width: 0;
  overflow: hidden;
  white-space: nowrap;
  transition: opacity 300ms ease, max-width 460ms var(--spring);
}
.timepod:hover .timepod__label,
.timepod:focus-within .timepod__label { opacity: 1; max-width: 3.2rem; }

.mute {
  position: fixed;
  z-index: 50;
  bottom: 20px;                     /* measured */
  right: 20px;
  width: 2rem; height: 2rem;        /* measured 32px */
  display: grid; place-items: center;
  border-radius: 50%;
  background: rgba(8, 6, 4, 0.37);
  backdrop-filter: blur(10px) saturate(1.15);
  -webkit-backdrop-filter: blur(10px) saturate(1.15);
  box-shadow: 0 1px 2px rgba(8, 6, 4, 0.18), 0 8px 22px -10px rgba(8, 6, 4, 0.45);
  color: #fff;
}
.mute:hover { transform: translateY(calc((1 - var(--end, 0)) * 14px)) scale(1.09); }
.mute svg { width: 0.9rem; height: 0.9rem; }
.mute .slash { transition: opacity 200ms ease, transform 320ms var(--spring); transform-origin: center; }
.mute[aria-pressed='false'] .slash { opacity: 0; transform: scale(0.4); }

/* ==========================================================================
   15b. TACTILE PRESS
   Everything clickable dips under the finger and springs back with a little
   overshoot. The dip is fast (90ms, ease-out) so it feels instant; the return
   is the spring, which is what makes it read as physical.
   ========================================================================== */

.pressable {
  transition: transform 380ms var(--spring);
  will-change: transform;
}

.pressable.is-press {
  transform: scale(0.975);
  transition: transform 90ms cubic-bezier(0.3, 0, 0.4, 1);
}

/* belt and braces: a full-viewport backdrop must never scale */
[data-nopress].is-press { transform: none; }

/* larger surfaces need a shallower dip or it looks like a glitch */
.row.is-press, .project.is-press, .post.is-press, .person.is-press {
  transform: scale(0.994);
}
.tab.is-press { transform: scale(0.94); }

/* ==========================================================================
   15c. TOOLTIP
   ========================================================================== */

/* The surface is unchanged — same ink, same radius, same type, same shadow,
   same spring up from under the anchor. What changed is where it is put and
   how it gets there.

   POSITION IS A TRANSLATE, NOT `left`/`top`. It has to be, because the label
   travels between anchors: `left` and `top` are laid out, so animating them
   costs a layout per frame, while `translate` is composited and — crucially —
   is a separate property from `transform`, which is already spoken for by the
   spring. The two compose without either having to know about the other.
   `--tip-x`/`--tip-y` are the anchor point in viewport coordinates; the
   percentage in each `calc` resolves against the label's own box, which is what
   turns that point into a corner. */
.tip {
  position: fixed;
  left: 0;
  top: 0;
  --tip-x: 0px;
  --tip-y: 0px;
  --tip-slide: 150ms;
  padding: 0.24rem 0.5rem;
  border-radius: 6px;
  background: #1a1713;
  color: #fbfaf8;
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.005em;
  white-space: nowrap;
  pointer-events: none;
  /* default: above the anchor, centred on it */
  translate: calc(var(--tip-x) - 50%) calc(var(--tip-y) - 100%);
  transform: translateY(6px) scale(0.9);
  opacity: 0;
  transition:
    transform 420ms var(--spring),
    opacity 200ms ease,
    translate var(--tip-slide) var(--ease);
  box-shadow: 0 4px 14px -4px rgba(0, 0, 0, 0.4);
}

/* Appearing is not travelling. The module turns the glide off for the frame in
   which a label arrives from nothing, so it fades up in place instead of flying
   across the screen from whatever was hovered last — and off again while the
   anchor itself is moving, so the label stays welded to it rather than easing
   along behind. */
.tip.is-jump { --tip-slide: 0s; }

/* Beside the anchor: right edge on the point, centred on it vertically. This is
   what the dock gets, and it is chosen in the module from the anchor rather
   than declared here per component — the label has one owner and one place that
   decides where it goes. */
.tip[data-side='left'] {
  translate: calc(var(--tip-x) - 100%) calc(var(--tip-y) - 50%);
  /* THE SPRING TURNS WITH THE LABEL. The default entrance rises 6px and settles
     6px clear of the anchor, which is what a label above something should do —
     and applied sideways it is just 6px of vertical misalignment against the
     button it is naming. Here it comes out of the dock's edge instead, and
     settles at nothing, so the 10px gap the module measured is the 10px gap you
     get and the label's centre is the button's centre. */
  transform-origin: 100% 50%;
  transform: translateX(8px) scale(0.9);
}

.tip.is-up { transform: translateY(-6px) scale(1); opacity: 1; }
.tip[data-side='left'].is-up { transform: none; }

.tip::after {
  content: '';
  position: absolute;
  left: 50%; bottom: -3px;
  width: 7px; height: 7px;
  translate: -50% 0;
  rotate: 45deg;
  background: #1a1713;
  border-radius: 1px;
}

/* the same square, turned to the side the label is now on */
.tip[data-side='left']::after {
  left: auto; right: -3px;
  top: 50%; bottom: auto;
  translate: 0 -50%;
}

/* ==========================================================================
   15d. LOGO STACK
   ========================================================================== */

.stack {
  position: relative;
  display: inline-block;
  width: 1.5em;
  height: 1.5em;
  vertical-align: -0.3em;
  margin-inline: 0.16em 0.3em;
  cursor: pointer;
}

.stack__card {
  position: absolute;
  inset: 0;
  border-radius: 28%;
  background: var(--card, #2b2b2b);
  box-shadow: 0 1px 3px rgba(20, 16, 12, 0.28), inset 0 0 0 1px rgba(255, 255, 255, 0.12);
  display: grid;
  place-items: center;
  font-size: 0.5em;
  font-weight: 700;
  color: #fff;
  transition: transform 620ms var(--spring), opacity 340ms ease;
  will-change: transform;
}

/* the two cards behind fan out slightly and sit lower */
.stack__card[data-depth='0'] { transform: translateY(0) rotate(0deg) scale(1); opacity: 1; z-index: 3; }
.stack__card[data-depth='1'] { transform: translateY(-2px) rotate(-7deg) scale(0.94); opacity: 0.55; z-index: 2; }
.stack__card[data-depth='2'] { transform: translateY(-4px) rotate(-13deg) scale(0.88); opacity: 0.28; z-index: 1; }

:root:not(.hover-cold) .stack:hover .stack__card[data-depth='0'] { transform: translateY(-2px) scale(1.06); }
:root:not(.hover-cold) .stack:hover .stack__card[data-depth='1'] { transform: translate(4px, -5px) rotate(9deg) scale(0.96); opacity: 0.8; }
:root:not(.hover-cold) .stack:hover .stack__card[data-depth='2'] { transform: translate(-5px, -7px) rotate(-16deg) scale(0.92); opacity: 0.6; }

/* ==========================================================================
   15e. AVATAR GROUP  (transitions.dev)
   The hover spring is owned entirely by .t-avatar; .chip-badge only brings the
   size, shape and colour. JS writes --shift and --scale-active per sibling with
   a distance falloff, so neighbours lift less the further out they are.
   ========================================================================== */

.t-avatar {
  transform-origin: center;
  /* translateY before scale so scale doesn't amplify the lift offset */
  transform:
    translateY(var(--shift, 0px))
    scale(calc(var(--scale-active, 1) * var(--press, 1)));
  transition: transform var(--avatar-dur) var(--avatar-ease-in);
  will-change: transform;
}

/* the press dip multiplies into the same transform instead of replacing it,
   so a badge can be lifted and pressed at the same time */
.t-avatar.is-press { --press: 0.92; }

.t-avatar-group { display: inline-flex; }

@media (prefers-reduced-motion: reduce) {
  .t-avatar { transition: none !important; transform: none !important; }
}

/* --- the badges themselves: round, overlapping, brand-coloured ----------- */

/* --- WHERE THE STACK SITS ON THE LINE ----------------------------------------

   It was `-0.22em`, and measured against the rendered page that put the discs'
   centres 1px BELOW the text baseline — the whole group hanging off the bottom
   of the line with its lower third in the descender space, which is what read
   as "not aligned". A round mark in running text wants its centre on the CAP
   midpoint, half a cap-height above the baseline; anything lower and it looks
   like it fell off the words.

   Stated in em so it tracks the type size rather than being right at one
   breakpoint. Measured, not guessed: at 29px the cap is 21.2px, so the target
   centre is 10.6px above the baseline and this is the offset that puts it
   there. */
.badges {
  vertical-align: 0.17em;
  margin-left: 0.3em;
}

.chip-badge {
  position: relative;
  display: inline-grid;
  place-items: center;
  /* JUST UNDER CAP HEIGHT. It was 1.28em, which at the story's 29px came out
     at 20.8 against a 21.2px cap — a disc the full height of a capital, ringed
     in paper and dropped in shadow, which is more weight than three of them in
     a row can carry without becoming the sentence. Sat a step down, the group
     reads as an aside inside the line, which is what it is. */
  width: 1.1em;
  height: 1.1em;
  border-radius: 50%;
  background: var(--chip, #cfcac2);
  color: #fff;
  font-size: 0.56em;
  font-weight: 700;
  font-style: normal;
  cursor: pointer;
  /* the ring came down with the disc — 2px on an 18px circle is a bezel */
  box-shadow: 0 1px 2.5px rgba(20, 16, 12, 0.22), 0 0 0 1.5px var(--paper);
}

.badges .chip-badge + .chip-badge { margin-left: -0.28em; }

/* the hovered one has to come forward or the overlap clips its lift */
.chip-badge:hover { z-index: 3; }

/* THE INSET IS ONE NUMBER FOR EVERY MARK, and it can be because the marks
   themselves are now cropped to their own ink — see the note at the top of
   safe.svg. 64% of the disc is the usual seat for a logo in a lockup: enough
   air that the glyph is not pressed against the rim, tight enough that it is
   the thing you see rather than the circle. It came down from 64 once the
   viewBoxes were cropped: the same number against uncropped art and against
   cropped art are two different amounts of ink, and at 64 the shield and the
   boat were touching the rim.

   `--mark` is the one concession to reality. Optical sizing of logos in a
   lockup is per-logo work in every design system that has ever shipped one: a
   wide, short mark and a tall, narrow one do not read at the same weight at
   the same box size however square you make their boxes. The default is 1 and
   only a mark that genuinely needs it sets otherwise. */
.chip-badge img,
.chip-badge svg {
  width: 56%;
  height: 56%;
  display: block;
  pointer-events: none;
  /* A SCALE, NOT A calc ON THE PERCENTAGE. `calc(64% * var(--mark))` parses,
     and then an <img> of an SVG that carries a viewBox but no intrinsic width
     resolves it to something else entirely — measured, the marked-up badge came
     back 68px wide inside an 18px disc. A transform sidesteps replaced-element
     sizing completely: it is applied after layout, it cannot be renegotiated by
     the image, and it costs nothing. */
  transform: scale(var(--mark, 1));
}

/* ==========================================================================
   16. TOAST
   ========================================================================== */

.toast {
  position: fixed;
  z-index: 60;
  left: 50%; bottom: 1.6rem;
  transform: translate(-50%, 140%);
  padding: 0.55rem 0.95rem;
  border-radius: 999px;
  background: var(--ink);
  color: var(--paper);
  font-size: var(--step--1);
  letter-spacing: 0.005em;
  box-shadow: 0 10px 30px -12px rgba(0, 0, 0, 0.6);
  transition: transform 520ms var(--ease), opacity 300ms ease;
  opacity: 0;
  pointer-events: none;
}
.toast.is-up { transform: translate(-50%, 0); opacity: 1; }

/* ==========================================================================
   17. PAGE ENTER
   ========================================================================== */

.sheet { animation: sheetIn 700ms var(--ease) both; }
@keyframes sheetIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }

body.is-leaving .sheet { opacity: 0; transform: translateY(-8px); transition: opacity 260ms ease, transform 260ms ease; }

/* ==========================================================================
   18. CLICK SPARK
   Measured off the reference frame-by-frame: six spokes at 60°, offset ~36°,
   travelling outward from r=3px to r=6px while their far end goes 12px → 15px,
   fading to nothing. Whole thing lasts ~95ms — it is a spark, not a ripple.
   ========================================================================== */

.spark {
  position: fixed;
  z-index: 96;
  width: 0; height: 0;
  pointer-events: none;
}

.spark i {
  position: absolute;
  left: -1px;
  top: -4.5px;
  width: 2.2px;
  height: 9.5px;
  border-radius: 1.1px;
  background: #14100c;
  transform-origin: 50% 50%;
  animation: spark var(--spark-ms, 95ms) cubic-bezier(0.2, 0.55, 0.4, 1) forwards;
}

/* The reference is still clearly visible at +50ms and only collapses in the
   last ~25ms, so the opacity holds and then drops rather than fading evenly. */
@keyframes spark {
  0%   { transform: rotate(var(--a)) translateY(-7.5px) scaleY(1);    opacity: 0.52; }
  55%  { opacity: 0.46; }
  78%  { opacity: 0.28; }
  100% { transform: rotate(var(--a)) translateY(-11px)  scaleY(0.82); opacity: 0; }
}

/* ==========================================================================
   19. RESPONSIVE NAV
   ========================================================================== */

@media (max-width: 46rem) {
  .nav {
    top: 0; left: 0; right: 0;
    padding: 0.7rem clamp(1rem, 4vw, 1.5rem);
    display: flex;
    gap: 0.9rem;
    background: rgba(247, 246, 243, 0.82);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    border-bottom: 1px solid var(--line-soft);
    font-size: 1rem;
  }
  .nav a[aria-current='page']::after { display: none; }
  .hero { padding-top: 5rem; }
  /* the footer is a grid now and stacks in its own 52rem block — a leftover
     `flex-direction: column` here would have fought that silently */
}

/* ==========================================================================
   20. MOTION PREFERENCES
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
  .c { color: var(--ink-2); }
  .chip { opacity: 1; transform: none; }
  .block u { background-size: 100% 1.5px; }
  .block mark { background: rgba(20, 16, 12, 0.09); }
  .reveal { opacity: 1; transform: none; }
}

/* ==========================================================================
   21. PEOPLE FIELD
   Clicking "people" scatters a field of cards over the page. The page itself
   blurs and desaturates behind them; the trigger word stays sharp on top.
   ========================================================================== */

.people-trigger {
  font: inherit;
  color: inherit;
  padding: 0;
  cursor: pointer;
}

/* While the field is open the paper falls back and out of focus.
   Measured off the recording: the blur is NOT animated — it snaps in one 17ms
   frame in both directions. --field-blur-ms exists so it can be softened, but
   0ms is what the reference actually does. */
.sheet,
.outro {
  transition:
    filter var(--field-blur-ms, 0ms) linear,
    transform var(--field-blur-ms, 0ms) linear;
}

body.is-field .sheet,
body.is-field .outro {
  filter: blur(7px) saturate(0.72);
  transform: scale(0.994);
}

/* the trigger word is lifted above the blur so it stays legible */
body.is-field .people-trigger {
  position: relative;
  z-index: 62;
  filter: none;
}

.field {
  position: fixed;
  inset: 0;
  z-index: 60;
  pointer-events: none;
  opacity: 0;
  transition: opacity 320ms ease;
}
.field.is-open { pointer-events: auto; opacity: 1; }

/* a click anywhere that isn't a card closes the field */
.field__scrim {
  position: absolute;
  inset: 0;
  background: rgba(247, 246, 243, 0.28);
  backdrop-filter: blur(1px);
  -webkit-backdrop-filter: blur(1px);
  border: 0;
  cursor: zoom-out;
}

.field__card {
  position: absolute;
  margin: 0;
  border-radius: 12px;
  background: var(--paper-2);
  box-shadow:
    0 1px 2px rgba(20, 16, 12, 0.08),
    0 18px 40px -16px rgba(20, 16, 12, 0.28);
  overflow: hidden;
  display: grid;
  place-items: center;
  padding: 8%;
  /* Measured: cards appear ~85ms after the trigger and grow from roughly 0.86
     to full size over ~300ms, easing out. They do not start tiny. */
  opacity: 0;
  transform:
    translate3d(0, 9px, 0)
    scale(0.86)
    rotate(calc(var(--rot, 0deg) * 1.5));
  transition:
    opacity 240ms ease var(--delay, 0ms),
    transform 320ms var(--spring-soft) var(--delay, 0ms);
  will-change: transform;
}

.field.is-open .field__card {
  opacity: 1;
  transform: translate3d(0, 0, 0) scale(1) rotate(var(--rot, 0deg));
}

.field__card img { width: 100%; height: 100%; object-fit: contain; }

/* a card lifts and straightens when you point at it */
.field__card:hover {
  transform: translate3d(0, -6px, 0) scale(1.045) rotate(calc(var(--rot, 0deg) * 0.3));
  box-shadow:
    0 2px 4px rgba(20, 16, 12, 0.1),
    0 30px 60px -18px rgba(20, 16, 12, 0.34);
  z-index: 3;
}

@media (prefers-reduced-motion: reduce) {
  body.is-field .sheet,
  body.is-field .outro { filter: none; transform: none; }
  .field__card { transform: none !important; }
}

/* ==========================================================================
   22. PROJECT DRAWER
   Slides up from the bottom over a dimmed, blurred page. Measured from the
   recording: rests with its top ~14% down the viewport (so it covers ~86%),
   rounded top corners, a sticky header, and its own internal scroll.
   ========================================================================== */

.drawer {
  position: fixed;
  inset: 0;
  z-index: 80;
  visibility: hidden;
  pointer-events: none;
}
/* Visibility can't be interpolated, so it has to stay `visible` for the whole
   exit or the panel vanishes instead of sliding out. `is-closing` keeps it
   painted while the transform and opacity finish. */
.drawer.is-open,
.drawer.is-closing { visibility: visible; }
.drawer.is-open { pointer-events: auto; }

/* --- the dimmed, blurred page behind ------------------------------------- */

.drawer__scrim {
  position: absolute;
  inset: 0;
  border: 0;
  padding: 0;
  cursor: pointer;
  background: rgba(16, 13, 10, 0.42);
  backdrop-filter: blur(7px) saturate(0.9);
  -webkit-backdrop-filter: blur(7px) saturate(0.9);
  opacity: 0;
  transition: opacity 380ms var(--ease);
  /* its own layer: without this, fading an element that also carries a
     backdrop-filter can flash as the compositor re-rasterises */
  will-change: opacity;
  transform: translateZ(0);
}
.drawer.is-open .drawer__scrim { opacity: 1; }

/* --- the panel ----------------------------------------------------------- */

.drawer__panel {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  /* Full height, pushed down 14% at rest (the measured resting position) and
     slid to 0 as you scroll. Doing this with a transform rather than a height
     means docking to the top never re-lays-out the content mid-scroll. */
  height: 100%;
  --rest: 14%;
  display: flex;
  flex-direction: column;
  background: var(--paper-2);
  border-radius: calc((1 - var(--grow, 0)) * 18px) calc((1 - var(--grow, 0)) * 18px) 0 0;
  box-shadow: 0 -8px 60px -12px rgba(16, 13, 10, 0.4);
  overflow: hidden;
  transform: translate3d(0, 100%, 0);
  /* a drawer wants to arrive decisively and stop dead — no overshoot, or the
     panel appears to bounce off the bottom of the screen */
  transition: transform 460ms cubic-bezier(0.22, 1, 0.3, 1);
  will-change: transform;
}
.drawer.is-open .drawer__panel {
  transform: translate3d(0, calc((1 - var(--grow, 0)) * var(--rest)), 0);
}

/* Closing is quicker than opening, which is how drawers usually feel right —
   but not so quick that it snaps. The panel accelerates away while the scrim
   holds its dim slightly longer, so the page doesn't brighten under a panel
   that's still on screen. */
.drawer.is-closing .drawer__panel {
  transition-duration: 340ms;
  transition-timing-function: cubic-bezier(0.36, 0, 0.66, 0.36);
}
.drawer.is-closing .drawer__scrim { transition-duration: 300ms; }

/* --- sticky header ------------------------------------------------------- */

.drawer__bar {
  position: relative;
  z-index: 2;
  flex: none;
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
  padding: 1.15rem clamp(1.25rem, 4vw, 2.5rem);
  border-bottom: 1px solid var(--line-soft);
  background: rgba(255, 254, 252, 0.92);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}

.drawer__title { font-size: var(--step-0); font-weight: 600; color: var(--ink); }
.drawer__sub   { font-size: var(--step-0); color: var(--ink-dim); }
.drawer__sub::before { content: '· '; }

.drawer__close {
  margin-left: auto;
  width: 1.6rem; height: 1.6rem;
  display: grid; place-items: center;
  border-radius: 50%;
  color: var(--ink-dim);
  transition: background 260ms ease, color 260ms ease, transform 480ms var(--spring);
}
.drawer__close:hover { background: rgba(20, 16, 12, 0.06); color: var(--ink); transform: rotate(90deg); }
.drawer__close svg { width: 0.8rem; height: 0.8rem; }

/* --- scrolling body ----------------------------------------------------- */

.drawer__scroll {
  flex: 1;
  overflow-y: auto;
  /* The NDA tape is 128vw and has to cross the whole page. Clipping it at its
     own wrapper cut it off at the reading column's edge; the clip belongs here,
     which is the actual viewport for this content. hidden rather than clip,
     because it pairs with overflow-y: auto everywhere. */
  overflow-x: hidden;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  /* the last 6rem is the run-out past the final section; the inset keeps it
     clear of the home indicator rather than ending underneath it */
  padding: var(--drawer-top) clamp(1.25rem, 4vw, 2.5rem)
           calc(6rem + var(--sa-bottom));
}

/* the two-column doc layout: small grey label in a gutter, content beside it */
.drawer__row {
  display: grid;
  grid-template-columns: 7.5rem minmax(0, 34rem);
  gap: 1.5rem;
  margin: 0 auto clamp(1.75rem, 5vh, 2.75rem);
  width: max-content;
  max-width: 100%;
}

.drawer__label {
  font-size: var(--step--1);
  color: var(--ink-dim);
  padding-top: 0.2em;
}

.drawer__body { font-size: var(--step-0); line-height: 1.62; color: var(--ink-2); }
.drawer__body p + p { margin-top: 0.85em; }

/* --- prev / next project, at the end of a case study ---------------------
   Two halves of one rule: a quiet direction label, then the project name at
   reading size so it is the thing you actually see. It cycles, so the last
   project points back to the first rather than dead-ending. */

.drawer__pager {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(1rem, 3vw, 2.5rem);
  margin: clamp(3rem, 8vh, 5rem) 0 clamp(2rem, 6vh, 3.5rem);
  padding-top: clamp(1.5rem, 4vh, 2.25rem);
  border-top: 1px solid var(--line);
}

.pager__side {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  padding: 0.75rem 0;
  background: none;
  border: 0;
  cursor: pointer;
  text-align: left;
  color: inherit;
  font: inherit;
  transition: transform 460ms var(--spring), opacity 260ms var(--ease);
}
.pager__side--next { text-align: right; align-items: flex-end; }

.pager__dir {
  font-size: var(--step--1);
  color: var(--ink-dim);
  transition: color 260ms var(--ease);
}

.pager__name {
  font-size: clamp(1.15rem, 2.4vw, 1.6rem);
  line-height: 1.2;
  letter-spacing: -0.015em;
  color: var(--ink);
}

.pager__meta {
  font-size: var(--step--1);
  color: var(--ink-dim);
}

/* the arrow is drawn on the direction label, so it travels with the hover */
.pager__side--prev .pager__dir::before { content: '\2190\00a0'; }
.pager__side--next .pager__dir::after { content: '\00a0\2192'; }

:root:not(.hover-cold) .pager__side--prev:hover { transform: translateX(-4px); }
:root:not(.hover-cold) .pager__side--next:hover { transform: translateX(4px); }
:root:not(.hover-cold) .pager__side:hover .pager__dir { color: var(--ink-2); }

/* one column on a narrow drawer, next first so it stays the primary action */
@media (max-width: 40rem) {
  .drawer__pager { grid-template-columns: 1fr; gap: 0.5rem; }
  .pager__side--next { text-align: left; align-items: flex-start; order: -1; }
}

@media (prefers-reduced-motion: reduce) {
  .pager__side { transition-duration: 1ms; }
}

.drawer__media {
  position: relative;
  width: min(44rem, 100%);
  margin: 0 auto clamp(1.75rem, 5vh, 2.75rem);
  aspect-ratio: 1.62;
  border-radius: 8px;
  overflow: hidden;
  background: var(--pv-bg, #f1efec);
  box-shadow: 0 0 0 1px rgba(20, 16, 12, 0.05);
}

/* each row and panel eases in as the drawer settles */
.drawer__row,
.drawer__media {
  opacity: 0;
  transform: translateY(14px);
  transition:
    opacity 460ms var(--ease) var(--sd, 0ms),
    transform 620ms var(--spring-soft) var(--sd, 0ms);
}
.drawer.is-open .drawer__row,
.drawer.is-open .drawer__media { opacity: 1; transform: none; }

@media (max-width: 46rem) {
  .drawer__panel { --rest: 8%; }
  .drawer__row { grid-template-columns: 1fr; gap: 0.4rem; width: auto; }
}

@media (prefers-reduced-motion: reduce) {
  .drawer__panel,
  .drawer__scrim,
  .drawer__row,
  .drawer__media { transition-duration: 1ms; }
  .drawer__row, .drawer__media { opacity: 1; transform: none; }
}

/* ==========================================================================
   23. PROJECT PAGE  (case study)
   Measured off the recording at a 1470px viewport: rail ink x26–119, content
   column 862px starting at x351 (a 232px gutter), rail rows 40px apart at
   ~13px type. The rail replaces the global nav on this page.
   ========================================================================== */

body[data-page='project'] .nav { display: none; }

.proj { padding: clamp(2.5rem, 7vh, 4.5rem) 0 0; }

/* --- sticky left rail ---------------------------------------------------- */

.rail {
  position: fixed;
  top: clamp(2.5rem, 7vh, 4.5rem);
  left: 1.625rem;                    /* measured 26px */
  width: 11rem;
  z-index: 40;
  font-size: 0.8125rem;              /* measured ~13px */
  line-height: 1.3;
}

.rail__back {
  display: inline-block;
  letter-spacing: 0.09em;
  color: var(--ink-2);
  margin-bottom: 1.55rem;            /* measured: 52px to the first link */
  transition: transform 480ms var(--spring), color 240ms ease;
}
.rail__back:hover { transform: translateX(-3px); color: var(--ink); }
.rail__back::before {
  content: '←';
  display: inline-block;
  margin-right: 0.4em;
  transition: transform 480ms var(--spring);
}
.rail__back:hover::before { transform: translateX(-2px); }

.rail__list { display: grid; }

.rail__link {
  display: block;
  height: 2.5rem;                    /* measured 40px row pitch */
  line-height: 2.5rem;
  color: var(--ink-dim);
  /* colour and weight are the whole active state — the reference has no
     indicator bar, so there isn't one here either */
  font-weight: 400;
  transition: color 380ms var(--ease), transform 520ms var(--spring);
  transform-origin: left center;
  width: max-content;
}
.rail__link:hover { color: var(--ink-2); transform: translateX(3px); }

.rail__link.is-active {
  color: var(--ink);
  font-weight: 600;
}

/* --- content column ------------------------------------------------------ */

.proj__body { padding-left: 21.9375rem; }   /* measured 351px */

/* was min(53.875rem, ...) — widened to match the drawer. The prose caps inside
   keep the measure honest, so this only buys room for media. */
.proj__col { width: min(72rem, calc(100% - var(--gut))); }

.proj__head:not(.proj__head--dark) { margin-bottom: var(--sp-7); }
.proj__eyebrow {
  display: block;
  font-size: var(--step--1);
  color: var(--ink-dim);
  margin-bottom: 0.35rem;
}
.proj__title {
  font-size: var(--step-3);
  line-height: 1.02;
  letter-spacing: -0.028em;
  font-weight: 500;
}

/* --- a section ----------------------------------------------------------- */

/* --sp-6, not --sp-8. The gap a reader sees between two sections is the SUM of
   two adjacent paddings, so --sp-8 either side read as 216px of void and the
   sections stopped feeling connected. Half the intended gap on each side means the
   gap itself is one step of the scale, and it is the same after every block. */
.sec {
  padding-block: var(--sp-6);
  scroll-margin-top: 5rem;
}

.sec__eyebrow {
  display: block;
  font-size: var(--step-0);
  color: var(--ink-dim);
  margin-bottom: 0.2rem;
}

.sec__heading {
  font-size: var(--step-2);
  line-height: 1.18;
  letter-spacing: -0.024em;
  font-weight: 600;
  margin-bottom: 1.15rem;
  max-width: var(--measure);
}

.sec__body { max-width: var(--measure); }
.sec__body p {
  font-size: var(--step-0);
  line-height: 1.62;
  color: var(--ink-dim);
}
.sec__body p + p { margin-top: 0.9em; }

/* --- blocks -------------------------------------------------------------- */

.blk { margin-top: var(--sp-4); }

.blk__caption {
  margin-top: 0.85rem;
  font-size: var(--step-0);
  line-height: 1.6;
  color: var(--ink-dim);
  max-width: 40rem;
}

/* one wide panel, or three across */
.blk-panel .pane { aspect-ratio: 1.9; }
.blk-row__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
.blk-row__grid .pane { aspect-ratio: 0.72; }

.pane {
  position: relative;
  border-radius: 5px;
  overflow: hidden;
  background: #f1efec;
  box-shadow: 0 0 0 1px rgba(20, 16, 12, 0.05);
}

/* large pull quote */
.blk-quote {
  font-size: var(--step-1);
  line-height: 1.34;
  letter-spacing: -0.019em;
  font-weight: 500;
  max-width: var(--measure);
  margin-block: var(--sp-6);
}

/* --- the Not / Instead contrast -------------------------------------------
   One card, split down the middle: the weak framing on the left, the business
   reality on the right, an arrow on the seam. Set side by side because the
   comparison is the point — stacked as text it merely stated a contrast without
   showing one.

   Two colours only, and they carry the argument: red is the Not side, green is
   the Instead side. Everything else — tiles, seam, arrow, card — sits on the
   background, so nothing competes with the verdict. */
.blk-vs {
  max-width: var(--measure);
  margin-top: var(--sp-5);
  /* one number for the split, read by both the grid and the arrow, so the badge
     cannot drift off the seam when the ratio changes */
  --vs-split: 30%;
  --vs-yes: #1e8a5c;
  --vs-line: rgba(20, 16, 12, 0.09);
  --vs-hair: rgba(20, 16, 12, 0.07);
  --vs-well: rgba(20, 16, 12, 0.035);
  --vs-face: var(--paper);
}

/* --vs-no lives outside that block because the two cards mean different things
   by their left side.

   Not / Instead is an argument: the left framing IS wrong, and red says so.
   Before / After is a history: the left side is the state the product was in, not
   a failure of it, and red would accuse the old product of being broken. So the
   even card takes a neutral ink and only After carries a colour — which is also
   what makes After read as the answer rather than making Before read as a
   mistake.

   Written as a :not() exclusion rather than an override. Both selectors carry one
   class, so an override would tie and be settled by source order. */
.blk-vs:not(.blk-vs--even) { --vs-no: #c4384c; }
.blk-vs--even { --vs-no: #6f6862; }

.vs__card { position: relative; display: grid; }
/* One frame with a seam down it — the Not / Instead card. The even card is two
   separate panels instead, so the framing is excluded here rather than undone
   there: both selectors carry one class, and an override would tie. */
.vs__card:not(.vs__card--even) {
  grid-template-columns: var(--vs-split) 1fr;
  border-radius: 16px;
  box-shadow: inset 0 0 0 1px var(--vs-line);
  overflow: hidden;
}
.vs__side { padding: clamp(1.1rem, 2.6vw, 1.6rem); }
/* the seam the arrow sits on */
.vs__card:not(.vs__card--even) .vs__side--yes { box-shadow: inset 1px 0 0 var(--vs-line); }

.vs__lab {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0 0 1.15rem;
  font-size: var(--step-0);
  font-weight: 600;
}
.vs__lab svg { width: 20px; height: 20px; flex: none; }
.vs__lab--no { color: var(--vs-no); }
.vs__lab--yes { color: var(--vs-yes); }

.vs__weak {
  margin: 0;
  font-size: var(--step-05);
  line-height: 1.45;
  color: var(--ink);
}

/* the arrow badge, centred on the seam */
.vs__arrow {
  position: absolute;
  top: 50%;
  left: var(--vs-split);
  translate: -50% -50%;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: var(--vs-face);
  box-shadow: inset 0 0 0 1px var(--vs-line);
  color: var(--ink);
  z-index: 2;
}
.vs__arrow svg { width: 17px; height: 17px; }

.vs__list { margin: 0; padding: 0; list-style: none; }
.vs__row {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  gap: 0.85rem;
  padding: 0.72rem 0;
}
.vs__row + .vs__row { box-shadow: inset 0 1px 0 var(--vs-hair); }

/* --- the even card: two answering lists ------------------------------------
   Before / After, one row against one row. Two tinted panels with a gap rather
   than one frame with a seam: the sides are now claims in opposition, and giving
   each its own ground is what lets the eye compare them row by row.

   `--vs-split: 50%` still sets the seam, because the arrow reads the same token
   and would otherwise be left behind on the old one.

   The washes are mixed FROM the accent tokens rather than typed as their own
   colours, so they follow the light/dark switch for free and no new hue can
   creep into a palette that is supposed to hold exactly red and green. */
.blk-vs--even { --vs-gap: clamp(3rem, 5vw, 4.25rem); }
.vs__card--even {
  --vs-split: 50%;
  grid-template-columns: 1fr 1fr;
  gap: var(--vs-gap);
}
.vs__card--even .vs__side {
  border-radius: 16px;
  padding: clamp(1.2rem, 2.4vw, 1.55rem) clamp(1.2rem, 2.4vw, 1.6rem);
}
/* Outlined, not filled. The washes were doing no work the outline was not already
   doing — two tinted slabs at 7% read as decoration, and on the After side a green
   field behind green text flattened the tick it was supposed to support. The
   hairline still separates the two sides and still carries the tint, so After is
   the only coloured thing on the card and the mark keeps its emphasis. */
.vs__card--even .vs__side--no {
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-no) 22%, transparent);
}
.vs__card--even .vs__side--yes {
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-yes) 26%, transparent);
}
/* the panel is already labelled in words, and every row below carries its own
   mark — a third glyph on the label was the same thing said a third time */
.vs__card--even .vs__lab svg { display: none; }
.vs__card--even .vs__lab { gap: 0; margin-bottom: 0.9rem; font-size: var(--step--1); }

/* One mark, at the head of the line. Two columns, not three: the row used to end
   with the same verdict circled at the far edge, which said nothing the leading
   mark had not already said and made the line read as a form field. */
.vs__card--even .vs__row {
  grid-template-columns: auto 1fr;
  gap: 0.7rem;
  padding: 0.4rem 0;
}
.vs__card--even .vs__row + .vs__row { box-shadow: none; }
.vs__card--even .vs__txt { font-size: var(--step--1); line-height: 1.45; }

.vs__tick { display: grid; place-items: center; flex: none; }
.vs__tick svg { width: 15px; height: 15px; }
/* Before takes the neutral ink, After the green — see the --vs-no note above */
.vs__side--no .vs__tick,
.vs__side--no .vs__ico { color: var(--vs-no); }
.vs__side--yes .vs__tick { color: var(--vs-yes); }

/* the arrow becomes the hinge between two panels rather than a badge on a seam,
   so it fills in — an outlined disc floating in a gap reads as a hole */
.vs__card--even .vs__arrow {
  width: 46px;
  height: 46px;
  background: #6d4dd8;
  box-shadow: none;
  color: #ffffff;
}
.vs__card--even .vs__arrow svg { width: 19px; height: 19px; }
.sec--dark .vs__card--even .vs__arrow { background: #7f6ae8; color: #ffffff; }

/* the tile stays on the background — only the glyph carries the green */
.vs__ico {
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  border-radius: 13px;
  background: var(--vs-well);
  box-shadow: inset 0 0 0 1px var(--vs-line);
  color: var(--vs-yes);
}
.vs__ico svg { width: 26px; height: 26px; }

.vs__txt {
  margin: 0;
  font-size: var(--step-0);
  line-height: 1.4;
  color: var(--ink);
}
.vs__sub { display: block; color: var(--ink-dim); }

/* --- the entrance ---------------------------------------------------------
   The card reads left to right, so it arrives that way: the Not panel, then the
   arrow, then each fact in turn, every one sliding in from its left.

   No observer of its own. `--i` is an index the renderer writes on each part,
   and the release is the section's existing `.is-shown` gate — so this cannot
   drift out of step with the rest of the page, and it inherits the section's
   own `--stag` offset rather than starting the moment the card is built.

   18px, not more: at this size a longer throw reads as the row being dragged
   into place rather than settling. */
.stag .vs__side--no,
.stag .vs__arrow,
.stag .vs__row {
  --vs-d: calc(var(--stag, 0) * 90ms + 120ms + var(--i, 0) * 70ms);
  opacity: 0;
  transition:
    opacity 520ms var(--ease) var(--vs-d),
    transform 640ms var(--spring-soft) var(--vs-d),
    scale 640ms var(--spring-soft) var(--vs-d);
}
.vs__side--no { --i: 0; }
.vs__arrow { --i: 1; }
.stag .vs__side--no,
.stag .vs__row { transform: translateX(-18px); }
/* the arrow is already carrying `translate` for its centring, and `rotate` when
   the card stacks — a transform here would be applied in the rotated space and
   travel downward. It scales instead, which composes cleanly with both. */
.stag .vs__arrow { scale: 0.82; }

.sec.is-shown .stag .vs__side--no,
.sec.is-shown .stag .vs__arrow,
.sec.is-shown .stag .vs__row { opacity: 1; transform: none; scale: 1; }

@media (prefers-reduced-motion: reduce) {
  .stag .vs__side--no,
  .stag .vs__arrow,
  .stag .vs__row { opacity: 1; transform: none; scale: 1; transition: none; }
}

/* on the band the card lifts off it, the same step the other panels take.
   Only the four neutrals and the two accents are restated — every rule above
   reads them through the tokens, so nothing has to be duplicated. */
.sec--dark .blk-vs:not(.blk-vs--even) { --vs-no: #f08a97; }
.sec--dark .blk-vs--even { --vs-no: #9fb3c0; }
.sec--dark .blk-vs {
  --vs-yes: #5fd3a0;
  --vs-line: rgba(255, 255, 255, 0.08);
  --vs-hair: rgba(255, 255, 255, 0.07);
  --vs-well: rgba(255, 255, 255, 0.045);
  --vs-face: #17303d;
}
.sec--dark .vs__card { background: #17303d; }
.sec--dark .vs__weak,
.sec--dark .vs__txt { color: #eaf1f6; }
.sec--dark .vs__sub { color: #9fb3c0; }
.sec--dark .vs__arrow { color: #eaf1f6; }

/* stacked on a narrow screen, with the arrow turned to point down */
@media (max-width: 46rem) {
  .blk-vs { --vs-split: 50%; }   /* the seam is now horizontal; the arrow re-centres */
  .blk-vs--even { --vs-gap: 3.5rem; }
  /* prefixed with .blk-vs so this outranks the :not() base rule outright — at
     equal weight it would come down to source order inside the same file */
  .blk-vs .vs__card { grid-template-columns: 1fr; }
  .blk-vs .vs__card:not(.vs__card--even) .vs__side--yes {
    box-shadow: inset 0 1px 0 var(--vs-line);
  }
  .vs__arrow { rotate: 90deg; }
}

/* numbered code block */
.blk-code {
  border-radius: 6px;
  background: #f4f2ef;
  box-shadow: 0 0 0 1px rgba(20, 16, 12, 0.05);
  padding: 1rem 0;
  overflow-x: auto;
}
.blk-code ol { counter-reset: ln; }
.blk-code li {
  display: grid;
  grid-template-columns: 2.6rem 1fr;
  gap: 0.75rem;
  font-family: var(--mono);
  font-size: 0.78125rem;
  line-height: 1.72;
  white-space: pre;
}
.blk-code li::before {
  counter-increment: ln;
  content: counter(ln);
  text-align: right;
  color: var(--ink-ghost);
}

/* the masonry tile field at the top of the case study */
/* ==========================================================================
   RESEARCH: editorial blocks, Figma frames, annotations, lightbox
   A reading surface. Generous vertical rhythm, a 45rem (720px) measure, and
   artifacts that sit in white frames like boards exported from a design file.
   ========================================================================== */

/* every block in a reading section gets room to breathe */
.sec .blk-bul,
.sec .blk-frame { margin-top: var(--sp-6); }

/* --- how the gaps relate --------------------------------------------------
   The gap above a block is the gap between it and what came before, so it has
   to know what came before. `.blk--leads` is a block that starts with a heading;
   `.blk--headonly` is a block that is nothing BUT a heading.

   A heading followed straight away by another heading was the visible fault: a
   major gap above it and a major gap below it, so it sat marooned in whitespace
   with nothing attached to it. The pair is one unit, so the gap between them
   closes to --sp-4 and the whole group keeps the single large gap above it. */
.blk--headonly + .blk--leads { margin-top: var(--sp-4); }

/* A bare heading also carries a bottom margin meant for the body copy that is
   not there, which widened the same gap from the other side. */
.blk--headonly .blk-head__t,
.blk--headonly .blk__sub2,
.blk--headonly .blk__sub { margin-bottom: 0; }

/* The section's own padding already separates the first block from the section
   above, so the block's own top gap would double it. */
.sec > .blk--first { margin-top: var(--sp-6); }

/* ==========================================================================
   THE DARK OVERVIEW BAND
   The opening stretch of a case study — the summary and the highlights — sits on
   a dark band, so it reads as overview rather than as the body of the argument.
   Colour sampled from the reference: #122633.

   The band is painted with a spread box-shadow clipped to the section's own
   height, which gives full-bleed width without an extra element and without
   creating horizontal overflow. Consecutive dark sections tile seamlessly
   because sections are spaced with padding, not margin.

   One caveat worth knowing: the clip cuts at the section's top and bottom edges,
   so an artifact whose annotation overhangs vertically would be trimmed. Nothing
   in the overview does that today; if you add one, give that block its own
   wrapper or drop the clip on that section.

   THE SEAM. "Consecutive dark sections tile seamlessly" was only true on paper.
   A section's height is rarely a whole number of device pixels, and the clip cut
   each band dead on its own edges — so between two dark sections a sub-pixel
   sliver of the pale page showed through as a hairline running the full bleed
   width, across the rail and everything else. The clip now bleeds half a pixel
   top and bottom so adjacent bands overlap instead of abutting. It costs nothing
   (the overlap is the same colour) and there is no gap left to show through.
   ========================================================================== */

.sec--dark {
  --tone: dark;                        /* the nav reads this and flips its ink */
  position: relative;
  background: #122633;
  box-shadow: 0 0 0 100vmax #122633;
  clip-path: inset(-0.5px -100vmax);
  color: #dfe7ec;
}

/* type on the band */
.sec--dark .sec__eyebrow { color: #7e94a1; }
.sec--dark .sec__heading,
.sec--dark .blk-head__t,
.sec--dark .blk__sub,
.sec--dark .blk__sub2 { color: #ffffff; }
.sec--dark .sec__body p,
.sec--dark .blk-head__b,
.sec--dark .bul li,
.sec--dark .bul__lead { color: #b6c5cf; }
.sec--dark .sec__body b,
.sec--dark .bul li b,
.sec--dark .blk-head__b b { color: #ffffff; }
.sec--dark .bul li::before { background: #4c6472; }
.sec--dark .fframe__cap { color: #8fa4b0; }

/* the facts row: light chips would glare, so they become quiet plates */
.sec--dark .blk-facts dt { color: #7e94a1; }
.sec--dark .blk-facts dd > span {
  color: #dfe7ec;
  background: rgba(255, 255, 255, 0.06);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07);
}

/* the gap cards have to lift OFF the band, not blend into it */
.sec--dark .gapc { background: #1b3543; }
.sec--dark .gapc--lift { background: #22434f; }

/* the ticker's own panel is nearly the band colour, so it drops the panel and
   lets the screens sit directly on the band */
.sec--dark .tick { background: transparent; padding-inline: 0; }

/* the highlights panel stays a distinct plate, a step up from the band */
.sec--dark .hlwrap { background: #1b3543; }

/* the rule under a preamble, and any hairline, needs inverting */
.sec--dark .sec__pre { border-bottom-color: rgba(255, 255, 255, 0.12); }
.sec--dark .blk-call p { border-left-color: rgba(255, 255, 255, 0.22); color: #c6d3dc; }
.sec--dark .blk-call b { color: #ffffff; }

/* the study header joins the band, so it runs to the very top of the scroll
   area instead of cutting a hard edge across the title */
.proj__head--dark {
  --tone: dark;
  position: relative;
  background: #122633;
  box-shadow: 0 0 0 100vmax #122633;
  clip-path: inset(0 -100vmax);
  padding-top: var(--drawer-top);
  padding-bottom: var(--sp-6);
}
.proj__head--dark .proj__eyebrow { color: #7e94a1; }
.proj__head--dark .proj__title { color: #ffffff; }

/* the scroll container's own top padding would show as a pale strip above the
   band, so a dark-topped study pulls the band up over it */
.drawer__study--darktop { margin-top: calc(-1 * var(--drawer-top)); }
.drawer__study--darktop .drawer__rail { padding-top: clamp(1.9rem, 5.6vh, 3.1rem); }

/* --- the rail over the band ----------------------------------------------
   The rail must paint above the band, and each link flips its own ink as the
   boundary crosses it (see RailInk). The colour transition is what makes that
   read as a smooth change rather than a snap. */
.drawer__rail { z-index: 5; }

.rail__link { transition: color 420ms var(--ease), opacity 420ms var(--ease); }
.rail__link[data-ink='light'] { color: #8ba0ad; }
.rail__link[data-ink='light'].is-active,
.rail__link[data-ink='light'][aria-current='true'] { color: #ffffff; }
:root:not(.hover-cold) .rail__link[data-ink='light']:hover { color: #dfe7ec; }

@media (prefers-reduced-motion: reduce) {
  .rail__link { transition-duration: 1ms; }
}

/* --- the three heading levels -------------------------------------------
   L1  .sec__heading / .blk-head__t   step-2, 600  — the big section titles
   L2  .sec__pre__t / .blk__sub2      step-1, 500  — the quieter sub-heads
   L3  body                           step-0, 400

   The section used to collapse L1 and L2 into one, which is what flattened the
   hierarchy: "Solve for unstructured hiring decisions" ended up as a bullet
   lead-in rather than a heading in its own right. */

/* the opener above a section heading */
.sec__pre {
  max-width: var(--measure);
  margin-bottom: var(--sp-6);
  padding-bottom: 1.35rem;
  border-bottom: 1px solid var(--line-soft);
}
.sec__pre__t {
  margin: 0 0 0.45rem;
  font-size: var(--step-1);
  font-weight: 500;
  line-height: 1.3;
  letter-spacing: -0.014em;
  color: var(--ink-2);
}
.sec__pre__b {
  margin: 0;
  font-size: var(--step-0);
  line-height: 1.68;
  color: var(--ink-dim);
}

/* a second (or third) major heading inside the same section */
.blk-head { max-width: var(--measure); margin-top: var(--sp-7); }
.blk-head__t {
  margin: 0 0 1.15rem;
  font-size: var(--step-2);
  line-height: 1.18;
  letter-spacing: -0.024em;
  font-weight: 600;
}
.blk-head__b {
  margin: 0 0 0.9rem;
  font-size: var(--step-0);
  line-height: 1.68;
  color: var(--ink-2);
}
.blk-head__b:last-child { margin-bottom: 0; }
.blk-head__b b { color: var(--ink); font-weight: 600; }

.blk-bul { max-width: var(--measure); }
.blk__sub2 {
  margin: 0 0 0.9rem;
  font-family: var(--sans);
  font-weight: 500;                    /* a step below the L1 headings */
  font-size: var(--step-1);
  line-height: 1.28;
  letter-spacing: -0.014em;
  color: var(--ink-2);
  font-weight: 500;
}
.bul__lead {
  margin: 0 0 1.1rem;
  font-size: var(--step-0);
  line-height: 1.68;
  color: var(--ink-2);
}
.bul { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.85rem; }
.bul li {
  position: relative;
  padding-left: 1.4rem;
  font-size: var(--step-0);
  line-height: 1.68;
  color: var(--ink-2);
}
.bul li::before {
  content: '';
  position: absolute;
  left: 0.15rem; top: 0.72em;
  width: 5px; height: 5px;
  border-radius: 50%;
  background: var(--ink-ghost);
}
.bul li b, .bul__lead b, .lead__t b { color: var(--ink); font-weight: 600; }

/* --- the Figma frame ------------------------------------------------------
   20px radius, hairline border, soft shadow, generous padding. Hover lifts it
   a hair and deepens the shadow; the cursor becomes a zoom-in because clicking
   opens the lightbox. */

.blk-frame { margin-inline: 0; }

.fframe {
  /* Hug the artwork rather than fill the column. The image is capped at half its
     export width so it stays sharp, and a full-width frame turned the difference
     into empty white padding either side of it — the frame was sized by the column
     while its content was sized by the file.

     Left-aligned rather than centred, so the frame's edge lines up with the
     headings and body copy of this left-aligned column. */
  width: fit-content;
  max-width: 100%;
  position: relative;
  /* No card of its own. Every export already arrives as a framed, rounded artifact
     with transparent surrounds, so a white plate with padding, a radius and a
     shadow was a second frame drawn around the first. What remains is a positioning
     context for the annotations and a click target for the lightbox. */
  cursor: zoom-in;
  transition: transform 620ms var(--spring-soft);
}
/* the lift alone signals it opens — there is no plate left to deepen */
:root:not(.hover-cold) .fframe:hover {
  transform: translateY(-3px) scale(1.01);
}
.fframe:focus-visible { outline: 2px solid #7c3aed; outline-offset: 4px; }
/* Deliberately styles nothing. It used to clip to a 12px radius, which shaved the
   corners the artwork already has. Left as a plain wrapper rather than removed,
   because the renderer and the lightbox both expect the element. */

/* --- a waiting-for-artwork slot ------------------------------------------
   The frame and caption are the real ones, so the page already reads correctly
   at the right size. No zoom cursor and no lightbox, because there is nothing
   to enlarge yet — a placeholder that pretends to be clickable is worse than an
   obvious gap. */
/* A waiting slot still needs a visible box now that the frame draws nothing, so it
   carries the padding and radius the frame used to provide. */
.fframe--ph {
  cursor: default;
  padding: clamp(1.1rem, 2.6vw, 2rem);
  border-radius: 20px;
  background: repeating-linear-gradient(
    -45deg,
    var(--paper) 0 10px,
    #f2f0ec 10px 20px);
  box-shadow: inset 0 0 0 1px rgba(20, 16, 12, 0.09);
}
:root:not(.hover-cold) .fframe--ph:hover { transform: none; }

/* ---------------------------------------------------- before / after compare

   Two images in one box with a draggable seam. The geometry is one custom
   property, --split, written by JS from a hidden range input; everything
   visible reads it.

   THE CLIP, NOT TWO WIDTHS. Side B is clipped from the left edge of the seam
   rather than sized to the remaining space. Resizing a pane would rescale the
   image inside it, so the two halves would show the same subject at different
   sizes and the comparison would be meaningless — the whole point is that the
   pixel under the seam on one side is the same pixel on the other. Clipping
   moves the window; the image never moves.                                  */
.blk-cmp { width: fit-content; max-width: 100%; }

.cmp {
  position: relative;
  /* 630px is what a single screen occupies and is the sensible default. A
     panel showing a GRID of screens needs more, so `max` in content.js widens
     it through --cw rather than only capping it. */
  width: var(--cw, 39.375rem);
  max-width: 100%;
  aspect-ratio: var(--ratio, 1.45);
  border-radius: 12px;
  overflow: hidden;
  background: rgba(255, 255, 255, 0.72);
  box-shadow: inset 0 0 0 1.5px rgba(20, 16, 12, 0.10);
  /* the cursor is the affordance — the seam is thin and the whole frame is
     the control, which is not discoverable from the bar alone */
  cursor: ew-resize;
  touch-action: pan-y;              /* vertical scroll still belongs to the page */
  user-select: none;
}

.cmp__side { position: absolute; inset: 0; display: grid; place-items: stretch; }
.cmp__side--b { clip-path: inset(0 0 0 var(--split)); }

.cmp__img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* the placeholder standing in for an image that does not exist yet. It fills
   the pane rather than sitting in the middle of it, so the seam has something
   to cut through and the block can be dragged before either file arrives. */
.phbox--cmp {
  width: 100%;
  height: 100%;
  aspect-ratio: auto;
  border-radius: 0;
  box-shadow: none;
  align-content: center;
}
.cmp__side--a .phbox--cmp { background: rgba(20, 16, 12, 0.05); }
.cmp__side--b .phbox--cmp { background: rgba(20, 16, 12, 0.16); }

/* The seam. A hairline of the paper colour so it separates the two images
   whatever they are, and a knob big enough to be a touch target. Both are
   inert: the input underneath is what is actually being dragged, and a bar
   that swallowed the pointer would leave the rest of the frame dead. */
.cmp__bar {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--split);
  width: 2px;
  margin-left: -1px;
  background: var(--paper, #f7f6f3);
  box-shadow: 0 0 0 1px rgba(20, 16, 12, 0.18);
  pointer-events: none;
  z-index: 3;
}
.cmp__knob {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 2.25rem;
  height: 2.25rem;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: var(--ink, #14100c);
  box-shadow: 0 2px 10px -2px rgba(20, 16, 12, 0.45);
  transition: transform 320ms var(--spring-soft);
}
/* the arrows are drawn, not typed — a glyph would inherit the page's font and
   change shape with it */
.cmp__knob::before,
.cmp__knob::after {
  content: '';
  position: absolute;
  top: 50%;
  width: 0.3rem;
  height: 0.3rem;
  border: 1.5px solid var(--paper, #f7f6f3);
  border-width: 1.5px 1.5px 0 0;
}
.cmp__knob::before { left: 28%; transform: translateY(-50%) rotate(-135deg); }
.cmp__knob::after  { right: 28%; transform: translateY(-50%) rotate(45deg); }

:root:not(.hover-cold) .cmp:hover .cmp__knob { transform: translate(-50%, -50%) scale(1.08); }
.cmp__range:active ~ .cmp__bar .cmp__knob { transform: translate(-50%, -50%) scale(0.96); }

/* Corner labels, so the two sides are named rather than guessed at. They sit
   inside their own half and are clipped with it, which means each one
   disappears exactly when its image does. */
.cmp__tag {
  position: absolute;
  top: 0.75rem;
  z-index: 2;
  padding: 0.25rem 0.55rem;
  border-radius: 999px;
  font: 500 0.75rem/1 var(--mono, ui-monospace, monospace);
  letter-spacing: 0.02em;
  background: rgba(247, 246, 243, 0.92);
  color: var(--ink, #14100c);
  box-shadow: 0 1px 4px -1px rgba(20, 16, 12, 0.3);
  pointer-events: none;
}
.cmp__tag--a { left: 0.75rem; }
.cmp__tag--b { right: 0.75rem; }

/* THE CONTROL. Stretched over the whole frame and invisible: it owns every
   pointer, touch and key event, and the bar above is only a picture of its
   value. Both the track and the thumb are zeroed out — a native thumb is
   rendered above everything else in some engines and would sit on top of the
   knob it is supposed to be driving. */
.cmp__range {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  opacity: 0;
  cursor: ew-resize;
  z-index: 4;
  -webkit-appearance: none;
  appearance: none;
  background: transparent;
}
.cmp__range::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 2.25rem;
  height: 100%;
}
.cmp__range::-moz-range-thumb { width: 2.25rem; height: 100%; border: 0; }

/* keyboard reaches the frame through the input, so the ring belongs on the
   frame — the input itself is invisible and a ring on it would be too */
.cmp__range:focus-visible ~ .cmp__bar .cmp__knob {
  box-shadow: 0 0 0 3px var(--paper, #f7f6f3), 0 0 0 5.5px var(--ink, #14100c);
}

@media (prefers-reduced-motion: reduce) {
  .cmp__knob { transition: none; }
}

.phbox {
  /* An explicit width, because the frame is now shrink-to-fit and this box has no
     intrinsic one — only an aspect-ratio and two short labels. Left to itself it
     would collapse to the width of the word "Image". 630px is what the real shots
     occupy, so the page keeps reading at the size the artwork will land at. */
  width: 39.375rem;
  max-width: 100%;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: 0.5rem;
  aspect-ratio: var(--ratio, 1.7);
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.72);
  box-shadow: inset 0 0 0 1.5px rgba(20, 16, 12, 0.10);
  text-align: center;
  padding: 1.25rem;
}
.phbox__l {
  font-size: var(--step-0);
  font-weight: 600;
  color: var(--ink-2);
}
.phbox__p {
  font-family: var(--mono);
  font-size: 0.6875rem;
  letter-spacing: 0.01em;
  color: var(--ink-dim);
  background: #efece6;
  padding: 0.2rem 0.45rem;
  border-radius: 4px;
}

/* --- outcome metrics ------------------------------------------------------
   Four cards: a tinted icon chip, the number in its own colour, the sentence that
   explains it, and a sparkline. Each card carries --c (its accent) and --wash (its
   background), so the whole card is themed from two values in content.js rather
   than from four near-identical rules here. */
.blk-metrics { margin-top: var(--sp-5); }

.mgrid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 0.75rem;
}

.mcard {
  display: grid;
  /* the sparkline is pushed to the bottom edge, so cards of unequal text length
     still line their graphs up with each other */
  grid-template-rows: auto auto auto 1fr auto;
  gap: 0.55rem;
  padding: 1.1rem 1.1rem 1rem;
  border-radius: 14px;
  background: linear-gradient(180deg, var(--wash) 0%, rgba(255, 255, 255, 0) 78%);
  box-shadow: inset 0 0 0 1px rgba(20, 16, 12, 0.055);
}

.mcard__ico {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: 10px;
  background: var(--wash);
  box-shadow: inset 0 0 0 1px rgba(20, 16, 12, 0.05);
}
.mcard__ico svg { width: 16px; height: 16px; }

.mcard__v {
  font-size: var(--step-1);
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1;
  color: var(--c);
  /* the figure is counted up by JS, so every digit has to occupy the same box —
     proportional numerals make the whole number twitch as it runs */
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.mcard__l {
  font-size: var(--step-0);
  font-weight: 650;
  line-height: 1.3;
  color: var(--ink);
}

.mcard__b {
  margin: 0;
  font-size: var(--step--1);
  line-height: 1.5;
  color: var(--ink-dim);
}

/* --- the chart ------------------------------------------------------------
   The line is only one of five layers. Two rules and a baseline give the
   vertical distance a scale, and the gradient gives the area under the curve
   weight — without them a stroke on white reads as a squiggle, not a
   measurement. The baseline is the strongest of the three because it is the one
   the line is measured FROM; the rules above it are almost subliminal. */
.mcard__spark {
  display: block;
  width: 100%;
  height: auto;
  margin-top: 0.6rem;
  overflow: visible;   /* the end marker's halo sits proud of the viewBox */
}
.spark__rule,
.spark__base {
  stroke: var(--ink);
  stroke-width: 0.5;
  vector-effect: non-scaling-stroke;   /* hairlines stay hairlines at any width */
}
.spark__rule { opacity: 0.07; }
.spark__base { opacity: 0.16; }
.sec--dark .spark__rule,
.sec--dark .spark__base { stroke: #ffffff; }
.sec--dark .spark__rule { opacity: 0.09; }
.sec--dark .spark__base { opacity: 0.2; }

/* --- the metrics entrance -------------------------------------------------
   Cards arrive one after another; inside each, the line draws itself from left
   to right and the end dot lands once the line reaches it. The figure counts up
   over the same window (see Counters in site.js).

   `--md` is written by JS, not declared here, so the count and the drawing share
   one delay. The 0ms fallback below is what runs if that never happens — the
   animation degrades to "all at once", never to "invisible".

   Everything hangs off `.stag`, which only exists once Reveal has bound the
   section. A page that never binds it renders the cards plainly rather than
   leaving them stuck at zero opacity. */
.mcard { --md: 0ms; }

/* The cards own the entrance, so the block itself must not fade as well: two
   nested opacity ramps multiply, and the grid arrives washed out for the length
   of the longer one. */
.blk-metrics.stag { opacity: 1; transform: none; filter: none; }

.stag .mcard {
  opacity: 0;
  transform: translateY(16px);
  transition:
    opacity 560ms var(--ease) var(--md),
    transform 640ms var(--spring-soft) var(--md);
}
.sec.is-shown .stag .mcard { opacity: 1; transform: none; }

/* The chart builds in the order it would be drawn by hand: the ruling first,
   then the line across it, then the area filling in behind, then the marker on
   the last reading.

   Each layer is named. `.mcard__spark path` used to be enough when the line was
   the only path in the SVG — it now also matches the area fill, which would be
   handed the line's dash animation and vanish. */
.stag .spark__grid {
  opacity: 0;
  transition: opacity 420ms var(--ease) calc(var(--md) + 60ms);
}
/* pathLength="1" on the line makes these two values the whole of it, whatever
   its real length — so the timing is identical across all four cards even
   though their data traces different distances */
.stag .spark__line {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  transition: stroke-dashoffset 1000ms var(--ease) calc(var(--md) + 200ms);
}
.stag .spark__area {
  opacity: 0;
  transition: opacity 760ms var(--ease) calc(var(--md) + 420ms);
}
.stag .spark__halo,
.stag .spark__dot {
  opacity: 0;
  transition: opacity 240ms var(--ease) calc(var(--md) + 1200ms);
}
.sec.is-shown .stag .spark__grid { opacity: 1; }
.sec.is-shown .stag .spark__line { stroke-dashoffset: 0; }
.sec.is-shown .stag .spark__area { opacity: 1; }
.sec.is-shown .stag .spark__dot { opacity: 1; }
.sec.is-shown .stag .spark__halo { opacity: 0.14; }   /* back to its own value, not 1 */

/* --md-end is written by JS from the number of cards, so this lands after the
   last line finishes however many there are */
.stag .mcard__note {
  opacity: 0;
  transition: opacity 480ms var(--ease) var(--md-end, 1500ms);
}
.sec.is-shown .stag .mcard__note { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .stag .mcard,
  .stag .mcard__note,
  .stag .spark__grid,
  .stag .spark__area,
  .stag .spark__dot { opacity: 1; transform: none; transition: none; }
  .stag .spark__halo { opacity: 0.14; transition: none; }
  .stag .spark__line { stroke-dasharray: none; stroke-dashoffset: 0; transition: none; }
}

.mcard__note {
  margin-top: 0.85rem;
  font-size: var(--step--1);
  color: var(--ink-dim);
}

/* on the band the washes would glow, so they invert to quiet plates */
.sec--dark .mcard {
  background: #1b3543;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07);
}
.sec--dark .mcard__ico { background: rgba(255, 255, 255, 0.07); }
.sec--dark .mcard__l { color: #eaf1f6; }
.sec--dark .mcard__b, .sec--dark .mcard__note { color: #9fb3c0; }

@media (max-width: 60rem) {
  .mgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 34rem) {
  .mgrid { grid-template-columns: 1fr; }
}

/* --- the standalone chip --------------------------------------------------
   The same pill as the frame captions, but as a block of its own: it narrates the
   step BETWEEN two artifacts, so it must not read as a caption belonging to
   either. Centred, and it stretches with its text rather than being fixed. */
.blk-chip {
  margin-top: var(--sp-5);
  text-align: center;
}
/* ONE rule for every pill, whether it captions the image above it or narrates the
   step between two. They were two rule sets with different padding, size and
   letter-spacing, which is why they did not match. */
/* A box exactly as wide as the artwork, with the pill centred inside it. The
   caption used to be centred on the COLUMN while the frame hugged its artwork at
   the column's left edge — so the pill sat right of the picture and read as
   unrelated to it. --fw is the artwork's rendered width, written by FRAME(). */
.fframe__cap--pill {
  margin-top: 0.95rem;
  display: block;
  width: var(--fw, 100%);
  max-width: 100%;
  text-align: center;
}

.blk-chip span,
.fframe__cap--pill > span {
  display: inline-block;
  /* 100% of the column, not of the frame: the frame hugs its artwork, so a caption
     limited to that wrapped to two cramped centred lines while the same text in a
     chip sat on one. */
  max-width: 100%;
  margin-inline: auto;
  padding: 0.5rem 1.1rem;
  border-radius: 999px;
  background: #f0ece1;
  box-shadow: inset 0 0 0 1px rgba(20, 16, 12, 0.06);
  font-family: var(--mono);
  font-size: 0.6875rem;
  letter-spacing: 0.06em;
  line-height: 1.55;
  text-transform: uppercase;
  color: var(--ink-2);
  text-align: left;
}
.sec--dark .blk-chip span,
.sec--dark .fframe__cap--pill > span {
  background: rgba(255, 255, 255, 0.07);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09);
  color: #cfdae2;
}
/* it introduces the artifact under it, so it sits closer to that than to the one
   above — the same rule the headings follow */
.blk-chip + .blk-frame { margin-top: var(--sp-4); }

/* The figure stays left-aligned with everything else in the column; the caption
   centres itself within the artwork's width instead. */
.blk-frame:has(.fframe__cap--pill) { text-align: left; }

/* The pill variant is excluded here rather than overriding from its own rule: the
   two tie at one class each and this one sits LATER in the file, so the override
   silently lost and a long pill caption still wrapped. Fourth time this tie has
   come up in this stylesheet — exclusion in the selector is the fix that holds. */
.fframe__cap:not(.fframe__cap--pill) {
  margin-top: 0.95rem;
  /* Held to the artwork's own width where one is known, so a caption never runs
     wider than the thing it describes. */
  max-width: min(var(--fw, 100%), var(--measure), 45rem);
  font-size: var(--step--1);
  line-height: 1.6;
  color: var(--ink-dim);
}

/* --- design artifacts around the frame ------------------------------------
   Measurement labels, sticky notes and comments. Decoration, so pointer-events
   are off on the container; each one re-enables itself just enough to glow or
   rotate on hover. */

.anno {
  position: absolute;
  z-index: 3;
  max-width: 11rem;
  padding: 0.3rem 0.55rem;
  font-size: 0.6875rem;
  line-height: 1.35;
  pointer-events: auto;
  transition: transform 520ms var(--spring), box-shadow 300ms var(--ease),
              filter 300ms var(--ease);
}

/* a measurement label, like a Figma distance readout */
.anno--measure {
  border-radius: 4px;
  background: #7c3aed;
  color: #fff;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.01em;
}
:root:not(.hover-cold) .anno--measure:hover {
  filter: brightness(1.12);
  box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.16);
}

/* a sticky note — rotates a touch on hover, like nudging a real one */
.anno--note {
  border-radius: 2px;
  background: #f7e9a8;
  color: #4a3f16;
  box-shadow: 0 4px 12px -4px rgba(20, 16, 12, 0.22);
  rotate: -1.6deg;
}
:root:not(.hover-cold) .anno--note:hover { rotate: 1.4deg; transform: scale(1.03); }

/* a review comment, with the little tail a Figma comment pin has */
.anno--comment {
  border-radius: 10px 10px 10px 2px;
  background: #ffffff;
  color: var(--ink-2);
  box-shadow: inset 0 0 0 1px rgba(20, 16, 12, 0.08),
              0 6px 18px -8px rgba(20, 16, 12, 0.2);
}
:root:not(.hover-cold) .anno--comment:hover {
  box-shadow: inset 0 0 0 1px rgba(124, 58, 237, 0.35),
              0 10px 24px -10px rgba(124, 58, 237, 0.3);
}

@media (max-width: 52rem) { .anno { display: none; } }

/* --- the research board --------------------------------------------------- */

.board {
  padding: clamp(1.1rem, 2.6vw, 1.9rem);
  background: linear-gradient(150deg, #35205e 0%, #2a1a4c 55%, #241640 100%);
  display: grid;
  gap: clamp(1.5rem, 3.5vh, 2.4rem);
}
.bgroup { display: grid; gap: 0.7rem; justify-items: start; }
.bgroup__tab {
  padding: 0.3rem 0.75rem;
  border-radius: 8px;
  background: var(--tab, #4cc2f1);
  color: #10202b;
  font-size: var(--step--1);
  font-weight: 700;
  letter-spacing: -0.01em;
  box-shadow: 0 0 22px -6px var(--tab, #4cc2f1);
}
.bgroup__cards {
  margin: 0; padding: 0; list-style: none;
  width: 100%;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr));
  gap: 8px;
}
.bcard {
  padding: 0.75rem 0.85rem;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.10);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
.bcard__q { margin: 0; font-size: var(--step--1); line-height: 1.5; color: #f2eefc; }
.bcard__n {
  margin: 0.5rem 0 0;
  font-size: var(--step--1);
  line-height: 1.5;
  color: #b9a9dd;
}

/* --- persona cards -------------------------------------------------------- */

.pgrid {
  margin: 0; padding: 0; list-style: none;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr));
  gap: 10px;
}
.pcard {
  display: grid;
  gap: 0.75rem;
  align-content: start;
  padding: clamp(0.9rem, 2vw, 1.2rem);
  border-radius: 12px;
  background: var(--paper);
  box-shadow: inset 0 0 0 1px rgba(20, 16, 12, 0.055);
}
.pcard__top { display: flex; align-items: center; gap: 0.6rem; }
.pcard__av {
  display: grid; place-items: center;
  width: 30px; height: 30px;
  flex: none;
  border-radius: 50%;
  background: var(--av, #cbb8f9);
  font-size: var(--step--1);
  font-weight: 700;
  color: #2a2438;
}
.pcard__id { display: grid; line-height: 1.25; }
.pcard__id b { font-size: var(--step-0); font-weight: 600; }
.pcard__id i { font-size: var(--step--1); font-style: normal; color: var(--ink-dim); }
.pcard__q {
  margin: 0;
  padding: 0.6rem 0.7rem;
  border-radius: 6px;
  background: #efece6;
  font-family: var(--sans);
  font-size: 0.6875rem;
  line-height: 1.5;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--ink-2);
}
.pcard__w, .pcard__l {
  margin: 0;
  font-size: var(--step--1);
  line-height: 1.55;
  color: var(--ink-2);
}
.pcard__l {
  padding-top: 0.7rem;
  border-top: 1px solid var(--line-soft);
  color: var(--ink-dim);
}

/* --- staggered entrance ---------------------------------------------------
   Each section releases its own children in order, so a block arrives as one
   considered unit rather than the page animating all at once. */

.stag {
  opacity: 0;
  transform: translateY(14px);
  filter: blur(6px);
  transition:
    opacity 660ms var(--ease) calc(var(--stag, 0) * 90ms),
    transform 680ms var(--spring-soft) calc(var(--stag, 0) * 90ms),
    filter 620ms var(--ease) calc(var(--stag, 0) * 90ms);
}
.sec.is-shown .stag { opacity: 1; transform: none; filter: none; }

@media (prefers-reduced-motion: reduce) {
  .stag { opacity: 1; transform: none; filter: none; transition: none; }
}

/* --- the lightbox --------------------------------------------------------- */

.lbox {
  position: fixed;
  inset: 0;
  z-index: 120;                      /* above the dock (90) and the tooltip (95) */
  display: grid;
  place-items: center;
  /* full-bleed, so it has to clear both ends of the screen */
  padding: calc(clamp(1.5rem, 5vw, 4rem) + var(--sa-top))
           clamp(1.5rem, 5vw, 4rem)
           calc(clamp(1.5rem, 5vw, 4rem) + var(--sa-bottom));
  opacity: 0;
  pointer-events: none;
  transition: opacity 300ms var(--ease);
}
.lbox.is-on { opacity: 1; pointer-events: auto; }

/* the backdrop blurs rather than blacking out, so the page stays present */
.lbox__scrim {
  position: absolute;
  inset: 0;
  background: rgba(247, 246, 243, 0.62);
  backdrop-filter: blur(18px) saturate(1.1);
  -webkit-backdrop-filter: blur(18px) saturate(1.1);
  cursor: zoom-out;
}

.lbox__stage {
  position: relative;
  width: min(100%, 68rem);
  max-height: 100%;
  overflow: auto;
  transform: scale(0.94);
  opacity: 0;
  transition:
    transform 640ms var(--spring),
    opacity 320ms var(--ease);
}
.lbox.is-on .lbox__stage { transform: none; opacity: 1; }

/* the cloned frame inside the lightbox: no hover lift, no zoom cursor */
/* a shadow here would trace the image's rectangle rather than its artwork, which
   is visible wherever the export has transparent corners */
.lbox .fframe.is-zoomed {
  cursor: zoom-out;
  transform: none !important;
}
.lbox .anno { display: none; }        /* annotations belong on the page, not here */

body.is-lbox { overflow: hidden; }

@media (prefers-reduced-motion: reduce) {
  .lbox, .lbox__stage { transition-duration: 1ms; }
}

/* --- the pull-out callout ------------------------------------------------- */

.blk-call { max-width: var(--measure); margin-top: var(--sp-5); }
.blk-call p {
  margin: 0;
  padding-left: clamp(1rem, 2.5vw, 1.6rem);
  border-left: 2px solid var(--ink-ghost);
  /* was --step-1, which is a heading size — the rule and the darker ink are what
     mark this as a pull-out, not scale */
  font-size: var(--step-05);
  line-height: 1.62;
  letter-spacing: -0.004em;
  color: var(--ink-2);
}
.blk-call b { color: var(--ink); font-weight: 600; }

/* --- a supplied image in the premium frame -------------------------------- */

/* the recording sits in the same frame as the stills, so it matches them */
.shotvid {
  display: block;
  width: 100%;
  height: auto;
  margin-inline: auto;
  background: #f2f0ec;          /* while the poster loads */
}

.shotimg {
  display: block;
  width: 100%;
  height: auto;
  margin-inline: auto;
}

/* --- the NDA caution tape ------------------------------------------------
   Two independent strips laid across the benchmarking card. The back one passes
   behind it, the front one over its lower edge — a single tape can only be in
   front or behind, never both, and that contrast is what gives the depth.

   The artwork is Ishaan's own "Work under NDA" PNG, tiled with repeat-x. There
   is no HTML text and no font substitution anywhere in here: the type you see is
   his file, scaled by background-size so it never stretches.

   The scroll is a translate3d of exactly one tile period, which is why the loop
   is invisible: after moving one full tile the pattern is identical to where it
   started. --tile is derived from the tape height so the two stay in step at any
   size, mobile included. */

.ndawrap {
  position: relative;
  /* No padding of its own. This used to carry room for the strips, which added a
     third gap on top of the two section paddings — 220px after a taped block
     against 144px everywhere else, which is the unevenness that showed. The strips
     are positioned inside the wrapper now (13% / 19%) and it does not clip, so
     they need no reserved space; the frame spaces itself like any other. */
  padding-block: 0;
  /* deliberately NOT clipping: the strips must run past this column and across
     the whole page. .drawer__scroll does the clipping instead. */
  overflow: visible;
}

.ndawrap .blk-frame { position: relative; z-index: 1; }

.ndatape {
  --tape-h: 34px;
  /* Seconds for one tile period. Lower is faster, and Marquee reads it from here
     so the cadence lives with the rest of the tape's numbers rather than in JS. */
  --tape-secs: 10;
  /* tile is 1094 x 98, so one rendered tile is 11.1633 x the tape height */
  --tile-ratio: 11.1633;
  --tile: calc(var(--tape-h) * var(--tile-ratio));

  position: absolute;
  /* 128vw centred on this wrapper. The reading column is not centred in the
     window (the rail sits to its left), but 128vw is wide enough that the strip
     still covers the full viewport from either offset. */
  left: 50%;
  margin-left: -64vw;
  width: 128vw;
  height: var(--tape-h);
  overflow: hidden;
  background: #ffc700;
  box-shadow:
    inset 0 1px 0 rgba(20, 16, 12, 0.14),
    inset 0 -1px 0 rgba(20, 16, 12, 0.14),
    0 4px 14px -6px rgba(20, 16, 12, 0.32);
  pointer-events: auto;
  cursor: not-allowed;
  transition: filter 320ms var(--ease), box-shadow 320ms var(--ease);
}

/* Behind the card, higher, thinner. Anchored to the wrapper's top rather than a
   percentage so it always clears the card's upper edge — at 14% it sat entirely
   within the card's height and only its far ends showed, which is why it read as
   missing. */
/* The two strips sit nearer each other than they did — at 3.5% and 5% of the
   wrapper they were pinned to its extremes, and read as two unrelated bands with a
   void between them rather than as a pair crossing the same card. */
/* Both strips are the same thickness now — they read as one roll of tape laid
   twice rather than two different tapes. */
.ndatape--back {
  top: 13%;
  z-index: 0;
  rotate: -9deg;
}
/* over the card's lower edge, breaking the layout */
.ndatape--front {
  bottom: 19%;
  z-index: 2;
  rotate: 8deg;
}

.ndatape__lane {
  position: absolute;
  inset: 0;
  /* one extra tile of width, so there is always material to scroll in */
  width: calc(100% + var(--tile));
  background-image: url('../img/nda-tape-tile.png');
  background-repeat: repeat-x;
  background-size: auto 100%;          /* height drives it — never stretched */
  background-position: 0 50%;
  will-change: transform;
  /* the pre-JS resting position; Marquee drives it from here */
  transform: translate3d(calc(-1 * var(--tile)), 0, 0);
}

/* paper grain: a hair of noise so the strip reads as a physical thing rather
   than a flat rectangle. Kept very light — it should never be legible. */
.ndatape::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0.14;
  mix-blend-mode: multiply;
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='90' height='90'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='90' height='90' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E"),
    /* the faint lengthwise sheen of a taped-down strip */
    linear-gradient(180deg, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0) 38%,
                    rgba(20,16,12,0.06) 100%);
}

/* slowed, brighter, deeper shadow — never paused */
:root:not(.hover-cold) .ndatape:hover {
  filter: brightness(1.03);
  box-shadow:
    inset 0 1px 0 rgba(20, 16, 12, 0.14),
    inset 0 -1px 0 rgba(20, 16, 12, 0.14),
    0 10px 26px -8px rgba(20, 16, 12, 0.42);
}
@media (max-width: 46rem) {
  /* same angle, shorter strip — the overlap is the point, so it stays */
  .ndatape { --tape-h: 26px; }
}

@media (prefers-reduced-motion: reduce) {
  .ndatape__lane { transform: none; }
}

/* --- "defining the gap" cards --------------------------------------------
   Colours sampled from the recording: #142c3b, with one card at #143648. */

.blk__sub {
  font-size: var(--step-1);
  line-height: 1.25;
  letter-spacing: -0.018em;
  font-weight: 500;
  margin: 0 0 1.1rem;
}

.gapgrid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: 12px;
}

.gapc {
  padding: clamp(1.1rem, 2.4vw, 1.6rem);
  border-radius: 10px;
  background: #142c3b;
  color: #e8eef3;
  /* the label sits at the bottom of a tall card in the reference, which is why
     the text is pushed down rather than centred */
  display: grid;
  align-content: end;
  gap: 0.5rem;
  min-height: 9.5rem;
}
.gapc--lift { background: #143648; }

.gapc__t { margin: 0; font-size: var(--step-0); font-weight: 600; letter-spacing: -0.01em; }
.gapc__b { margin: 0; font-size: var(--step--1); line-height: 1.55; color: #b8c6d1; }
.gapc__b b { color: #ffffff; font-weight: 600; }

/* --- the highlights carousel --------------------------------------------
   Outer panel #1e303a with a centred eyebrow, a near-white card inside, and a
   dot pill at the bottom. The track is a flex row translated by whole slides:
   --at is the index, so one custom property drives the whole thing and the
   transition is a single transform. */

.hlwrap {
  padding: clamp(0.9rem, 2vw, 1.35rem);
  border-radius: 14px;
  background: #1e303a;
}

.hlwrap__eyebrow {
  display: block;
  text-align: center;
  font-size: 0.625rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: #8ba0ad;
  margin-bottom: clamp(0.75rem, 1.6vw, 1.1rem);
}

.hlcard {
  position: relative;
  border-radius: 10px;
  background: #fdfdfd;
  overflow: hidden;                  /* the track runs wider than this box */
}

.hltrack {
  display: flex;
  margin: 0;
  padding: 0;
  list-style: none;
  transform: translate3d(calc(var(--at, 0) * -100%), 0, 0);
  transition: transform 620ms var(--spring-soft);
}

.hl {
  flex: 0 0 100%;
  min-width: 0;
  display: grid;
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
  align-items: center;
  gap: clamp(1rem, 3vw, 2.5rem);
  padding: clamp(1.5rem, 3.4vw, 2.6rem) clamp(1.5rem, 3.4vw, 2.6rem) 3.25rem;
}
/* the first slide centres its copy above the shot, as the reference does */
.hl--stack {
  grid-template-columns: minmax(0, 1fr);
  justify-items: center;
  text-align: center;
}

.hl__t {
  margin: 0 0 0.6rem;
  font-size: var(--step-1);
  line-height: 1.22;
  letter-spacing: -0.02em;
  font-weight: 600;
  color: var(--ink);
}
.hl--stack .hl__t { max-width: 26rem; }

.hl__b {
  margin: 0;
  font-size: var(--step--1);
  line-height: 1.6;
  color: var(--ink-2);
}
.hl--stack .hl__b { max-width: 34rem; }
.hl__b b { font-weight: 600; color: var(--ink); }

.hl__shot { width: 100%; }
.hl__shot img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 8px;
  /* the exports are 892px wide, so never scale past that or they go soft */
  max-width: 446px;
  margin-inline: auto;
  box-shadow: 0 8px 26px -12px rgba(11, 22, 30, 0.35);
}

/* the dot pill */
.hldots {
  position: absolute;
  left: 50%;
  bottom: 0.85rem;
  translate: -50% 0;
  display: flex;
  gap: 6px;
  padding: 6px 9px;
  border-radius: 999px;
  background: rgba(20, 28, 34, 0.14);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}
.hl__dot {
  width: 7px; height: 7px;
  padding: 0;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.72);
  box-shadow: inset 0 0 0 1px rgba(20, 28, 34, 0.10);
  cursor: pointer;
  transition: background 220ms var(--ease), transform 380ms var(--spring);
}
.hl__dot.is-on { background: #ffffff; transform: scale(1.25); }
:root:not(.hover-cold) .hl__dot:hover { transform: scale(1.3); }

@media (max-width: 46rem) {
  .hl { grid-template-columns: minmax(0, 1fr); text-align: left; padding-bottom: 3rem; }
  .hl__shot img { max-width: 100%; }
}

@media (prefers-reduced-motion: reduce) {
  .hltrack { transition-duration: 1ms; }
}

/* --- the product ticker --------------------------------------------------
   Measured off the recording: panel #0f1c25, uniform card height with widths
   following each screen's own aspect, two rows running in opposite directions
   at ~100 css px/second.

   Each lane holds its card list twice and translates exactly -50%, so the
   second copy lands where the first was when the animation wraps and the seam
   is invisible. That is why the markup duplicates the cards rather than the CSS
   trying to be clever about it. */

.blk-ticker { margin-top: var(--sp-4); }

/* The strip stays inside the reading column, clipped at both edges, so the cards
   are cut off left and right and the motion reads as passing THROUGH a window.

   A full-bleed version was tried to fit more screens in and reverted: at 128vw the
   strip reached left under the sticky rail and covered the section links, and ran
   under the floating tool dock on the right. The clipped edges are the effect, not
   a limitation — how many screens fit is the card's job, not the strip's. */
.tick {
  display: grid;
  gap: 16px;
  padding: 22px 0;
  border-radius: 12px;
  background: #0f1c25;
  overflow: hidden;                  /* the lanes run far wider than this box */
}

.tick__row { overflow: hidden; }

.tick__lane {
  display: flex;
  gap: 16px;
  width: max-content;
  will-change: transform;
  /* the position is written by Marquee; --dir says which way this row runs */
  transform: translate3d(0, 0, 0);
}
.tick__row--b .tick__lane { --dir: -1; }

/* Nothing but the artwork. Each export already carries its own device frame with
   rounded corners and transparent surrounds, so a background plate, a radius and a
   drop shadow here were all chrome on top of chrome — the plate in particular
   showed as a dark rectangle behind every rounded frame.

   The encodes keep their alpha, so the transparent surrounds let the band show
   through. They were being flattened to RGB, which composited that transparency
   onto black and baked the rectangle into the file itself. */
.tick__card {
  flex: none;
  /* 240px tall is ~313px wide at the export's 1488x1140 aspect. The encode is
     1140px tall, so this is 4.75x of real detail — well past the 2x a retina
     screen asks for, which is why shrinking the card costs no sharpness at all.
     Card size trades against how many screens are on screen, not against
     resolution; those are separate knobs and only this one is a compromise. */
  height: 240px;
}
.tick__card img {
  display: block;
  height: 100%;
  width: auto;                       /* widths follow each screen's aspect */
}

/* Hovering slows the motion; it never stops. Driven from JS (see Marquee) rather
   than by swapping animation-duration: CSS derives an animation's progress from
   elapsed-time / duration, so changing the duration mid-flight relocates the
   phase instantly — the 30s -> 64s swap the tape used snapped it by up to a third
   of a period. An accumulated position cannot jump, whatever the speed does. */

/* Reduced motion keeps the panel as a static, scrollable strip rather than
   removing it — the screens are still worth seeing, they just stop moving. */
@media (prefers-reduced-motion: reduce) {
  /* Marquee checks this too and never starts; the strip stays static and
     scrollable, because the screens are still worth seeing. */
  .tick__row { overflow-x: auto; }
}

@media (max-width: 46rem) {
  .tick { padding: 14px 0; gap: 10px; }
  .tick__lane { gap: 10px; }
  .tick__card { height: 170px; }
}

.blk-tiles {
  columns: 7 5.5rem;
  column-gap: 0.4rem;
  max-height: 26rem;
  overflow: hidden;
  mask-image: linear-gradient(to bottom, #000 68%, transparent 100%);
  -webkit-mask-image: linear-gradient(to bottom, #000 68%, transparent 100%);
}
.blk-tiles i {
  display: block;
  margin-bottom: 0.4rem;
  border-radius: 2px;
  background: linear-gradient(var(--a), var(--c1), var(--c2));
}

/* the Role / Timeline / Team / Software row */
/* Back to a narrower track now that each chip holds one thing: the widest chip is
   a couple of words, so 11rem columns hold them without wrapping mid-chip. The
   13rem here was to stop a long comma-separated value wrapping inside its pill,
   which is no longer how the values are shaped. */
.blk-facts {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: var(--sp-5) 1.25rem;
  padding-top: var(--sp-5);
}
/* as the first block its own top margin already spaces it; the padding was for
   its old position at the foot of the section */
.blk--first.blk-facts { padding-top: 0; }
.blk-facts dt {
  font-size: var(--step-0);
  color: var(--ink-dim);
  margin-bottom: 0.5rem;
}
.blk-facts dd {
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
}
/* One chip per item. The pill used to wrap the whole value, so a value holding
   three things read as one long pill with commas in it — a list pretending to be
   a single item. */
.blk-facts dd > span {
  font-size: var(--step--1);
  color: var(--ink-2);
  background: rgba(20, 16, 12, 0.04);
  border-radius: 4px;
  padding: 0.3rem 0.5rem;
  max-width: 100%;
}

/* blocks ease in rather than appearing */
.blk, .sec__body, .sec__heading {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity 700ms var(--ease), transform 820ms var(--spring-soft);
}
.blk.is-in, .sec__body.is-in, .sec__heading.is-in { opacity: 1; transform: none; }

/* --- narrow: the rail lies down across the top --------------------------- */

@media (max-width: 70rem) {
  .proj__body { padding-left: 0; }
  .proj__col { width: min(53.875rem, calc(100% - var(--gut) * 2)); margin-inline: auto; }
  .rail {
    position: sticky;
    top: 0; left: 0;
    width: 100%;
    display: flex;
    align-items: center;
    gap: 1rem;
    padding: 0.7rem var(--gut);
    overflow-x: auto;
    background: rgba(247, 246, 243, 0.9);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    border-bottom: 1px solid var(--line-soft);
  }
  .rail__back { margin-bottom: 0; }
  .rail__list { display: flex; gap: 1rem; }
  .rail__link { height: auto; line-height: 1.6; white-space: nowrap; }
  .blk-tiles { columns: 4 4.5rem; max-height: 18rem; }
}

@media (prefers-reduced-motion: reduce) {
  .blk, .sec__body, .sec__heading { opacity: 1; transform: none; }
}

/* ==========================================================================
   24. FLOATING TOOL DOCK  —  built to the supplied spec
   92px wide, 32px radius, pure white, 1px rgba(0,0,0,.06) border,
   0 8px 30px rgba(0,0,0,.10) shadow, 24px from the right edge, centred.
   Padding 12px top / 12px sides / 20px bottom. Dividers 42px x 1px.
   ========================================================================== */

.tools {
  position: fixed;
  /* The dock's behaviour is untouched — this is only its distance from the
     screen edge, so it cannot end up under a rounded corner. Added to the
     measured 24px, so with no inset it is still exactly 24px.
     Named, because the collapsed handle has to undo it — see .tools__tab. */
  --dock-inset: calc(24px + var(--sa-right));
  right: var(--dock-inset);
  top: 50%;
  translate: 0 -50%;
  /* Order within this layer: drawer 80, ink 85, dock 90, click spark 96. The
     tooltip is no longer on this scale at all — it is a layer above the shell,
     for the reason given where the old `.tools .tip` block used to be. */
  z-index: 90;
}

/* --- the expanded panel -------------------------------------------------- */

.tools__panel {
  opacity: 0;
  transform: translateX(26px) scale(0.96);
  transform-origin: 100% 50%;
  pointer-events: none;
  visibility: hidden;
  transition:
    opacity 300ms var(--ease),
    transform 460ms var(--spring-soft),
    visibility 0s linear 460ms;
}

.tools.is-open .tools__panel {
  opacity: 1;
  transform: none;
  pointer-events: auto;
  visibility: visible;
  transition:
    opacity 260ms var(--ease),
    transform 460ms var(--spring-soft),
    visibility 0s;
}

/* drawing is only possible while the panel is out */
.tools:not(.is-open) ~ .ink { pointer-events: none !important; }

/* --- the collapsed tab --------------------------------------------------- */

.tools__tab {
  position: fixed;
  /* IT WAS NOT ACTUALLY DOCKED. `.tools` carries `translate: 0 -50%`, and a
     translate makes its element the containing block for anything fixed inside
     it exactly as a transform does — so `right: 0` here resolved to the right
     edge of the dock, not of the screen, and the handle sat 24px inside the
     content with the dock's own inset under it. It looked like a small floating
     button because that is what it was.

     Undoing the dock's inset puts it back on the frame. It stays a child of the
     rack on purpose: the panel grows out of this element's edge when it opens,
     and a handle mounted elsewhere would be a second object the drawer merely
     appears near. */
  right: calc(-1 * var(--dock-inset));
  top: 50%;
  translate: 0 -50%;
  /* The peek: 28px of a 92px dock, so two thirds of it is off the screen and
     what is left reads as an edge rather than as a button touching the frame. */
  width: 28px;
  height: 52px;
  display: grid;
  place-items: center;
  border: 1px solid rgba(0, 0, 0, 0.06);
  border-right: 0;
  border-radius: 14px 0 0 14px;
  background: #ffffff;
  color: #1c1c1e;
  box-shadow: -4px 4px 18px rgba(0, 0, 0, 0.10);
  cursor: pointer;
  /* it slides in from off-screen, so it never pops */
  opacity: 0;
  transform: translateX(34px);
  pointer-events: none;
  transition:
    opacity 260ms var(--ease),
    background-color 220ms var(--ease),
    transform 460ms var(--spring-soft),
    box-shadow 300ms var(--ease);
}

.tools.is-tab .tools__tab { opacity: 1; transform: none; pointer-events: auto; }

/* Hover offers the drawer without opening it: ten pixels out, a little more
   lift, and the surface comes up off paper-white to true white. Ten rather than
   the five it was — under about eight the movement reads as a wobble rather
   than as the thing coming to meet you, and past twelve it has committed to
   opening something it is not going to open. */
:root:not(.hover-cold) .tools.is-tab .tools__tab:hover {
  transform: translateX(-10px);
  background: #fff;
  box-shadow: -8px 6px 30px rgba(0, 0, 0, 0.16);
}
.tools__tab.is-press { transform: translateX(-7px) scale(0.97); transition-duration: 110ms; }
.tools__tab svg { width: 15px; height: 15px; }

/* the subtle glow that keeps it discoverable */
.tools__tab::before {
  content: '';
  position: absolute;
  inset: -6px -2px -6px -8px;
  border-radius: 18px 0 0 18px;
  background: radial-gradient(60% 60% at 100% 50%, rgba(124, 58, 237, 0.16), transparent 70%);
  opacity: 0;
  transition: opacity 500ms var(--ease);
  pointer-events: none;
}
.tools.is-tab .tools__tab::before { opacity: 1; animation: tabGlow 3.2s ease-in-out infinite; }
@keyframes tabGlow { 0%,100% { opacity: 0.55; } 50% { opacity: 1; } }

/* --- the welcome bubble and the shortcut flash --------------------------- */

.tools__say {
  position: absolute;
  right: calc(100% + 12px);
  top: 50%;
  translate: 0 -50%;
  max-width: 13rem;
  padding: 0.5rem 0.7rem;
  border-radius: 10px;
  background: #2c2c2e;
  color: #fff;
  font-size: 0.8125rem;
  line-height: 1.35;
  white-space: nowrap;
  box-shadow: 0 8px 22px -8px rgba(0, 0, 0, 0.4);
  opacity: 0;
  transform: translateX(8px) scale(0.96);
  pointer-events: none;
  transition: opacity 240ms var(--ease), transform 420ms var(--spring);
}
.tools__say.is-up { opacity: 1; transform: none; }

/* the shortcut confirmation sits a little above centre so it can't cover the
   tool you just picked */
.tools__flash { top: 34%; }

/* Straight off the Figma frame (48 x 286): radius 12, white fill, a 1px inside
   stroke in #E2E2E2, and a drop shadow of 0/4/8 at 8% black.

   The clip is NOT `overflow: hidden` on this element, and that is deliberate.
   A symmetric clip cuts both edges, so the moment the pencil slid out on hover
   its tip crossed the panel's LEFT edge and was deleted — the object appeared to
   shorten instead of emerge. The mask is one-sided instead, applied per object
   (see .tool--obj), cutting only at this panel's right edge and leaving the
   object free to protrude left into the page.

   The sticker drawer still had to move out to .tools, since it sits outside the
   panel entirely. */
/* MEASURED off the two supplied references, which agree to within a pixel (a 2x
   crop and a 2.33x spec sheet). Every offset below is from the panel's top edge.

   Items are PLACED, not flowed. A grid with one gap cannot produce these spacings —
   they are all different (16.5, 19.5, 24, 39.5, 10.5, 20, 26, 16) — and the earlier
   `gap: 10px` version could only approximate them. */
.tools__panel {
  --p-w: 48px;
  /* 327.6, up from 286 — one more slot at the pitch the lower group already
     uses. The bottom group was plus at 220.8 and undo at 262.4, so 41.6px
     apart; Builds takes 262.4 and undo moves to 304, and the 23.6px of air
     under the last chip is unchanged. Nothing is re-spaced and no other
     number in this block moves: the panel is one control taller, which is
     what adding a control honestly looks like. */
  --p-h: 327.6px;
  --y-cursor: 23.5px;   /* centre */
  --y-div1: 50px;
  --y-pencil: 85.7px;   /* centre */
  --y-note: 160px;      /* centre */
  --y-div2: 194px;
  /* named for the slot, not for the glyph in it — the glyph used to be a plus
     and is now a die-cut sticker, and the measurement did not change */
  --y-sticker: 220.8px; /* centre */
  --y-preset: 262.4px;  /* centre */
  --y-undo: 304px;      /* centre */
  --div-w: 32.5px;
  /* how far each object's near edge sits from the panel's inner left edge */
  --x-pencil: 10.5px;
  --x-note: 30.5px;

  position: relative;
  width: var(--p-w);
  height: var(--p-h);
  border-radius: 12px;
  background: #ffffff;
  /* inside stroke, so the 48px width stays exact */
  box-shadow:
    inset 0 0 0 1px #e2e2e2,
    0 4px 8px rgba(0, 0, 0, 0.08);
  /* objects protrude past the left edge, so nothing is clipped here */
  overflow: visible;
}

/* --- placement, all measured ---------------------------------------------- */
/* The objects are excluded here rather than overridden below: this selector is a
   class plus an attribute and .tool--obj is one class, so an override loses and the
   objects were being centred instead of spanning the inner width. */
/* `.tool--preset` is listed by class rather than by `data-tool-btn` on
   purpose: it opens a shelf instead of arming the pointer, so it must stay out
   of the mutually-exclusive tool state that attribute drives. */
.tools__panel > [data-tool-btn]:not(.tool--obj),
.tools__panel > .tool--preset,
.tools__panel > .tools__div {
  position: absolute;
  left: 50%;
  translate: -50% 0;
}
.tools__panel > [data-tool-btn='select'] { top: var(--y-cursor); margin-top: -16px; }
.tools__panel > [data-tool-btn='sticker'] { top: var(--y-sticker); margin-top: -16px; }
.tools__panel > .tool--preset { top: var(--y-preset); margin-top: -16px; }
.tools__panel > [data-tool-btn='undo'] { top: var(--y-undo); margin-top: -16px; }
.tools__panel > .tools__div:nth-of-type(1) { top: var(--y-div1); }
.tools__panel > .tools__div:nth-of-type(2) { top: var(--y-div2); }
/* the objects span the full inner width, so they are not centred on the panel */
.tools__panel > [data-tool-btn='marker'] { top: var(--y-pencil); }
.tools__panel > [data-tool-btn='note'] { top: var(--y-note); }

/* 32.5px wide (67% of the panel) and #E6E6E6 — both measured. It was 22px at 8%
   black, which read as a shorter, fainter line than the reference's. */
.tools__div {
  width: var(--div-w);
  height: 1px;
  background: #e6e6e6;
}

/* --- flat chips: move tool, plus, undo ----------------------------------- */

.tool {
  position: relative;
  display: grid;
  place-items: center;
  border: 0;
  background: transparent;
  color: #1c1c1e;
  border-radius: 9px;
  transition:
    background 200ms var(--ease),
    color 200ms var(--ease),
    box-shadow 240ms var(--ease),
    transform 140ms cubic-bezier(0.34, 1.46, 0.64, 1);
}

/* THE ICONS ESCAPE THE GLOBAL CAP.

   Section 0 sets `img, svg, video { max-width: 100% }`, and 100% of a chip is
   its CONTENT box — a <button> carries 1px 6px of UA padding, so inside a 30px
   chip that is 18px. Every icon here silently stopped growing at 18 no matter
   what width it was given, which is why the sticker and the undo would not take a
   larger size. The object tools were never affected: their art is absolutely
   positioned inside the 600px-wide slot, so 100% of that is no cap at all. */
.tool > svg { max-width: none; max-height: none; }

/* the move tool: 64x64, radius 16 */
[data-tool-btn='select'] { width: 32px; height: 32px; border-radius: 9px; }
/* 17.5, not 15: the arrow's own bbox is 9.4 x 11.3 of a 16 viewBox, so a 15px
   box drew it 8.8 wide where the recording has it 10.1. */
[data-tool-btn='select'] svg { width: 17.5px; height: 17.5px; }

/* Hover warms the chip, it does not lift it. The old rule scaled to 1.03 and
   translated -2px with a drop shadow, which on the filled purple Move chip read
   as a card floating off the panel. The press scale below is the only transform
   these chips get. */
:root:not(.hover-cold) .tool:hover { background: rgba(20, 16, 12, 0.055); }
:root:not(.hover-cold) [data-tool-btn='select'].is-on:hover {
  /* already purple — deepen it rather than wash the grey hover over the top */
  background: #6d28d9;
}
.tool.is-press { transform: scale(0.96); transition-duration: 90ms; }

/* Armed is the filled purple chip with a white arrow, which is what the
   reference frame shows. It reads differently from the pale plate behind the
   pencil and the note ON PURPOSE: those two plates are a PREVIEW of a thing
   about to be placed on the page, and Move places nothing. One is "here is what
   you are holding", the other is "this mode is on". Flattening them into one
   lavender treatment lost that, and lost the toolbar's only bit of colour with
   it. */
[data-tool-btn='select'].is-on {
  background: #7c3aed;
  color: #fff;
}

/* the sticker chip: 48x48 touch area, purple disc fading in behind the glyph */
[data-tool-btn='sticker'] {
  width: 30px; height: 30px;
  border-radius: 50%;
}
[data-tool-btn='sticker'] svg { width: 19.5px; height: 19.5px; }
:root:not(.hover-cold) [data-tool-btn='sticker']:hover { background: #7c3aed; color: #fff; }
[data-tool-btn='sticker'].is-press { transform: scale(0.94); }
.tools.has-pad [data-tool-btn='sticker'] {
  background: #7c3aed;
  color: #fff;
  box-shadow: 0 6px 18px -6px rgba(124, 58, 237, 0.55);
}

/* Builds. Same 30px disc, same hover wash and same press scale as the sticker
   and the undo it sits between — the only chip in the column that is neither a tool
   nor an action, and it takes the shape of both. `is-on` while its shelf is
   open, using the pale plate the object tools use rather than the filled purple
   Move wears, because an open drawer is not an armed pointer. */
.tool--preset { width: 30px; height: 30px; border-radius: 50%; }
.tool--preset svg { width: 19.5px; height: 19.5px; }
:root:not(.hover-cold) .tool--preset:hover { background: rgba(20, 16, 12, 0.055); }
.tool--preset.is-press { transform: scale(0.94); }
.tool--preset.is-on { background: #f0ebfd; }

/* undo: rotates a touch on hover, background fades in */
.tool--undo { width: 30px; height: 30px; border-radius: 50%; }
/* 21.5px. The ring fills 12 of its 16 viewBox units, so the box has to run well
   past the 17.5px the drawn glyph measures in the recording. Its stroke is
   thinned to 1.1 in the art to hold the line weight — see TOOL_ICON.undo. */
.tool--undo svg { width: 21.1px; height: 21.1px; transition: transform 320ms var(--spring); }
:root:not(.hover-cold) .tool--undo:hover { background: rgba(0, 0, 0, 0.05); }
:root:not(.hover-cold) .tool--undo:hover svg { transform: rotate(-22deg); }
.tool--undo.is-press { transform: scale(0.94); }

/* --- the physical tools: pencil and sticky note -------------------------- */

/* Spans the panel's full 48px width (36px column + the 6px padding on each
   side, cancelled with negative margins) so the lavender plate reaches the
   panel edges and the mask lines up with them. */
/* The object's row spans the panel's inner width and is centred on its measured
   y. --vis is how much of the object shows at rest, measured from its near edge;
   the far end runs past the panel's right edge and the one-sided mask cuts it. */
.tool--obj {
  position: absolute;
  left: 1px;
  right: 1px;
  width: auto;
  height: var(--oh, 24px);
  margin-top: calc(var(--oh, 24px) / -2);
  translate: 0 0;
  border-radius: 0;
  background: transparent;
  box-shadow: none;
}

/* The drawer mouth.

   This was `clip-path: inset(-40px -6px -40px -600px)` on the button, which was
   wrong: negative offsets in inset() are not reliably supported, and when the
   declaration is dropped the mask disappears entirely — which is why the pen and
   the note vanished. A plain overflow box is universally supported.

   The trick is where its edges sit: the right edge lands on the panel edge and
   does the cutting, while the left, top and bottom edges are pushed so far out
   they can never clip anything. So it masks on one side only, and the object is
   free to protrude left as it slides out. */
.tool__slot {
  position: absolute;
  right: 0;
  left: -600px;
  top: -60px;
  bottom: -60px;
  overflow: hidden;
  pointer-events: none;
}
/* No transform and no shadow on the object buttons — the object itself slides,
   and scaling the plate underneath it fought that motion. */
:root:not(.hover-cold) .tool--obj:hover,
.tool--obj.is-on,
.tool--obj.is-press { transform: none !important; box-shadow: none !important; }

/* THE PREVIEW PLATE.

   `.tool__plate` is in the markup — see `slot()` in site.js — but it had no rule
   here at all, so it rendered as a bare inline span and the lavender fell back
   onto the BUTTON. That is the bug you could see: a button in this row is not a
   chip, it is a full-width, square-cornered strip as tall as the object, so the
   tint came out as a purple band bleeding the whole way across the panel and
   past its rounded corners. The plate is a separate element precisely so the
   tint can be inset and rounded while the button stays the full hit area.

   MEASURED OFF THE RECORDING, frame by frame, at 2.1875x. The plate is a FIXED
   rectangle — it is not sized from the object it sits behind:

       38px wide, inset 5px from each of the panel's outer edges
       64px tall, centred on the tool's own y
       radius ~9px, fill #F4E8FD

   Sizing it from the object was the "cramped" look: the pencil's art is only
   24px tall, so `inset: -3px` gave it a 30px plate — a thin lozenge tight
   around the pencil instead of the roomy square the recording shows. The note's
   art is 46.5px, so the same rule gave IT a 52px plate. Two different plates
   for what is one repeated shape.

   AND IT IS ARMED-ONLY. On hover the recording shows the object slide out over
   plain white, no plate at all. The plate is what says "this tool is the live
   one", so putting it under the pointer as well made hover and armed say the
   same thing. */
.tool__plate {
  position: absolute;
  top: 50%;
  left: 4px;    /* the button is already inset 1px from the panel, so 5px total */
  right: 4px;
  height: 64px;
  margin-top: -32px;
  border-radius: 9px;
  background: transparent;
  transition: background-color 220ms var(--ease);
  pointer-events: none;
}
.tool--obj.is-on .tool__plate { background: #f4e8fd; }

.tool--obj svg {
  position: absolute;
  top: 50%;
  /* Placed by its NEAR edge: the visible run starts --vis in from the panel's right
     edge, so the whole object sits at (width - vis) to the left of that edge. The
     far end therefore overhangs and the mask cuts it. */
  right: calc(var(--vis, 36px) - var(--ow, 120px));
  width: var(--ow, 120px);
  height: auto;
  translate: 0 -50%;
  transform-origin: 82% 50%;
  filter: drop-shadow(0 4px 7px rgba(0, 0, 0, 0.18));
  transition: transform 620ms var(--spring), filter 380ms var(--ease);
  pointer-events: none;
}

/* THE DRAWER OPENING. Outward is negative X because the panel is pinned to the
   right edge, so the object emerges leftward into the page.

   THE SLIDE BELONGS TO THE POINTER, NOT TO THE STATE. Tracking the pencil's nib
   across the recording:

       rest ................ tip 36.1px in from the panel's right edge
       hover ............... 49.4px   — slid out 13
       armed, not hovered .. 36.1px   — back home, exactly where it started
       armed AND hovered ... 53.0px   — slid out 17

   So arming does not pull the object out; only the pointer does. The old rule
   had arming slide it 18px and hold it there, which left the pencil jutting out
   of a panel nobody was pointing at — and, with the tight plate, sitting half
   off its own highlight. Arming is the plate alone now.

   Pure translation, no tilt. An earlier version rotated -6deg on arming, which
   on a 94px pencil swung the far end visibly and broke the illusion that it is
   sliding along a slot. */
:root:not(.hover-cold) .tool--obj:hover svg { transform: translateX(-13px); }
.tool--obj.is-on svg {
  transform: none;
  filter: drop-shadow(0 6px 11px rgba(0, 0, 0, 0.2));
}
/* the pointer is what pulls it out, so an armed tool under the pointer goes
   further than an unarmed one — it is already the live tool */
:root:not(.hover-cold) .tool--obj.is-on:hover svg {
  transform: translateX(-17px);
  filter: drop-shadow(0 8px 14px rgba(0, 0, 0, 0.24));
}
.tool--obj.is-press svg { transform: translateX(-7px) rotate(-3deg) scale(0.98); transition-duration: 120ms; }

/* The note used to carry `background: #f0ebfd` here permanently. On a chip that
   would have been the soft plate it was named for; on this button it was the
   full-width band again, painted at rest, which is what made the note look like
   a spill rather than a sticky note in a slot. It gets the same plate as the
   pencil now, and the same nothing at rest. */

/* --- cursors ------------------------------------------------------------- */

/* A real pen nib rather than a crosshair, hotspot on the tip at (3,21).
   The white halo underneath is what keeps it legible on both the light paper
   and a dark panel — a bare grey pen disappears against either. */
body[data-tool='marker'],
body[data-tool='marker'] * {
  cursor:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath d='M3 21 L6.6 13 L13.6 6 L18 10.4 L11 17.4 Z' fill='none' stroke='%23ffffff' stroke-width='3.4' stroke-linejoin='round' stroke-linecap='round'/%3E%3Cpath d='M6.6 13 L13.6 6 L18 10.4 L11 17.4 Z' fill='%23e9e9ee' stroke='%233a3a42' stroke-width='1.1' stroke-linejoin='round'/%3E%3Cpath d='M3 21 L6.6 13 L11 17.4 Z' fill='%231a1a20'/%3E%3Cpath d='M8.8 12.3 L14.5 7' stroke='%23ffffff' stroke-width='1.2' stroke-linecap='round' opacity='.85'/%3E%3C/svg%3E")
    3 21,
    crosshair;
}
body[data-tool='note'],
body[data-tool='note'] * { cursor: copy; }

/* The sticker tool is a hand, not a crosshair: open while you are choosing a
   spot, closed the moment you press. `grab`/`grabbing` are the native hand
   cursors, so they match the platform and match .drg, which already uses them
   for the objects on the canvas.

   These live here rather than in a later block on purpose — a duplicate rule
   further down the file used to override them with `crosshair`, which is where
   the `+` came from. Same bug the pen tool had. */
body[data-tool='sticker'],
body[data-tool='sticker'] * { cursor: grab; }
body.is-holding[data-tool='sticker'],
body.is-holding[data-tool='sticker'] * { cursor: grabbing; }
body[data-tool='marker'] { user-select: none; -webkit-user-select: none; }

/* --- the ink layer ------------------------------------------------------- */

/* Two layers, both anchored to the top of the viewport and moved by transform as
   the page scrolls. `inset: 0` is gone: the canvas is TALLER than the viewport now
   (a band with padding above and below), so it must not be stretched to fit.

   pointer-events is none and stays none. It used to flip to `auto` while the pen
   was armed, which laid a full-viewport surface over the page and took the wheel,
   the trackpad and the scrollbar with it. Input is read from the page instead. */
.ink {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 85;                        /* over the drawer, under the dock */
  pointer-events: none;
  /* the transform is written per frame; promoting the layer keeps the scroll
     movement on the compositor rather than repainting it */
  will-change: transform;
  opacity: 1;
  transition: opacity 420ms var(--ease);
}
.ink--live { z-index: 86; }           /* the wet stroke sits over the dry ones */

/* Inside the hero the ink is a layer of the canvas rather than a sheet over the
   page — see Ink.mountOn in site.js. Above the dot grid at 0, below everything
   you can pick up: the headline at 2, the tags and stickers at 3, whatever is
   in your hand at 40. Draw a line, drag a sticker over it, and the sticker
   passes in front the way it would in a real file. */
.canvas > .ink.ink--base { z-index: 1; }
.canvas > .ink.ink--live { z-index: 1; }   /* over .ink--base by DOM order */

/* Absolute, not fixed — `.sheet` carries a transform and so is the containing
   block for anything fixed inside it, which would make `top: 0` mean the top of
   the document instead of the top of the viewport. Ink.recentre() knows which
   of the two it is measuring against. */
.ink.ink--inline { position: absolute; }
/* Deliberately NOT `pointer-events: auto`. Arming the pen used to cover the page
   with a live surface, which is why scrolling stopped working in draw mode. The
   canvas never takes pointer events; Ink listens on the page and only begins a
   stroke on a primary mouse or stylus press. */
body[data-tool='marker'] .ink { pointer-events: none; }
body.past-hero .ink { opacity: 0; pointer-events: none !important; }
body.past-hero .sel { opacity: 0; }

/* --- tooltips: to the LEFT of the dock ----------------------------------
   Nothing here any more, and this note is why. There was a `.tools .tip` block
   in this spot that set the label to `translate: -100% -50%` at `z-index: 95`,
   which is exactly the behaviour wanted — and it never applied to a single
   element, because the tooltip is one shared node mounted by the shell and has
   never been a descendant of `.tools`. A rule that cannot match is worse than
   no rule: it reads as the problem already being solved.

   It cannot be made to match, either, by moving the label inside the dock.
   `.tools` carries `translate: 0 -50%`, and a translate makes its element the
   containing block for fixed descendants and a stacking context for all of
   them — the label would be clipped to the dock's own paint order again, which
   is the bug. So placement lives with the label, in section 15c: `.tips` is a
   layer above the entire shell, and `[data-side='left']` is set on the label by
   whichever anchor it is serving. The dock is styled here; its label is not. */

@media (max-width: 46rem) {
  .tools { right: 12px; }
  .tools__panel { width: 44px; padding: 5px 5px 9px; border-radius: 16px; }
  [data-tool-btn='select'] { width: 30px; height: 30px; }
}

/* ==========================================================================
   25. CASE STUDY INSIDE THE DRAWER
   The same content as project.html, laid out for the narrower panel: the rail
   sticks to the top of the drawer's own scroll container.
   ========================================================================== */

/* The content column takes whatever width is going rather than being pinned to
   44rem, so media blocks — the tile field, the three-across rows, the facts grid
   — get the room they were always short of.

   The prose does NOT widen with it: .sec__body caps at 44rem, .sec__heading at
   34rem and .blk-quote at 40rem, so the measure stays readable while only the
   media stretches. Widening the column without those caps would have pushed
   body copy past 1000px, which is unreadable. */
.drawer__study {
  display: grid;
  grid-template-columns: 8.5rem minmax(0, 1fr);
  gap: clamp(2.5rem, 4vw, 4.5rem);
  width: min(100%, 84rem);
  margin-inline: auto;
}

.drawer__rail {
  position: sticky;
  top: 0;
  align-self: start;
  padding-top: 0.2rem;
  font-size: 0.8125rem;
  line-height: 1.3;
}

.drawer__rail .rail__link { height: 2.25rem; line-height: 2.25rem; }

/* The dark head carries the band right up to the first section, so any bottom
   margin here opens a white strip between the two bands. Excluding it in the
   selector rather than overriding it later keeps this order-independent — an
   override would tie at two classes and lose on source order. */
.drawer__study .proj__head:not(.proj__head--dark) {
  margin-bottom: var(--sp-6);
}
.drawer__study .proj__title { font-size: var(--step-2); }
.drawer__study .sec { padding-block: var(--sp-6); }

/* --- the scroll anchor ----------------------------------------------------
   One per section, zero-height, immediately before the heading. Every rail click
   scrolls to one of these, so the HEADING lands in the same place every time —
   scrolling to the section box could not do that, because what sits between the
   section's top and its heading differs (Research has a preamble; the others do
   not), and that difference landed one section near the top and another mid-screen.

   The landing allowance lives here, in the layout, as scroll-margin-top: the space
   above the heading plus one line for the eyebrow that sits in it. SectionNav reads
   this value rather than holding a number, so the same figure decides where a click
   lands AND where the active state flips — the reading line and the landing line
   are the same line. */
.sec__anchor {
  display: block;
  height: 0;
  scroll-margin-top: var(--nav-land);
}
.drawer__study .sec:first-of-type { padding-top: 0; }
.drawer__study .blk-tiles { columns: 5 4.5rem; max-height: 18rem; }
.drawer__study .blk-row__grid { gap: 0.6rem; }

@media (max-width: 60rem) {
  .drawer__study { grid-template-columns: 1fr; gap: 1rem; width: auto; }
  .drawer__rail { position: static; display: flex; flex-wrap: wrap; gap: 0.85rem; }
  .drawer__rail .rail__link { height: auto; line-height: 1.5; }
}

/* ==========================================================================
   26. HERO CANVAS  (the Figma file)
   Measured off the reference: 24px dot grid at rgb(205,203,204) on near-white,
   headline flush left at x28, ~28px pills beneath it. Everything placed on the
   canvas is draggable.
   ========================================================================== */

.canvas {
  position: relative;
  min-height: 100svh;
  overflow: hidden;
  background: #fbfafa;
  isolation: isolate;
  touch-action: pan-y;
}

/* the infinite dotted grid — one tiled gradient, offset by a parallax variable */
.canvas__dots {
  position: absolute;
  inset: -12vmax;                    /* oversized so parallax never exposes an edge */
  z-index: 0;
  background-image: var(--dots);
  background-size: var(--dots-step);
  transform: translate3d(var(--px, 0px), var(--py, 0px), 0);
  will-change: transform;
  pointer-events: none;
}

/* --- the stickers --------------------------------------------------------
   React Bits' StickerPeel, ported. The peel is entirely here — no script runs
   during the animation, it's a transition on `clip-path`.

   HOW THE FOLD WORKS. The sticker is drawn twice. `.sticker-main` is the whole
   image with its top strip clipped away; `.flap` is the same image flipped
   with `scaleY(-1)`, clipped to only that strip, and parked directly above so
   the two meet exactly on the cut line. Move the cut down and the flap grows
   downward from the same line — the top of the sticker folding onto itself.
   Both clip-paths animate to the same percentage, which is why they stay
   welded at the fold instead of drifting apart.

   The padding variable pushes both clip regions out past the element's box, so
   the drop shadow has somewhere to live and isn't sliced off at the edge.   */

.peel {
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;    /* the layer is a container; the stickers are live */
}

.peel__defs { position: absolute; width: 0; height: 0; }

/* The wrapper is a .drg, so the site's own drag owns position, the resting
   angle, the lean into a throw, selection and delete. Only sizing here.

   NOT `.stk`. That name is already taken, by the pixel-art stickers you place
   from the "+" drawer — and it carries `width: 3rem; height: 3rem` with
   `object-fit: contain` on its images. Reusing it squeezed every one of these
   into a 48px box and pixellated it, which looked like a sizing bug in this
   file and was really a name collision three thousand lines away. */
/* The wrapper stays exactly the size of the artwork, whatever angle the fold
   comes off at. Placement, the drag box and the selection outline are all
   measured off it, so it is the one box that must not grow when a sticker peels
   from a corner. */
.peelstk {
  --sticker-p: 10px;
  --sticker-start: calc(-1 * var(--sticker-p));
  --sticker-end: calc(100% + var(--sticker-p));
  position: absolute;
  width: var(--sticker-width, 200px);
  height: var(--sticker-art-h, auto);
  pointer-events: auto;
  line-height: 0;          /* no descender gap under the image */
}

/* Which edge peels. The container turns, and everything inside turns back — so
   the fold always comes off the container's top, and this decides where "top"
   is without the artwork ever looking rotated.

   WHY THE BOX IS ITS OWN SIZE. Turning the container does not turn its box, and
   the artwork inside is turned back, so on anything that isn't square the two
   stop agreeing. The F1 car is 250x99: rotate its container 90° for a
   right-edge peel and the box on screen becomes 99 wide by 250 tall while the
   car is still drawn 250 by 99, so the car hangs out of both sides and the grey
   adhesive flap — sized to the box — lands across the middle of it instead of
   along its edge. It read as the sticker being eaten rather than lifted.

   So the container gets the artwork's bounding box *at that angle*, written by
   place() as --sticker-box-w/h: w·|cos| + h·|sin| across, w·|sin| + h·|cos|
   down. Rotated back by the same angle it is exactly the artwork's footprint,
   at 0° it collapses to the old w x h, and the fold percentages now measure
   along the axis the fold actually travels. The box is centred in the wrapper
   and allowed to overflow it, which is why the wrapper above can stay the size
   of the picture. */
.sticker-container {
  position: absolute;
  left: 50%;
  top: 50%;
  width: var(--sticker-box-w, 100%);
  height: var(--sticker-box-h, 100%);
  transform: translate(-50%, -50%) rotate(var(--peel-direction, 0deg));
  transform-origin: center;
}

.sticker-container * {
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}

.sticker-main {
  position: relative;
  width: 100%;
  height: 100%;
  clip-path: polygon(
    var(--sticker-start) var(--sticker-start),
    var(--sticker-end) var(--sticker-start),
    var(--sticker-end) var(--sticker-end),
    var(--sticker-start) var(--sticker-end));
  transition: clip-path 600ms ease-out;
}

/* The artwork turns back out of the container's rotation, and sits centred in
   it — the box is bigger than the picture at every angle but 0 and 180, so
   without the centring the picture would sit in its top-left corner. */
.sticker-main > *,
.flap > * {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%) rotate(calc(-1 * var(--peel-direction, 0deg)));
}

.flap {
  position: absolute;
  left: 0;
  top: calc(-100% - var(--sticker-p) - var(--sticker-p));
  width: 100%;
  height: 100%;
  /* flipped, so the folded part is the sticker's own back rather than a second
     copy of its front */
  transform: scaleY(-1);
  clip-path: polygon(
    var(--sticker-start) var(--sticker-start),
    var(--sticker-end) var(--sticker-start),
    var(--sticker-end) var(--sticker-start),
    var(--sticker-start) var(--sticker-start));
  transition: clip-path 600ms ease-out, top 600ms ease-out;
}

/* Hover opens the fold; press opens it further — but only while the sticker is
   an ordinary object on the canvas. The moment it is selected or in your hand
   the peel stops: it is a hover affordance, and hover affordances have no business
   firing once you are past hover and into editing. A sticker folding itself open
   behind its own selection box read as two states arguing. */
.drg:not(.is-sel):not(.is-drag) .sticker-container:hover .sticker-main,
.sticker-container.touch-active .sticker-main {
  clip-path: polygon(
    var(--sticker-start) var(--sticker-peelback-hover),
    var(--sticker-end) var(--sticker-peelback-hover),
    var(--sticker-end) var(--sticker-end),
    var(--sticker-start) var(--sticker-end));
}
.drg:not(.is-sel):not(.is-drag) .sticker-container:hover .flap,
.sticker-container.touch-active .flap {
  clip-path: polygon(
    var(--sticker-start) var(--sticker-start),
    var(--sticker-end) var(--sticker-start),
    var(--sticker-end) var(--sticker-peelback-hover),
    var(--sticker-start) var(--sticker-peelback-hover));
  /* the 1px keeps a hairline of overlap on the fold, so no seam shows through */
  top: calc(-100% + 2 * var(--sticker-peelback-hover) - 1px);
}

.drg:not(.is-sel):not(.is-drag) .sticker-container:active .sticker-main {
  clip-path: polygon(
    var(--sticker-start) var(--sticker-peelback-active),
    var(--sticker-end) var(--sticker-peelback-active),
    var(--sticker-end) var(--sticker-end),
    var(--sticker-start) var(--sticker-end));
}
.drg:not(.is-sel):not(.is-drag) .sticker-container:active .flap {
  clip-path: polygon(
    var(--sticker-start) var(--sticker-start),
    var(--sticker-end) var(--sticker-start),
    var(--sticker-end) var(--sticker-peelback-active),
    var(--sticker-start) var(--sticker-peelback-active));
  top: calc(-100% + 2 * var(--sticker-peelback-active) - 1px);
}

.sticker-image,
.flap-image {
  display: block;
  width: var(--sticker-width, 200px);
  height: auto;
  /* The reset at the top of this file caps every image at 100% of its
     container. The container here is the rotated peel box, which is narrower
     than the artwork whenever the fold comes off a side — 99px against a 250px
     car at 90° — so the cap shrank the picture to a third of its size and left
     the box around it empty. The width above is deliberate and already scaled;
     nothing should be clamping it. */
  max-width: none;
  -webkit-user-drag: none;
}

/* The cut-outs are product shots on white, so they arrive a stop flat against a
   near-white page — the die-cut edge in particular dissolves into #fbfafa. A
   small saturate/contrast lift puts the colour back without tipping into the
   oversaturated look, and taking a sliver off brightness keeps the white border
   reading as paper rather than as more background.

   Not on `.flap-image`: that one carries `filter:url(#fill-…)` inline, which
   floods its silhouette flat grey for the adhesive back. An inline filter would
   win over this rule anyway, but the fold should stay grey regardless. */
.sticker-image {
  filter: saturate(1.16) contrast(1.08) brightness(0.98);
}

/* both are rewritten on every frame of the fold */
.sticker-main, .flap { will-change: clip-path; }

@media (prefers-reduced-motion: reduce) {
  /* the fold still happens — it answers the cursor rather than playing on its
     own — but it arrives rather than travelling */
  .sticker-main, .flap { transition-duration: 1ms; }
}

/* --- the introduction --------------------------------------------------- */

/* Top-left, but standing well off both edges — the original sat flush at 28px
   and read as a page header rather than as something placed on a canvas. The
   inset is the point: roughly 100px down and 100px in at a laptop width, easing
   to the plain gutter on a phone where there is nothing to spare.

   ABSOLUTE, NOT FLOW. `.canvas` is already `min-height: 100svh`, so lifting the
   intro out of flow costs no height, and it keeps the block's position a
   property of the viewport rather than of the hint row and keycap legend that
   follow it.

   WIDTH IS fit-content ON PURPOSE. Peel.place() measures this box as the one
   region a sticker may never cover. As a full 44rem column that no-go zone ran
   several hundred px past the last word, and stickers placed at the percentages
   in content.js got shoved out of the arrangement by a collision with empty
   space. Hugging the content means the safe zone is the text itself. */
/* THE CORNER, MEASURED. The reference plants the whole intro block in the very
   top-left corner — text-box top 26px, left 23px in a 1470x836 window, derived
   by taking the headline's ink top/left off the 2x capture (31.5 / 25.5) and
   backing out the line box's own 5.7px / 2.6px ink offsets.

   This used to be top ~104px, left ~112px: a hero floating in a generous
   margin. The reference has no margin at all, and that is most of why it reads
   as a canvas with something lying on it rather than as a centred composition.

   Kept as clamps so the corner does not go slack on a huge monitor or crush on
   a small one, but both land on the measured value at desktop. The 48rem query
   below overrides all of this for phones, where the intro moves to the bottom. */
/* --- THE COLUMN IS PLACED, NOT PARKED ---------------------------------------

   It used to sit 20px from the top-left corner of the viewport, which is where
   you put a thing when you have not decided where it goes: the sentence ran
   along the very top edge with no air above it, the whole middle of the canvas
   was empty, and the only thing giving the page a composition was wherever the
   bricks happened to land.

   Now it is set in from the left on a real gutter and dropped into the upper
   third — high enough to be the first thing read, low enough that there is
   paper above it and room for the fall below. The block is a reading column
   rather than a banner: capped at 60–72% of the canvas so the sentence breaks
   into two lines of its own accord and the right-hand third stays open for the
   LEGO and the dock.

   `top` is a percentage of the CANVAS, not of the viewport, and that is
   deliberate — `Bricks.zone()` derives the protected region from this
   element's `offsetTop/offsetHeight`, so the composition and the physics are
   reading one number. */
.canvas__intro {
  position: absolute;
  top: clamp(4.5rem, 17vh, 9.5rem);
  left: clamp(1.5rem, 5.5vw, 5.5rem);
  z-index: 2;
  /* A MEASURE, NOT A SHRINK-WRAP. `fit-content` sizes the column to whichever
     child happens to be widest, and with a balanced two-line headline that is
     not the same number as the widest painted line — the box came out 119px
     narrower than the sentence it contains, and `Bricks.zone()` derives the
     protected region from exactly this box. Stating the measure means the
     column always contains its own type, the physics is told the truth, and
     the sentence breaks where the column says rather than where the longest
     word lands. */
  width: min(42rem, 62vw);
  /* 48rem, up from 44. At 34px the sentence measures ~693px and the old 704px
     cap left 11px of slack — one metric change away from breaking "engineers."
     onto a line by itself. `.canvas__head` is `width: fit-content`, so the box
     still hugs the words and this cannot push anything into the objects on the
     right; it only decides where the line is allowed to break. */
  max-width: min(44rem, 68vw);
  padding: 0;
  pointer-events: none;              /* only the pills inside are interactive */
}

/* Larger than it was — the line is the first claim the page makes, so it carries
   the weight rather than the tags below it.

   THE SELECTION BOX. The heading is a draggable layer, so its wrapper is what
   gets outlined when you click it — and as a plain block that outline ran the
   full width of the intro column, far past the words. `width: fit-content` on
   the wrapper fixes that, but on its own it lets the heading grow to its
   max-content width and the whole sentence goes on one line. So the two work
   together: `max-width` here decides where the line breaks, `fit-content` there
   shrinks the box onto the result, and `text-wrap: balance` keeps the lines
   near-equal so there is little slack left inside the box.

   `52ch` is the one number to nudge if the wrap lands badly — `ch` rather than
   rem so it tracks the font size instead of fighting it.

   ------------------------------------------------------------------------
   MEASURED OFF THE REFERENCE (Jerry Zhang's hero, DevTools open on it).

   Everything below came out of that screenshot rather than out of taste. The
   method, for whenever this needs redoing: normalise by the ink height of one
   line, which makes every ratio scale-invariant, then bisect.

       size ........ 26px, straight off the inspector
       weight ...... 500. Stem-width / ink-height is 0.135 there; rendering
                     Geist at 400/450/500/550/600/700 gives
                     .109/.109/.130/.130/.152/.170, so 500 — decisively not
                     the 600 this was, and nowhere near 700.
       tracking .... -0.023em. Line width / ink height is 26.49; the same
                     string at 0/-.008/-.015/-.023/-.03em measures
                     27.85/27.37/26.96/26.48/26.04. Already correct — this
                     value is unchanged, and it is the one thing that was.
       leading ..... 1.21. First estimated as 1.27 off a 1.52x scale guessed
                     from cap metrics; the full-page 2x capture settles it —
                     line tops 63px apart, and line 1's ink width of 612px
                     against 615px for the same string rendered at 26px pins
                     the scale at exactly 2.0, so 63/2 = 31.5px = 1.21em
       ink ......... #000000, per the inspector

   THE SIZE IS THE WHOLE CHANGE. This was a 42px display line at weight 600
   broken over a 18ch measure — a headline. The reference is a 26px sentence at
   weight 500 over a ~54ch measure — a paragraph you read. Matching the weight
   without matching the size would just have made the big line look underset.

   The measure is 52ch to sit where the reference's own lines break, so the copy
   wraps the way his does. At 45 characters the current sentence lands on ONE
   line; the reference's two-line block is a 108-character sentence. Lengthen
   the copy and this wraps into the same shape on its own — nothing here needs
   to change for that. */
.canvas__headline {
  /* 34px, not the reference's 26. Everything else on this rule is still his —
     the weight, the tracking, the 1.21 leading, the ink — but the size is a
     deliberate departure: at 26px a 45-character sentence is a small single
     line in a very large empty corner, and this line is the first claim the
     page makes. 34px keeps his density and his proportions while giving the
     sentence enough presence to carry that corner on its own.

     The floor rises with it, 20px -> 24px, so the phone does not drop to a
     third of the desktop size. */
  /* --- SIZE, AND WHY IT MOVED AGAIN ------------------------------------

     34px over a 52ch measure put this 45-character sentence on ONE line, and a
     single short line is a caption however well it is set. The claim the page
     opens with should be a BLOCK: two lines, stacked, with enough weight to
     hold the corner of the canvas on its own against eighteen saturated
     bricks.

     So the measure comes in to 24ch, which is where "…a product / designer
     who engineers." breaks, and `text-wrap: balance` keeps the two lines
     within a word of each other rather than leaving an orphan. The size rises
     with it — 32px on a phone to 52px at 1440 — and the leading tightens to
     1.1, because a two-line block set at 1.21 reads as two sentences rather
     than as one.

     Everything else on this rule is unchanged and still measured: the 500
     weight, the -0.023em tracking, the pure black ink. */
  font-size: clamp(2rem, 1.35rem + 2.35vw, 3.25rem);
  line-height: 1.1;
  letter-spacing: -0.028em;
  font-weight: 500;
  color: #000;
  text-wrap: balance;
  max-width: min(100%, 24ch);
}
.canvas__head { width: fit-content; max-width: 100%; }

/* 11px, measured: the reference's headline box ends at y=89 (top 26 + two
   31.5px lines) and its pill row starts at y=100. This was 17.6px. */
/* The gap under the headline grows with it. At 34px on one line 11px was the
   measured relationship; under a two-line 52px block the same 11px reads as
   the tags being stuck to the last word. */
.canvas__pills {
  position: relative;
  margin-top: clamp(1rem, 1.6vh, 1.35rem);
  min-height: 2.25rem;   /* JS sets the real height once widths are measured */
}

/* --- anything draggable on the canvas ----------------------------------- */

/* The order in this transform matters. --x/--y place the object, --lift is the
   hover nudge riding on top of that placement, --r is its angle, --sx/--sy are
   the resize, and --hs is the hover swell applied last so it multiplies the
   resize rather than being lost to it. */
.drg {
  position: absolute;
  z-index: 3;
  pointer-events: auto;
  touch-action: none;
  transform:
    translate3d(var(--x, 0px), var(--y, 0px), 0)
    translateY(var(--float, 0px))
    translateY(var(--lift, 0px))
    rotate(var(--r, 0deg))
    scale(var(--sx, 1), var(--sy, 1))
    scale(var(--hs, 1));
  transition:
    box-shadow 320ms var(--ease),
    filter 320ms var(--ease),
    transform 150ms var(--ease);
  /* Nothing is draggable until it is selected, so the resting cursor says
     "click me" rather than "drag me". It used to say grab, and the object
     obliged — that was the whole problem. */
  cursor: pointer;
}

/* HOVER. Its entire job is to say the thing is interactive. It grants no
   powers: no handles, no dragging, no edit mode. A press is still only ever a
   selection.

   The swell is worth the 1.2%: it grows the hit box by slightly more than the
   1px lift shrinks it at the bottom edge, so the pointer can't fall out of the
   element it just entered and start flickering between the two states. */
/* NOT THE BRICKS. The swell is right for a lone object on paper and wrong for
   a part in an assembly: it scales each piece about its own centre, so running
   the cursor along a welded row opens and closes a 0.3px gap at every seam
   under it, and the 1px lift raises one brick out of a structure the rest of
   which stays put. Bricks get their own hover a few blocks down, applied to
   the whole structure at once and with no scale in it. */
:root:not(.hover-cold) .drg:not(.brk):not(.is-drag):not(.is-sel):hover {
  --lift: -1px;
  --hs: 1.012;
  filter: drop-shadow(0 4px 10px rgba(20, 16, 12, 0.10));
}

/* SELECTED. Only now can it be moved, and the cursor says so. */
.drg.is-sel {
  cursor: move;
  filter: drop-shadow(0 6px 14px rgba(20, 16, 12, 0.13));
}

.drg.is-drag {
  cursor: grabbing;
  z-index: 40;
  /* the lift while it's in your hand */
  filter: drop-shadow(0 14px 26px rgba(20, 16, 12, 0.22));
}

/* Not while a gesture is running. A transition on `transform` is what makes
   hover feel soft, and the very same declaration would leave a moving object
   150ms behind the cursor and make a live resize arrive after the handle that
   asked for it. Spelled out for the pills too, because their own rule further
   down re-declares `transition` and would otherwise put it back. */
.drg.is-drag,
.drg.is-xf,
.drg.is-settle,
.drg.is-spin,
.canvas__pills .drg.is-drag,
.canvas__pills .drg.is-xf {
  transition: box-shadow 320ms var(--ease), filter 320ms var(--ease);
}

.drg.is-drag .pill,
.drg.is-drag .note,
.drg.is-drag .stk { box-shadow: 0 10px 24px -8px rgba(20, 16, 12, 0.3); }

/* --- the bricks ------------------------------------------------------------
   Almost nothing, and that is the point. A brick is a `.drg`, so it inherits
   the canvas's entire interaction language from that one class — the press,
   the drag threshold, the elevation, the tool gating, the selection chrome.
   What is left for this block is the depth: a contact shadow tight under the
   piece and a soft one further out, which is the whole of its physicality.

   `z-index: 4` puts the bricks one band above the loose objects at 3 while
   both stay far below the toolbar. It is only a RESTING order — Drag.raise
   still lifts whatever was touched last to the top, because a piece you cannot
   see is a piece you cannot place. */
.brk { z-index: 4; line-height: 0; }

/* --- THE RECTANGLE IS NOT THE BRICK ----------------------------------------
   An L is four cells in a 2x3 box, so a third of its bounding box is empty
   paper — and that empty paper was hit-testing as the piece. Two consequences,
   both of them in the recording: a press in the notch picked up a brick the
   cursor was not on, and two pieces standing beside each other each reached
   into the other's silhouette through the corner of a box, so moving between
   them handed hover back and forth without the cursor ever leaving either.

   So nothing in the tree takes pointer events except the painted cell rects.
   `.brk__hit` is the body group in `Bricks.art()` — the same rects that draw
   the silhouette — which makes the hit region and the picture the same object
   by construction rather than by two lists agreeing. Events still bubble to
   the wrapper, so Drag's listeners are untouched, and pointer capture is
   unaffected by hit-testing and keeps working through a drag. */
.brk,
.brk .brk__art { pointer-events: none; }
.brk .brk__hit { pointer-events: auto; }

/* A piece in flight is not yours to grab — `.is-auto` says so on the wrapper,
   and the rule above would otherwise hand the events straight back through the
   child. It has to be spelled out at the same depth to win. */
.brk.is-auto .brk__hit { pointer-events: none; }

/* The wrapper's own shadow would sit under the art's, doubled and offset,
   the moment a brick were selected. The art owns every shadow a brick has. */
.brk.is-sel { filter: none; }

.brk .brk__art {
  display: block;
  /* the global reset caps every svg at 100% of its box; these are sized in
     absolute pixels off the lattice and must not be scaled to fit anything */
  max-width: none;
  filter:
    drop-shadow(0 1px 0.5px rgba(20, 16, 12, 0.11))
    drop-shadow(0 2px 5px rgba(20, 16, 12, 0.10));
  transition: filter 260ms var(--ease);
}

/* in hand */
.brk.is-drag .brk__art {
  filter:
    drop-shadow(0 2px 2px rgba(20, 16, 12, 0.13))
    drop-shadow(0 14px 22px rgba(20, 16, 12, 0.20));
}

/* --- HOVER, AND ONLY ONE OF IT ---------------------------------------------
   Driven from JS, not from `:hover`, and applied to every member of a
   structure at once. The reason is that a structure is what the press picks
   up: hovering one brick of a welded row and lifting only that brick promises
   a gesture the canvas does not have. One class on all of them, so the whole
   thing acknowledges the cursor and the seams stay true.

   NO SCALE. The generic `.drg` hover swells by 1.2%, which is invisible on a
   sticker and is a visible crack at every joint on a brick. The lift is
   uniform across the structure, so the piece under the cursor does not move
   relative to the pieces it is joined to — the whole assembly rises 1px.

   The outline is the last drop-shadow: zero offset, half a pixel of blur, so
   it hugs the actual silhouette including the concave corners rather than
   drawing a box around it. */
.brk.is-hov .brk__art {
  filter:
    drop-shadow(0 1px 0.5px rgba(20, 16, 12, 0.13))
    drop-shadow(0 5px 12px rgba(20, 16, 12, 0.15))
    drop-shadow(0 0 0.5px rgba(20, 16, 12, 0.34));
}
.brk.is-hov { --lift: -1px; }

/* IN HAND. Every member of the set being dragged, not just the one under the
   cursor — the same reasoning as hover, and the reason there is a class here
   at all rather than reusing `.is-drag`, which Drag only ever puts on the lead
   node. A shade more lift than hover, a longer shadow, and the outline firms
   up: the piece reads as held rather than as pointed at. */
.brk.is-hold { --lift: -2px; }
.brk.is-hold .brk__art {
  filter:
    drop-shadow(0 2px 2px rgba(20, 16, 12, 0.14))
    drop-shadow(0 16px 26px rgba(20, 16, 12, 0.20))
    drop-shadow(0 0 0.6px rgba(20, 16, 12, 0.42));
}

/* THE PIECE BEING AIMED AT. The other half of the snap indicator — the ghost
   says where, this says what to. Every member of the target structure warms
   very slightly, which is enough to tell two candidate structures apart
   without a callout, an arrow or a highlight box anywhere on the canvas. */
.brk.is-tgt .brk__art {
  filter:
    drop-shadow(0 1px 0.5px rgba(20, 16, 12, 0.11))
    drop-shadow(0 2px 5px rgba(20, 16, 12, 0.10))
    drop-shadow(0 0 1.5px rgba(20, 16, 12, 0.26));
}

/* IT CANNOT STAY HERE, SAID ON THE BRICK.

   The piece is over the hero. The rule is that it will not be left there, and
   the page has to say so — but on the object the rule is about, not on the
   sentence underneath it. There was a red hatched rectangle over the hero here
   once and it was the collision model in the user's face: a debugger, shipped.
   Objects on a canvas do not draw their own bounding boxes, and the hero is an
   object on this canvas.

   So: the piece dims and cools, the way a thing does when it is not going to
   stay where it is. Two thirds opacity is enough to read as provisional and not
   so little that you lose the piece under your finger, and the shadow goes
   flatter because a piece that is about to be moved is not resting on anything.
   No red wash on the art — the ghost is already telling you where it goes, and
   an alarm colour would make a resolved, reversible interaction look like an
   error. The cursor carries the only hard "no" in the whole thing.

   Notice what this rule does NOT touch: the hero. Nothing here has a selector
   that reaches it. */
.brk.is-block .brk__art {
  opacity: 0.62;
  filter:
    saturate(0.72)
    drop-shadow(0 1px 1px rgba(20, 16, 12, 0.10))
    drop-shadow(0 3px 8px rgba(20, 16, 12, 0.10));
  transition: opacity 140ms var(--ease), filter 140ms var(--ease);
}
.brk.is-block .brk__hit { cursor: not-allowed; }

/* the held lift reads as "picked up"; a piece that has nowhere to land settles
   back down toward the paper instead */
.brk.is-block { --lift: 0px; }

@media (prefers-reduced-motion: reduce) {
  .brk.is-block .brk__art { transition-duration: 1ms; }
}

/* THE FEEDBACK THE SNAP GETS ONCE IT IS COMMITTED. Inside the threshold the
   piece sits a little heavier — the shadow it would cast if it had already
   come to rest against the other one. It is the last step of a sequence that
   now starts much earlier: pull, then ghost, then this. */
.brk.is-near .brk__art {
  filter:
    drop-shadow(0 1px 1px rgba(20, 16, 12, 0.15))
    drop-shadow(0 7px 12px rgba(20, 16, 12, 0.22));
}

/* IN FLIGHT. A piece the preset is carrying is not yours to grab — the drag
   would fight the animation for the same two numbers — and it is not a place
   for another piece to land either (see the guard in `plan()`). */
.brk.is-auto { pointer-events: none; }

/* recruited into a build, and repainted on the way there. The art is
   regenerated rather than swapped, so this is a cross-fade of one element. */
.brk.is-tint .brk__art { animation: brktint 400ms var(--ease); }
@keyframes brktint {
  0%   { opacity: 0.55; }
  100% { opacity: 1; }
}

/* --- the ghost -------------------------------------------------------------
   WHERE IT WILL LAND, DRAWN AT THE PLACE IT WILL LAND. Everything above tells
   you that something is about to happen; only this tells you what. It is the
   held set redrawn — same silhouettes, same colours, same relative offsets, at
   the exact cells `plan()` chose — so it is not a hint about the connection,
   it is the connection, shown early.

   Faint enough to be plainly not the piece, solid enough to read as plastic.
   It never takes a pointer event and it is never in the model: `plan()` reads
   `Bricks.groups`, and nothing in this layer is a member of a group, so a
   ghost cannot be snapped to and cannot become one of the things a later
   ghost is drawn against. */
.brkghost {
  position: absolute;
  inset: 0;
  z-index: 39;
  pointer-events: none;
  opacity: 0;
  transition: opacity 120ms var(--ease);
}
.brkghost.is-on { opacity: 1; }

.brkghost__pc {
  position: absolute;
  left: 0;
  top: 0;
  line-height: 0;
  opacity: 0.4;
  /* The ghost is redrawn from the plan on every pointer event, so the element
     is already where it should be by the time the frame paints. A transition
     on transform here would make it lag the piece it is previewing, which is
     the one thing it must never do. */
  transition: none;
}
.brkghost__pc svg {
  display: block;
  max-width: none;
  filter:
    saturate(0.72)
    drop-shadow(0 0 1px rgba(20, 16, 12, 0.4));
}

/* --- the rotate hint -------------------------------------------------------
   Only while a piece is actually in the hand, only when rotating it would do
   something, and never on hover — a permanent legend for a shortcut you are
   not currently able to use is furniture. It rides above the held piece and
   is nudged aside when that would put it off the canvas. */
.brkhint {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 41;
  pointer-events: none;
  display: flex;
  align-items: center;
  gap: 0.34em;
  padding: 0.3em 0.62em;
  border-radius: 999px;
  background: rgba(20, 16, 12, 0.78);
  color: var(--paper-2);
  font-size: 10.5px;
  letter-spacing: 0.01em;
  line-height: 1.5;
  white-space: nowrap;
  opacity: 0;
  /* left/top are the middle of the piece's top edge, so the pill hangs off
     that point rather than starting at it. */
  transform: translate(-50%, -100%) scale(0.94);
  transform-origin: 50% 100%;
  transition: opacity 140ms var(--ease), transform 220ms var(--spring-soft);
}
.brkhint.is-on {
  opacity: 1;
  transform: translate(-50%, -100%) scale(1);
}
.brkhint kbd {
  font: inherit;
  font-weight: 600;
  padding: 0 0.34em;
  border-radius: 3px;
  background: rgba(255, 255, 255, 0.16);
}

/* THE SAME PILL, AS A BUTTON. On a touchscreen there is no R to press, so the
   hint stops describing a shortcut and becomes the control itself — one thumb
   holds the brick, the other taps this. Bigger than the text version because
   it is now a target rather than a label, and it is the only thing in the
   brick layer that takes a pointer event. */
.brkhint--tap {
  pointer-events: auto;
  touch-action: none;
  cursor: pointer;
  font-size: 12px;
  padding: 0.46em 0.78em;
  gap: 0.42em;
  /* clear of the piece and of the finger already holding it */
  margin-top: -4px;
  -webkit-tap-highlight-color: transparent;
  user-select: none;
}
.brkhint--tap svg { display: block; }
.brkhint--tap:active { background: rgba(20, 16, 12, 0.92); }

@media (prefers-reduced-motion: reduce) {
  .brkghost, .brkhint { transition-duration: 1ms; }
}

/* --- the debug overlay -----------------------------------------------------
   Never on unless `?brkdebug` is in the URL or `__brickDebug()` is called from
   the console, and it takes no pointer events, so at worst it is looked
   through. Every colour here is a wire colour on purpose — nothing in this
   block should be mistakable for part of the page. */
.brkdbg {
  position: absolute;
  inset: 0;
  z-index: 60;
  pointer-events: none;
  overflow: visible;
}
.brkdbg .d-hit { fill: rgba(0, 132, 255, 0.09); stroke: rgba(0, 132, 255, 0.5); stroke-width: 0.75; }
.brkdbg .d-held { fill: rgba(255, 60, 0, 0.12); stroke: rgba(255, 60, 0, 0.75); }
.brkdbg .d-land { fill: rgba(0, 190, 90, 0.16); stroke: rgba(0, 160, 70, 0.9); stroke-width: 1; }
.brkdbg .d-anchor { fill: rgba(0, 132, 255, 0.85); }
.brkdbg .d-detect { fill: none; stroke: rgba(255, 60, 0, 0.35); stroke-width: 1; stroke-dasharray: 5 4; }
.brkdbg .d-magnet { fill: none; stroke: rgba(255, 60, 0, 0.6); stroke-width: 1; stroke-dasharray: 2 3; }
.brkdbg .d-cand { fill: rgba(150, 0, 200, 0.55); }
.brkdbg .d-pick { fill: rgba(0, 160, 70, 0.9); stroke: rgba(0, 160, 70, 0.9); stroke-width: 1.5; }
/* the hero blocks: the lattice rectangle, and the four edges a brick may seat
   against. If one of these is not sitting exactly on the block it stands for,
   that is the bug, and it is visible in one glance. */
.brkdbg .d-wall { fill: rgba(150, 62, 44, 0.07); stroke: rgba(150, 62, 44, 0.65); stroke-width: 1; }
.brkdbg .d-wedge { fill: rgba(150, 62, 44, 0.55); }
/* the edge the current landing is seated against */
.brkdbg .d-whot { fill: rgba(0, 160, 70, 0.95); }
.brkdbg .d-stud { fill: rgba(0, 132, 255, 0.75); }

/* --- the builds shelf ------------------------------------------------------
   NOT A MODAL. It is the toolbar's own drawer: the same white, the same 12px
   radius, the same 1px inside stroke and the same shadow, hung off the panel's
   left edge and vertically centred on the button that opened it. It covers a
   corner of the canvas and nothing else, and the canvas underneath keeps
   working the entire time it is open. */
.shelf {
  position: absolute;
  right: calc(100% + 10px);
  top: 50%;
  translate: 0 -50%;
  display: grid;
  gap: 2px;
  padding: 6px;
  width: 172px;
  border-radius: 12px;
  background: #fff;
  box-shadow:
    inset 0 0 0 1px #e2e2e2,
    0 8px 24px -10px rgba(20, 16, 12, 0.22);
  opacity: 0;
  transform: translateX(6px) scale(0.97);
  transform-origin: 100% 50%;
  pointer-events: none;
  transition:
    opacity 200ms var(--ease),
    transform 300ms var(--spring-soft, var(--ease));
  z-index: 2;
}
.shelf.is-up { opacity: 1; transform: none; pointer-events: auto; }

.shelf__it {
  display: grid;
  grid-template-columns: 40px 1fr;
  align-items: center;
  gap: 9px;
  padding: 6px 8px 6px 6px;
  border: 0;
  border-radius: 8px;
  background: transparent;
  text-align: left;
  cursor: pointer;
  transition: background 180ms var(--ease);
}
:root:not(.hover-cold) .shelf__it:hover { background: rgba(20, 16, 12, 0.05); }

/* the lift is on the ARTWORK, not the row — a row that moves under the pointer
   drags the label with it and the whole shelf looks loose */
.shelf__art {
  display: grid;
  place-items: center;
  height: 34px;
  filter: drop-shadow(0 1px 1.5px rgba(20, 16, 12, 0.16));
  transition:
    transform 240ms var(--spring-soft, var(--ease)),
    filter 240ms var(--ease);
}
.shelf__art svg { display: block; max-width: 40px; max-height: 34px; }
:root:not(.hover-cold) .shelf__it:hover .shelf__art {
  transform: translateY(-1.5px) scale(1.06);
  filter: drop-shadow(0 3px 5px rgba(20, 16, 12, 0.2));
}
.shelf__it.is-press .shelf__art { transform: scale(0.94); transition-duration: 90ms; }

.shelf__lb {
  font-size: 0.8125rem;
  font-weight: 500;
  letter-spacing: -0.01em;
  color: var(--ink-2);
}

@media (prefers-reduced-motion: reduce) {
  .shelf { transition-duration: 1ms; }
  .shelf__art { transition-duration: 1ms; }
  .brk.is-tint .brk__art { animation: none; }
}

/* a double-click frees a brick from its structure where it stands; this is the
   whole acknowledgement — 240ms, and it never touches the drag transform */
.brk.is-pop .brk__art { animation: brkpop 240ms var(--ease); }
@keyframes brkpop {
  0%   { transform: scale(1); }
  38%  { transform: scale(1.055); }
  100% { transform: scale(1); }
}

@media (prefers-reduced-motion: reduce) {
  .brk.is-pop .brk__art { animation: none; }
}

/* --- pills -------------------------------------------------------------- */

/* MEASURED OFF THE REFERENCE, off the 2x full-page capture (so every number
   below is halved from raw pixels and exact — the pill edges land on whole
   device pixels, no antialiasing to guess through):

       height ....... 31px   (rows 200-261)
       padding ...... 11px   (ink starts 11.5 in on the left, ends 10.5 from
                             the right, less the B's side bearing)
       radius ....... 6px
       fill ......... a flat #F4F4F4 with NO stroke — sampled straight across
                     both edges, the fill goes from bg to full in one pixel
       label ........ 15px. "Based in Toronto" measures 118px wide with 11.5px
                     of ink height there; the same string rendered at 15px is
                     116.5 / 11.5. At 14 or 16 nothing lines up.
       weight ....... 530. Width by weight at 15px runs 114.5 / 115.5 / 116.5 /
                     118.5 / 118.5 for 400/450/500/550/600, and the reference
                     is 118.0 — just past 500.
       label ink .... #918D8D, sampled (see the note on `color` below)
       icon gap ..... 6.5px, the clear run between "Toronto" and its glyph
       pill gap ..... 10px (was 8, in Rack's layout() in site.js)

   THE FILL IS NOT #F4F4F4 HERE, AND THAT IS DELIBERATE. The reference's page
   is #FDFCFC, so its pill sits 9 points below the paper and slightly cool.
   This site's paper is #f7f6f3 — warmer and already darker than that. Dropping
   the literal #F4F4F4 onto it would leave a 3-point step, near-invisible, with
   a neutral chip sitting on warm paper. So the RELATIONSHIP is copied instead:
   9 points down, warmth preserved. */
.pill {
  display: inline-flex;
  align-items: center;
  gap: 0.40625rem;                   /* 6.5px */
  height: 1.9375rem;                 /* 31px */
  padding: 0 0.6875rem;              /* 11px */
  border-radius: 7px;
  background: #f0efec;
  font-size: 0.9375rem;              /* 15px */
  font-weight: 530;
  /* THE NOTE THIS RULE USED TO CARRY said the sampled #918D8D gives 2.99:1 on
     its background — where 15px at this weight needs 4.5:1 — and offered
     #6f6b6b as the same grey at 4.79:1 "if legibility should win". It should.
     These three tags are two of the five facts the hero is there to state, and
     a fact you have to lean in to read is not stated. Same grey, same
     restraint, one step of ink darker. */
  color: #6f6b6b;
  white-space: nowrap;
  /* AND A LITTLE DEPTH. A hairline inside stroke and one soft shadow, at the
     strength the rest of the interface uses — enough that the tag reads as a
     small object lying on the paper rather than as a lighter rectangle printed
     on it, and nowhere near enough to compete with the sentence above it. */
  box-shadow:
    inset 0 0 0 1px rgba(20, 16, 12, 0.055),
    0 1px 2px -1px rgba(20, 16, 12, 0.09);
  transition:
    background 260ms var(--ease),
    box-shadow 320ms var(--ease);
}

.pill__label { display: inline-block; }

/* A glyph slot in the label — before it, after it, or in the middle of it;
   see the `{}` token in Canvas's pill builder.

   SIZED IN em, AND OFF THE CAP HEIGHT. These sit in a line of type and have to
   read as part of it, so their size is a ratio to the letters beside them
   rather than a pixel count that stops being right the moment the label's size
   changes.

   Both ratios come from measuring the reference art rather than from taste —
   ink extents, letter by letter, as a fraction of the cap they stand next to:

       pin ....... 76px against a 58px 'B'  = 1.31 cap
       mark ...... 90px against a 60px 'C'  = 1.50 cap

   Geist's caps measure 0.75em, so those land at 0.98em and 1.18em. Both viewBoxes
   are tight to the ink, so these numbers are the height you actually see. They
   differ on purpose: a pin is a symbol standing beside a word, and the mark is
   standing in FOR one — it has to hold its own against the caps either side. */
.pill__icon {
  display: inline-grid;
  place-items: center;
  /* 15px: the reference's glyph measures 15 x 16 of ink */
  width: 15px; height: 15px;
  flex: none;
  color: inherit;
}
.pill__icon svg { width: 100%; height: 100%; display: block; }

/* Both keep their own proportions — 14.1 x 18.48 and 100 x 120.72. Height is
   the dimension that has to agree with the type, so it is the one set, and the
   width follows from the ratio rather than being squared off into a box that
   would letterbox the drawing or stretch it. */
.pill__icon--pin {
  height: 0.98em;
  width: calc(0.98em * 14.1 / 18.48);
}

.pill__icon--cypherock {
  height: 1.18em;
  width: calc(1.18em * 100 / 120.72);
}
.pill__icon--logo { width: 15px; height: 15px; border-radius: 4px; overflow: hidden; }
.pill__icon--logo img { width: 100%; height: 100%; display: block; object-fit: contain; }

/* the detail is collapsed until you hover (or select) the tag */
.pill__detail {
  display: inline-block;
  max-width: 0;
  opacity: 0;
  overflow: hidden;
  white-space: nowrap;
  color: var(--ink-dim);
  transition:
    max-width 520ms var(--spring-soft),
    opacity 240ms var(--ease),
    margin-left 520ms var(--spring-soft);
}

:root:not(.hover-cold) .drg:not(.is-drag):hover .pill__detail,
.drg.is-sel .pill__detail,
.drg.is-measuring .pill__detail {
  max-width: 22rem;
  opacity: 1;
  margin-left: 0.4rem;
  transition:
    max-width 560ms var(--spring-soft),
    opacity 300ms var(--ease) 90ms,
    margin-left 560ms var(--spring-soft);
}

/* Expanding widens the tag, and the tags are absolutely positioned, so the
   detail would open straight over its neighbour. Two things prevent that: the
   hovered tag is raised, and the tags after it slide aside by exactly the width
   the detail needs (JS writes --nudge, measured — see Canvas.init).

   The nudge rides on `left` rather than the transform because Drag owns the
   transform and keeps writing --x every frame during the release glide; a
   transition there would smear the throw. */
/* Bricks are exempt for the same reason. A hovered pill has nothing beside it
   that it has to stay level with; a hovered brick is usually welded to three
   others, and lifting it a band takes it out of its own structure and puts it
   in front of the piece it is joined to. Crossing a seam then pops one piece
   forward and drops the last one back, which is the flicker. Their order comes
   from `Drag.raise` on the piece you actually touched, and nothing else. */
:root:not(.hover-cold) .drg:not(.brk):not(.is-drag):hover,
.drg:not(.brk).is-sel { z-index: 6; }

.canvas__pills .drg {
  left: calc(var(--col, 0px) + var(--nudge, 0px));
  transition:
    box-shadow 320ms var(--ease),
    filter 320ms var(--ease),
    left 520ms var(--spring-soft);
}

/* the frame JS measures the open width in — never painted */
.drg.is-measuring { transition: none; }
.drg.is-measuring .pill__detail { transition: none; }

/* a hairline divider appears with the detail */
:root:not(.hover-cold) .drg:not(.is-drag):hover .pill__detail::before,
.drg.is-sel .pill__detail::before,
.drg.is-measuring .pill__detail::before {
  content: '';
  display: inline-block;
  width: 1px;
  height: 0.85em;
  margin-right: 0.45rem;
  vertical-align: -0.12em;
  background: rgba(20, 16, 12, 0.16);
}

/* No hover *lift* — that was the twitchy part. Hover only warms the background
   and opens the detail. */
:root:not(.hover-cold) .drg:not(.is-drag):not(.is-sel):hover .pill {
  background: #eae9e5;
  box-shadow:
    inset 0 0 0 1px rgba(20, 16, 12, 0.08),
    0 2px 5px -2px rgba(20, 16, 12, 0.14);
}

/* --- sticky notes ------------------------------------------------------- */

@keyframes notePop {
  from { opacity: 0; transform: scale(0.85); }
  to   { opacity: 1; transform: scale(1); }
}

.note {
  position: relative;
  animation: notePop 480ms cubic-bezier(0.34, 1.42, 0.64, 1) both;
  display: flex;
  flex-direction: column;
  width: 13rem;
  min-height: 11.5rem;
  padding: 1rem 1.05rem 0.85rem;
  border-radius: 14px;
  background: var(--note, #c9b8f5);
  box-shadow:
    inset 0 0 0 1.5px rgba(255, 255, 255, 0.6),
    0 6px 18px -8px rgba(20, 16, 12, 0.28);
  font-size: 0.9375rem;
  line-height: 1.45;
  color: #2f2b3a;
}

/* the author line at the foot of the note */
.note__by {
  margin-top: auto;
  padding-top: 0.75rem;
  font-size: 0.8125rem;
  color: rgba(30, 26, 45, 0.62);
}

.note__text {
  display: block;
  width: 100%;
  outline: none;
  cursor: text;
  overflow-wrap: anywhere;
}
.note__text:empty::before {
  content: attr(data-placeholder);
  color: rgba(30, 26, 45, 0.42);
}


/* --- stickers ----------------------------------------------------------- */

.stk {
  display: grid;
  place-items: center;
  width: 2.5rem; height: 2.5rem;
  font-size: 1.5rem;
  line-height: 1;
  user-select: none;
  filter: drop-shadow(0 3px 5px rgba(20, 16, 12, 0.18));
  transition: transform 460ms var(--spring), filter 320ms var(--ease);
}
:root:not(.hover-cold) .drg:not(.is-drag):not(.is-sel):hover .stk { transform: scale(1.12); }

/* --- the ink colour palette, flying out beside the rack ----------------- */

.inks {
  position: absolute;
  right: 100%;
  top: 0;
  margin-right: 8px;
  padding: 5px;
  display: grid;
  gap: 4px;
  border-radius: 12px;
  background: #fdfdfd;
  box-shadow:
    inset 0 0 0 1px rgba(20, 16, 12, 0.045),
    0 12px 30px -10px rgba(20, 16, 12, 0.22);
  opacity: 0;
  transform: translateX(6px) scale(0.95);
  transform-origin: 100% 20%;
  pointer-events: none;
  transition: opacity 220ms ease, transform 420ms var(--spring);
}
.tools.has-ink .inks { opacity: 1; transform: none; pointer-events: auto; }

.inks button {
  width: 20px; height: 20px;
  border-radius: 50%;
  background: var(--c);
  box-shadow: inset 0 0 0 1px rgba(20, 16, 12, 0.12);
  transition: transform 380ms var(--spring), box-shadow 240ms ease;
}
.inks button:hover { transform: scale(1.18); }
/* A purple ring, not the dot's own colour — a black dot ringed in black is
   indistinguishable from an unselected one, which is the case in the reference
   where black happens to be the active ink. */
.inks button[aria-pressed='true'] {
  box-shadow: 0 0 0 2px #fdfdfd, 0 0 0 3.5px #7c3aed;
}

/* --- the sticker drawer ------------------------------------------------- */

/* parented to .tools rather than .tools__panel — the panel clips its content */
.drawerpad {
  position: absolute;
  right: 100%;
  /* Centred on the toolbar rather than hung from its top edge. The -50% has to
     be part of *both* transform states below, because the open state resets
     transform — setting it here alone would get thrown away on open. */
  top: 50%;
  margin-right: 8px;

  /* One large pixel sprite per row, as in the reference. Two numbers drive the
     whole drawer: the cell a sprite sits in, and how many are visible before it
     starts scrolling. The vh cap stops a short laptop viewport from getting a
     drawer taller than the window. */
  --pad-cell: 3.75rem;
  --pad-rows: 6;
  --pad-gap: 6px;
  --pad-pad: 6px;

  display: grid;
  grid-template-columns: 1fr;
  gap: var(--pad-gap);
  padding: var(--pad-pad);
  width: calc(var(--pad-cell) + var(--pad-pad) * 2);
  max-height: min(
    calc(var(--pad-rows) * var(--pad-cell)
       + (var(--pad-rows) - 1) * var(--pad-gap)
       + var(--pad-pad) * 2),
    62vh);
  overflow-y: auto;
  overscroll-behavior: contain;
  border-radius: 14px;
  background: #fdfdfd;
  box-shadow:
    inset 0 0 0 1px rgba(20, 16, 12, 0.045),
    0 14px 34px -10px rgba(20, 16, 12, 0.24);
  opacity: 0;
  transform: translateY(-50%) translateX(8px) scale(0.96);
  /* it grows out of the middle of the toolbar's edge, where it now sits */
  transform-origin: 100% 50%;
  pointer-events: none;
  transition: opacity 220ms ease, transform 440ms var(--spring);
}
.tools.has-pad .drawerpad {
  opacity: 1;
  transform: translateY(-50%);
  pointer-events: auto;
}

/* the add chip reads as active while its drawer is open */
.tools.has-pad [data-tool-btn='sticker'] { background: #8a3dfd; color: #fff; }

.drawerpad button {
  aspect-ratio: 1;
  display: grid; place-items: center;
  border-radius: 10px;
  font-size: 1.05rem;
  transition: background 200ms ease, transform 380ms var(--spring);
}
.drawerpad button:hover { background: rgba(20, 16, 12, 0.055); transform: scale(1.12); }

/* the armed sticker — a purple ring and a tinted plate, so you always know
   what the next click will place */
.drawerpad button[aria-pressed='true'] {
  background: #f0ebfd;
  box-shadow: inset 0 0 0 1.5px #7c3aed;
  transform: scale(1.04);
}
.drawerpad button[aria-pressed='true']:hover { background: #e9e1fc; transform: scale(1.12); }

/* a small dot in the corner, which survives even if the tint is hard to see
   against a pale sticker */
.drawerpad button { position: relative; }
.drawerpad button[aria-pressed='true']::after {
  content: '';
  position: absolute;
  top: 4px; right: 4px;
  width: 5px; height: 5px;
  border-radius: 50%;
  background: #7c3aed;
}

/* --- the rotating hint -------------------------------------------------- */

.canvas__hint {
  position: absolute;
  left: 1.75rem; bottom: 1.5rem;
  z-index: 2;
  font-size: var(--step--1);
  color: var(--ink-dim);
  pointer-events: none;
}
.canvas__hint span {
  display: inline-block;
  transition: opacity 460ms var(--ease), transform 560ms var(--spring);
}
.canvas__hint span.is-out { opacity: 0; transform: translateY(-6px); }

/* --- the shortcut legend ------------------------------------------------
   Bottom-right of the hero, opposite the rotating hint. The keycaps are the
   physical kind: a light face, a hairline edge, and one pixel of shadow under
   the bottom edge so they read as sitting slightly proud of the paper. */

.canvas__keys {
  position: absolute;
  right: 1.75rem; bottom: 1.5rem;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 1.15rem;
  pointer-events: none;   /* a legend, not a control */
}

.keyhint {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
}

.keyhint__label {
  font-size: var(--step--1);
  color: var(--ink-dim);
  white-space: nowrap;
}

.keyhint__keys { display: inline-flex; gap: 3px; }

.cap {
  display: inline-grid;
  place-items: center;
  min-width: 21px;
  height: 21px;
  padding: 0 4px;
  border-radius: 5px;
  background: #fff;
  /* the edge and the sliver of shadow that make it feel like a key */
  box-shadow:
    inset 0 0 0 1px rgba(20, 16, 12, 0.11),
    0 1px 0 rgba(20, 16, 12, 0.07);
  font: inherit;
  font-size: 11px;
  line-height: 1;
  color: var(--ink-2);
  transition:
    background 140ms var(--ease),
    color 140ms var(--ease),
    transform 140ms var(--ease),
    box-shadow 140ms var(--ease);
}

/* Ctrl / Space / Shift need more width than a single letter */
.cap--wide { padding: 0 6px; letter-spacing: 0.01em; }

/* pressed for real: the cap depresses, so the legend doubles as feedback */
.cap.is-hit {
  background: var(--ink);
  color: #fff;
  transform: translateY(1px);
  box-shadow: inset 0 0 0 1px rgba(20, 16, 12, 0.9);
}

/* The hint and the legend would meet in the middle on a narrow screen, so the
   legend stands down first. The breakpoint is arithmetic, not taste: five rows
   measure ~442px, the longest rotating hint ~339px, plus 2x1.75rem gutters and
   a 24px minimum gap = ~861px. Raise this if you add another row to
   `canvas.keys` — at 52rem, five rows already overlapped the hint. */
@media (max-width: 56rem) {
  .canvas__keys { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .cap { transition-duration: 1ms; }
}

@media (max-width: 46rem) {
  .tools__panel { padding: 6px; }
  /* still one per row — just a smaller cell, and one fewer visible */
  .drawerpad { --pad-cell: 3rem; --pad-rows: 5; }
  .note { width: 9rem; }
  /* The generous desktop inset is a wasted third of a phone; drop back to the
     plain gutter and let the block start higher. */
  .canvas__intro { top: 3.5rem; left: 1.5rem; max-width: calc(100vw - 3rem); }
}

@media (prefers-reduced-motion: reduce) {
  .canvas__dots { transform: none; }
  .drg, .pill, .stk, .sel, .inks, .drawerpad { transition-duration: 1ms; }
}

/* ==========================================================================
   27. FIGMA SELECTION CHROME
   From the recording: a 1px blue outline, four white squares with blue borders
   at the corners, and a rotation knob on a short stalk above the top edge.
   ========================================================================== */

/* THE FRAME DOES NOT TURN WITH THE OBJECT.

   It is the upright box the rotated object sits inside — so its dots stay at
   the corners and the middles of the sides no matter what angle the thing is
   at, and the guides that point at them stay put too. Rotating with the object
   put the top-middle dot somewhere off to one side, and the two dashed lines
   went chasing it around the canvas.

   HOW IT STAYS UPRIGHT. It is a child of the object, so it inherits the
   object's rotate and scale; the transform below is exactly their inverse, and
   the two cancel. That cancellation is only exact because resizing is
   proportional — a uniform scale commutes with a rotation, a lopsided one does
   not, and the frame would come out sheared. `--sel-k` is 1/scale, written by
   JS rather than divided here, so the arithmetic stays in one place.

   Its size is the object's bounding box at its current angle, written as
   --sel-w/--sel-h. Centred with margins rather than a translate, because
   margins are layout and would not be dragged through the parent's scale. */
.sel {
  position: absolute;
  left: 50%;
  top: 50%;
  width: var(--sel-w, 100%);
  height: var(--sel-h, 100%);
  margin-left: calc(var(--sel-w, 100%) / -2);
  margin-top: calc(var(--sel-h, 100%) / -2);
  transform: rotate(calc(-1 * var(--r, 0deg))) scale(var(--sel-k, 1));
  z-index: 2;
  border: 1px solid var(--fig, #0d99ff);
  opacity: 0;
  transition: opacity 200ms var(--ease);
  pointer-events: none;
}
.drg.is-sel .sel { opacity: 1; }

/* Eight handles: four corners for both axes at once, four edges for one axis
   each. They scale up into place with the outline rather than appearing
   fully-formed, which is the difference between the selection arriving and the
   selection blinking on. */
.sel__h {
  position: absolute;
  width: 8px; height: 8px;
  background: #fff;
  border: 1px solid var(--fig, #0d99ff);
  border-radius: 1px;
  pointer-events: auto;
  opacity: 0;
  scale: 0.4;
  transition: opacity 140ms var(--ease), scale 220ms var(--spring-soft, var(--ease));
}
.drg.is-sel .sel__h { opacity: 1; scale: 1; }

.sel__h[data-c='nw'] { left: -4.5px;  top: -4.5px;    cursor: nwse-resize; }
.sel__h[data-c='ne'] { right: -4.5px; top: -4.5px;    cursor: nesw-resize; }
.sel__h[data-c='se'] { right: -4.5px; bottom: -4.5px; cursor: nwse-resize; }
.sel__h[data-c='sw'] { left: -4.5px;  bottom: -4.5px; cursor: nesw-resize; }

/* the edge handles sit centred on their side */
.sel__h[data-c='n'] { left: 50%; top: -4.5px;     translate: -50% 0; cursor: ns-resize; }
.sel__h[data-c='s'] { left: 50%; bottom: -4.5px;  translate: -50% 0; cursor: ns-resize; }
.sel__h[data-c='w'] { top: 50%;  left: -4.5px;    translate: 0 -50%; cursor: ew-resize; }
.sel__h[data-c='e'] { top: 50%;  right: -4.5px;   translate: 0 -50%; cursor: ew-resize; }

/* --- the position guides -------------------------------------------------
   A dashed line down from the top of the canvas to the selected object's
   rotation knob, and one in from the left to its middle. Same blue as the
   outline, 1px, 4-on-4-off — measured off the reference.

   Their own layer, square to the canvas, sitting above the page's furniture
   and below anything you have picked up. They can't be part of the selection
   chrome: that lives inside the object and turns with it, and a guide that
   tilts with the thing it measures isn't a guide. */
.guides {
  position: absolute;
  inset: 0;
  z-index: 4;
  pointer-events: none;
  opacity: 0;
  transition: opacity 160ms var(--ease);
}
.guides.is-on { opacity: 1; }

.guide { position: absolute; }

.guide--v {
  top: 0;
  width: 1px;
  background-image: repeating-linear-gradient(
    to bottom, var(--fig, #0d99ff) 0 4px, transparent 4px 8px);
}
.guide--h {
  left: 0;
  height: 1px;
  background-image: repeating-linear-gradient(
    to right, var(--fig, #0d99ff) 0 4px, transparent 4px 8px);
}

@media (prefers-reduced-motion: reduce) {
  .guides { transition-duration: 1ms; }
}

/* the rotation knob, on its stalk */
.sel__rot {
  position: absolute;
  left: 50%; top: -30px;
  width: 10px; height: 10px;
  translate: -50% 0;
  background: #fff;
  border: 1px solid var(--fig, #0d99ff);
  border-radius: 50%;
  pointer-events: auto;
  cursor: grab;
  opacity: 0;
  scale: 0.4;
  transition: opacity 140ms var(--ease), scale 220ms var(--spring-soft, var(--ease));
}
.drg.is-sel .sel__rot { opacity: 1; scale: 1; }
.sel__rot::before {
  content: '';
  position: absolute;
  left: 50%; top: 100%;
  width: 1px; height: 20px;
  translate: -50% 0;
  background: var(--fig, #0d99ff);
}
.sel__rot:active { cursor: grabbing; }

/* while transforming, suppress the hover lift so it doesn't fight the gesture */
.drg.is-xf .pill, .drg.is-xf .note, .drg.is-xf .stk { transform: none !important; }

/* The heading is a layer too, and it follows the same rules as every other
   one — pointer while it can only be selected, move once it is. Inheriting
   rather than restating it keeps the two in step. */
.canvas__headline {
  display: inline-block;
  cursor: inherit;
}

/* --- the italic in the headline --------------------------------------------
   DORMANT. The headline is one uniform weight now — see the note over
   `headline` in content.js. This rule is left intact rather than deleted
   because the `*word*` syntax that produces it still works: wrap a word in
   asterisks there and the italic comes straight back, correctly balanced.

   `*word*` becomes italic and nothing else changes. Geist is loaded with
   its italic axis, so this is a real italic rather than a synthesised slant.

   `hdl-` prefixed, never `hl-`: `.hl` is the Shiny Highlights slide. */
.hdl-em {
  font-style: italic;
  /* An italic reads a shade lighter than a roman at the same weight, so it is
     nudged back — by the same +20 it always was, now measured from the 500 the
     roman dropped to rather than from the 600 it used to be. The reference has
     no italic anywhere; this word is the one thing here that is not his. */
  font-weight: 520;
}

/* --- exit: once the hero scrolls away, the tools and ink go with it ------ */

.ink {
  opacity: 1;
  transition: opacity 420ms var(--ease);
}
body.past-hero .ink { opacity: 0; pointer-events: none !important; }
body.past-hero .sel { opacity: 0; }

/* ==========================================================================
   28. LOAD REVEAL
   Measured off the recording: everything arrives out of focus and resolves.
   The headline's sharpness climbs from ~0 to full over ~1200ms, the tags start
   at ~1050ms and take ~850ms, the nav lands around 2300ms. It's a de-focus,
   not a slide — the tiny translate is only there to stop it feeling flat.

   The animation is applied to the *inner* element, never the .drg wrapper, so
   it can't clobber the drag transform (--x/--y/--r).
   ========================================================================== */

@keyframes reveal {
  from { opacity: 0; filter: blur(var(--rv-blur, 14px)); transform: translateY(5px); }
  60%  { opacity: 1; }
  to   { opacity: 1; filter: blur(0); transform: none; }
}

.rv {
  animation: reveal var(--rv-dur, 900ms) cubic-bezier(0.22, 0.7, 0.2, 1)
             var(--rv-delay, 0ms) both;
  will-change: filter, opacity;
}

/* once it has played, drop will-change so the compositor can let the layer go */
.rv.rv-done { will-change: auto; animation: none; }

/* the headline resolves word by word, which is what makes the second line
   lag behind the first the way it does in the recording */
.canvas__headline .rw {
  display: inline-block;
  white-space: pre;
}

@media (prefers-reduced-motion: reduce) {
  .rv { animation: none; opacity: 1; filter: none; }
}

.drawerpad img,
.stk img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  /* keep the pixels crisp instead of smoothing them into mush */
  image-rendering: pixelated;
  pointer-events: none;
}
.stk { width: 3rem; height: 3rem; font-size: 0; }

/* ==========================================================================
   29. TOOL PREVIEW  ("what happens if I click right now?")
   A single ghost element follows the pointer with ~70ms of smoothing and shows
   exactly what the active tool will place. It never snaps, and it crossfades
   when you switch tools.
   ========================================================================== */

.ghost {
  position: fixed;
  left: 0; top: 0;
  z-index: 88;                       /* under the dock (90), over the ink (85) */
  pointer-events: none;
  opacity: 0;
  /* 180–220ms crossfade between tools */
  transition: opacity 200ms var(--ease);
  will-change: transform;
}
.ghost.is-on { opacity: 1; }

.ghost__inner {
  transform: translate(-50%, -50%);
  transition: opacity 200ms var(--ease);
}
.ghost.is-swap .ghost__inner { opacity: 0; }

/* --- the sticky note preview -------------------------------------------- */

.ghost__note {
  width: 96px;                       /* spec: 90–100px square */
  height: 96px;
  border-radius: 14px;
  background: var(--note, #c9b8f5);
  opacity: 0.28;                     /* spec: 20–35% */
  box-shadow: 0 10px 26px -10px rgba(20, 16, 12, 0.5);
  /* the folded corner, kept in the preview */
  clip-path: polygon(0 0, 100% 0, 100% 74%, 74% 100%, 0 100%);
  position: relative;
}
.ghost__note::after {
  content: '';
  position: absolute;
  right: 0; bottom: 0;
  width: 26%; height: 26%;
  background: rgba(255, 255, 255, 0.55);
  clip-path: polygon(100% 0, 100% 100%, 0 100%);
}

/* --- the sticker silhouette --------------------------------------------- */

.ghost__stk {
  width: 56px; height: 56px;
  opacity: 0.3;                      /* spec: 25–35% */
  /* soft edges plus a floating shadow, so it reads as holographic */
  filter: blur(0.7px) drop-shadow(0 8px 12px rgba(20, 16, 12, 0.28));
}
.ghost__stk img {
  width: 100%; height: 100%;
  object-fit: contain;
  image-rendering: pixelated;
}

/* --- placement animations ----------------------------------------------- */

@keyframes notePop {
  from { opacity: 0.3; transform: scale(0.92); }
  to   { opacity: 1;   transform: scale(1); }
}

@keyframes stickerPop {
  from { opacity: 0.3; transform: scale(0.90); }
  to   { opacity: 1;   transform: scale(1); }
}
.stk { animation: stickerPop 460ms cubic-bezier(0.34, 1.42, 0.64, 1) both; }

@media (prefers-reduced-motion: reduce) {
  .ghost { display: none; }
}

/* --- narrow screens -------------------------------------------------------
   The scale shrinks as one piece, so every ratio in it survives. This is the
   only place vertical rhythm is allowed to change, and it keys off width — the
   thing that actually changes on a phone — not height. */
@media (max-width: 40rem) {
  :root {
    --sp-4: 1.125rem;   /*  18px */
    --sp-5: 1.5rem;     /*  24px */
    --sp-6: 2.25rem;    /*  36px */
    --sp-7: 3rem;       /*  48px */
    --sp-8: 4.25rem;    /*  68px */
  }
}

/* ==========================================================================
   30. THE PHONE AND THE TABLET  —  home page
   Desktop is the source of truth and is not touched below. Every rule here
   sits inside a max-width query, so at 1280 and above the cascade is byte for
   byte what it was.

   The tiers, from the scale in :root:
     ≥ 90rem / 1440   desktop      untouched
       80rem / 1280   laptop       untouched
       64rem / 1024   tablet       recomposed
       48rem /  768   mobile       recomposed
       22.5rem / 360  small phone  display steps trimmed
   ========================================================================== */

/* --- 30a. the type scale for a phone --------------------------------------
   A separate scale, not the desktop clamps read at a narrow width. Two things
   move in opposite directions and that is the whole point of writing it out.

   BODY GOES UP. 15px is comfortable on a monitor at arm's length and thin on a
   phone held in one hand — and below 16px iOS zooms the viewport the moment a
   field takes focus, and does not zoom back. 16px is the floor for that reason,
   not for taste.

   HEADINGS COME DOWN, HARD. --step-3 tops out at 56px, which is a wall across
   390px. The hierarchy is preserved by keeping the ratio between the steps, not
   by keeping their sizes. */
@media (max-width: 48rem) {
  :root {
    --step--1: 0.8125rem;    /* 13px — captions, never 12 */
    --step-0: 1rem;          /* 16px — body, the iOS zoom floor */
    --step-05: 1.0625rem;    /* 17px */
    --step-1: 1.375rem;      /* 22px */
    --step-2: 1.75rem;       /* 28px */
    --step-3: 2.125rem;      /* 34px */
  }
}

/* At 360 the display steps are still a shade wide; body and captions are not
   touched, because they are already at the floor and taking them below it is
   the iOS-zoom bug again. */
@media (max-width: 22.5rem) {
  :root {
    --step-1: 1.3125rem;     /* 21px */
    --step-2: 1.5625rem;     /* 25px */
    --step-3: 1.875rem;      /* 30px */
  }
}

/* --- 30b. touch ------------------------------------------------------------
   Hit areas, not sizes. Where a control is deliberately small — an ink swatch
   is 19px because a larger dot stops reading as a swatch — the box it draws
   stays exactly as designed and a transparent 44px pad is centred underneath
   it. Nothing moves, nothing is redrawn, and a thumb finds it. */
@media (pointer: coarse) {
  /* `.tool:not(.tool--obj)`, and the exclusion is load-bearing. `.tool--obj` —
     the pencil and the sticky note — is `position: absolute` a thousand lines
     up, and a bare `.tool { position: relative }` here is the same specificity
     later in the file, so it won every time the pointer was coarse: both
     objects fell back into the flow, collapsed from 46px wide to 12, and the
     note dropped 12px down the panel. It was invisible from a desk, because a
     mouse never matches this query — it broke the dock on every phone and on
     every touch tablet. The objects need no help from this rule in any case:
     they are already positioned, so their hit pad below resolves against them.

     AND `.mute` WAS THE SAME BUG, FOUND THE SAME WAY ONE PASS LATER.
     It is `position: fixed; right: 20px; bottom: 20px` a thousand lines up, and
     this rule outranked that on every touch device, so it dropped into the flow
     as the last child of body — where `right: 20px` on a relatively positioned
     element means "shift LEFT by twenty", and it landed at x = -20 with a third
     of itself off the edge of the screen. That is the half-circle stuck to the
     left margin beside the time slider. Like the objects above, it is already
     positioned and needs nothing from this rule for its hit pad to resolve. */
  .inks button,
  .tool:not(.tool--obj),
  .drawerpad button,
  .tools__tab,
  .tab,
  .stack { position: relative; }

  .inks button::after,
  .mute::after,
  .tool::after,
  .drawerpad button::after,
  .tools__tab::after,
  .tab::after,
  .stack::after {
    content: '';
    position: absolute;
    left: 50%;
    top: 50%;
    width: max(100%, 44px);
    height: max(100%, 44px);
    transform: translate(-50%, -50%);
    background: transparent;   /* a surface, never a mark */
  }

  /* Links that own a whole row get the whole row, width included — a footer
     link occupying a line should be tappable across that line, not just across
     its word. Height alone left them at 37x44. */
  .foot li > a {
    display: flex;
    align-items: center;
    width: 100%;
    min-height: 44px;
  }
  .row { min-height: 44px; }

  /* the email is the largest thing in the footer and was a 23px target */
  .foot__mail {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }

  /* and the buttons get it outright, since they have room */
  .btn { min-height: 44px; }
}

/* --- 30c. the phone's navigation ------------------------------------------
   A bar with the name at one end and one control at the other. The control is
   not a menu: it is the handle for the same reveal the desktop has, turned
   through ninety degrees — see section 38. Desktop keeps its own handle; the
   two are mutually exclusive and the switch is here, not in JS. */
.mbar { display: none; }

@media (max-width: 48rem) {
  /* `body[data-page='home'] .nav` sets display on the hero, so hiding the row
     needs a selector of the same weight — a bare `.nav` loses and the links
     stay on screen underneath the sheet. */
  .nav,
  body[data-page] .nav { display: none; }

  .mbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    position: fixed;
    top: 0; left: 0; right: 0;
    z-index: 94;
    height: calc(56px + var(--sa-top));
    padding: var(--sa-top) calc(var(--sa-right) + 1.25rem) 0 calc(var(--sa-left) + 1.5rem);
    pointer-events: none;              /* only the name and the button are live */
  }

  /* Set to sit ON the line the hamburger's bars are centred on, not above it:
     the two marks at either end of the bar are the only things up here and they
     have to read as one row. Quiet, because it is a label — the page's own
     first sentence is what should be loud, and that is at the other end of the
     screen. */
  .mbar__name {
    pointer-events: auto;
    font-size: 0.9375rem;
    line-height: 1;
    letter-spacing: -0.006em;
    color: var(--ink-2);
    opacity: 0.62;
    text-decoration: none;
    -webkit-tap-highlight-color: transparent;
    transition: opacity 220ms var(--ease);
  }
  .mbar__name:active { opacity: 1; }

  /* it goes with the page under the menu, the same way the bars do */
  .mbar.is-open .mbar__name { opacity: 0; transition: opacity 200ms var(--ease); }

  /* --- THERE IS NO BAR ANY MORE, ONLY A BUTTON ----------------------------
     What used to be here was a translucent strip across the top of the screen
     with a 10px backdrop-filter behind it. The intention was that the page's
     dotted field would still read through; what it did in practice was smear
     every line of text into grey mush on the way underneath, with a hard
     horizontal seam at the bottom edge where the blur stopped. It read as a
     rendering fault rather than as a surface, and the busier the page behind
     it, the worse it looked.

     A blurred strip is also the most expensive thing a fixed element can be on
     a page that scrolls: a backdrop-filter makes the compositor re-snapshot and
     re-blur everything behind it on every frame that might have changed, which
     while scrolling is all of them.

     So the strip went, and for a while the button carried its own surface — an
     opaque paper disc with a hairline and a soft shadow. Nothing was smeared,
     because nothing passed under anything.

     THE DISC HAS GONE TOO, now that the name sits at the other end of the bar.
     A disc alone in a corner is an object on the page; a disc opposite a plain
     line of text is one of two things that do not match. The pair has to read
     as one row, and the cheaper half to change is the button — a label cannot
     be given a disc without becoming a chip, which is a different thing again.

     What the disc was buying was contrast against whatever scrolled underneath,
     and what pays for it now is the page itself: everything that passes under
     this corner is paper or paper-2, and dark ink on either is legible without
     help. The 44px target is untouched; only the surface is. */
  /* THE NAME IS FOR THE TOP OF THE PAGE, AND IT LEAVES WITH IT.

     Its whole job is that until you scroll there is nothing above the fold
     saying whose site this is. Once you have scrolled there is: the work, the
     writing, the footer. And it has no surface — deliberately, a label given a
     disc becomes a chip — so anything that scrolls under it collides with it,
     which at the experience table is two headings written on top of each other.

     So it goes on the first scroll and comes back at the top. The handle does
     not: it is the navigation, and it carries its own paper. */
  .mbar { transition: opacity 240ms var(--ease); }
  .mbar.is-away { opacity: 0; pointer-events: none; }

  /* the hero's own content starts below the bar */
  body[data-page='home'] .canvas__intro { padding-top: calc(var(--sa-top) + 3.25rem); }
}

/* --- 30d. the hero, kept alive --------------------------------------------
   Desktop's hero breathes because the dot field drifts with the cursor. A
   phone has no cursor, so that motion simply never happens and the hero goes
   completely still — which is the difference between an immersive page and a
   screenshot of one.

   So the objects float instead. Two things make this safe rather than a fight
   with the drag code:

     · it animates --float, a registered custom property, NOT `transform`. Drag
       owns the transform and rewrites --x/--y on every pointer event; a
       keyframe on the same property would either be overwritten or would throw
       the object across the screen. --float is one more term in the same
       transform chain and the two never touch.
     · @property is what makes a length interpolate at all. Without the
       registration the value would jump between keyframes rather than glide,
       and in a browser that does not support it the variable stays at its
       initial 0px and the object simply sits still — which is a fine outcome.

   6px over 7s, offset per object so they are never in step. It reads as drift
   rather than as animation. */
@property --float {
  syntax: '<length>';
  inherits: false;
  initial-value: 0px;
}

@keyframes heroFloat {
  0%, 100% { --float: 0px; }
  50%      { --float: -6px; }
}

@media (max-width: 48rem) {
  .peelstk {
    animation: heroFloat 7s ease-in-out infinite;
    animation-delay: var(--float-delay, 0s);
  }
  .peelstk:nth-of-type(2) { --float-delay: -2.4s; }
  .peelstk:nth-of-type(3) { --float-delay: -4.1s; }
  /* the moment it is in your hand, or being transformed, it holds still */
  .peelstk.is-drag, .peelstk.is-xf, .peelstk.is-sel { animation-play-state: paused; }
}

@media (prefers-reduced-motion: reduce) {
  .peelstk { animation: none; }
}

/* --- 30e. the sections below the hero -------------------------------------
   The showcase and the index already fall to one column on their own — what
   they lack on a phone is measure and air, not a different structure. These
   are spacing and reading-width rules, not a relayout.

   The footer is the exception: a three-column grid built for a wide screen,
   which at 390px becomes two 161px columns with the email spanning them. */
@media (max-width: 48rem) {
  /* Line length. Prose that runs the full width of a phone is uncomfortable
     to read at any size; the gutter does most of the work, this caps the rest. */
  .story p,
  .story .line,
  .foot__note { max-width: 34ch; }

  /* one column, in reading order, with the email first as it is on desktop */
  .foot {
    grid-template-columns: 1fr;
    gap: var(--sp-5, 1.75rem);
  }
  .foot__col { min-width: 0; }

  /* the two link columns still sit side by side — they are short lists, and
     stacking them would push the page down for no gain */
  .foot__cols,
  .foot > .foot__col { break-inside: avoid; }

  /* Generous, consistent breathing room. One value, from the spacing scale,
     rather than each section carrying its own guess. */
  .story { padding-block: var(--sp-7, 3rem); }
  .showcase { padding-block: var(--sp-6, 2.25rem); }
  .index { padding-block: var(--sp-6, 2.25rem); }
}

/* --- 30f. images -----------------------------------------------------------
   Proportional, never stretched, never overflowing their column. Scoped to
   content imagery: the hero cut-outs, the tool illustrations and the pixel art
   all carry their own sizing, and a blanket `max-width: 100%` is exactly the
   reset that once shrank the sticker artwork to a third of its size. */
@media (max-width: 48rem) {
  .shotimg,
  .hl__shot img,
  .tick__card img,
  .fig img,
  .card img,
  .showcase img {
    max-width: 100%;
    height: auto;
    object-fit: contain;      /* scale, do not crop */
  }
}

/* --- 30g. motion -----------------------------------------------------------
   Adapted, not removed. The spring stays — it is most of what makes the page
   feel built rather than generated. What comes down is travel: a 40px entrance
   crossing a 1440px screen is a flourish, and the same 40px on a 390px screen
   is a third of the viewport moving, which reads as jank however smooth it is. */
@media (max-width: 48rem) {
  :root {
    --rv-rise: 12px;          /* was ~24px */
    --marq-speed: 0.6;        /* the ticker eases off on a small screen */
  }
  .rv { --rv-blur: 8px; }     /* less blur to resolve = less to composite */
}

/* ==========================================================================
   31. THE PHONE'S TOOLBAR  —  the same dock, on a button
   ==========================================================================

   The dock is 286px tall and pinned to the middle of the right edge. On a 390px
   screen that is 40% of the viewport standing across the hero, half of it off
   the side, exactly where a right thumb is trying to scroll. It has to fold
   away on a phone.

   WHAT IT FOLDS TO IS A BUTTON, NOT A DIFFERENT TOOLBAR. Open it and what comes
   up is the dock itself — the same 48px panel, the same two hairlines, the same
   pencil and sticky note sliding out of their slots, the same plus and the same
   undo, in the same order at the same sizes. Nothing here redraws a tool. The
   only new object on the page is the button, and all it does is decide whether
   the dock is up.

   That is the same trade the desktop already makes: past the hero the dock
   collapses to the edge tab and the tab brings it back. A tab hanging off the
   side of a phone is a 26px target under the scroll thumb, so the phone gets a
   corner button instead. Two affordances, one toolbar.

   767.98px, not 48rem. The brief puts the tablet at 768 and up, and 48rem would
   claim 768 itself — an iPad in portrait is exactly that wide. This is the
   exclusive complement of the tablet floor, not a thirteenth breakpoint; at 768
   and above every rule below is inert and the dock is the dock, byte for byte.  */

/* Off by default, so this is additive: at 768 and above the button does not
   exist and nothing in section 24 is overridden. */
.fab,
.fab__veil { display: none; }

@media (max-width: 767.98px) {

  /* --- the swap ---------------------------------------------------------- */

  /* the edge tab is what the DESKTOP folds to; the phone folds to the button */
  .tools__tab { display: none; }
  /* the welcome bubble is a desktop nicety with nowhere to sit beside a corner
     button; the shortcut flash stays, because Undo still has to say it did
     something */
  .tools__say:not(.tools__flash) { display: none; }

  .tools {
    /* 20px from both edges, plus whatever the device reserves for its rounded
       corners, its home indicator or its gesture bar — so the button is never
       against the absolute edge and never under the chrome */
    right: calc(20px + var(--sa-right));
    bottom: calc(20px + var(--sa-bottom));
    left: auto;
    top: auto;
    translate: none;
  }

  .fab { display: grid; }
  .fab__veil { display: block; }

  .tools__flash {
    top: 50%;
    right: calc(100% + 12px);
  }

  /* --- the dock, unchanged, moved ---------------------------------------- */

  /* Lifted out of the flow so the button stays put: `.tools` is pinned by its
     bottom edge, and a 286px panel in the flow above the button would push the
     button 286px up the screen. Absolute instead, hung off the top of it. */
  .tools__panel {
    position: absolute;
    right: 0;
    bottom: calc(100% + 16px);
  }

  /* And drawn exactly as the desktop draws it. An older rule narrows the panel
     to 44px and pads it below 46rem, which shifts every one of the measured
     offsets inside it — the pencil's tip, the note's edge, both hairlines. The
     whole point of this section is that the phone gets the same object, so the
     measurements are restored. */
  .tools__panel {
    width: var(--p-w);
    padding: 0;
    border-radius: 12px;
  }
  [data-tool-btn='select'] { width: 32px; height: 32px; }

  /* Visibility moves from the dock's own scroll state to the button. `.tools`
     is held open on a phone (see Rack.applyScope) so that placing, drawing and
     the ink layer keep the one condition they test for — which would otherwise
     leave the panel permanently on screen. Same specificity as the rule it
     replaces, later in the file, so it wins by order rather than by weight. */
  .tools.is-open .tools__panel {
    opacity: 0;
    /* it grows out of the button's own corner, rather than arriving from the
       edge of the screen — one object opening, not two objects appearing */
    transform: scale(0.9) translateY(10px);
    transform-origin: 100% 100%;
    pointer-events: none;
    visibility: hidden;
    transition:
      opacity 180ms var(--ease),
      transform 420ms var(--spring),
      visibility 0s linear 420ms;
  }

  .tools.is-dock .tools__panel {
    opacity: 1;
    transform: none;
    pointer-events: auto;
    visibility: visible;
    transition:
      opacity 160ms var(--ease),
      transform 460ms var(--spring),
      visibility 0s;
  }

  /* The sticker drawer hangs off the dock's left edge on a desktop, centred on
     a 286px panel. Here it keeps the edge and takes the panel's bottom instead
     of its middle, because the panel is now anchored from below. */
  .drawerpad {
    /* 48 + 8: the panel's width and the gap the desktop already leaves. Written
       out rather than read from --p-w, because that token is declared on the
       panel and this is its sibling — the var would resolve to nothing, take
       the whole declaration down with it, and drop the drawer straight back on
       top of the dock. */
    right: 56px;
    left: auto;
    top: auto;
    bottom: calc(100% + 16px);
    margin-right: 0;
    transform-origin: 100% 100%;
    /* Two columns. A single file of sprites is eight rows long and climbed most
       of the hero; two columns halve that for the same reach. */
    --pad-cell: 3rem;
    --pad-rows: 4;
    grid-template-columns: repeat(2, 1fr);
    width: calc(var(--pad-cell) * 2 + var(--pad-gap) + var(--pad-pad) * 2);
    max-height: min(38vh, calc(100dvh - 18rem));
    transform: translateY(8px) scale(0.94);
  }
  /* the base rule keeps a -50% in both states, for a drawer centred on a 286px
     dock; there is no centring left to preserve */
  .tools.has-pad .drawerpad { transform: none; }

  /* --- the button -------------------------------------------------------- */

  /* Width is `auto` against a 64px floor rather than a flat 64px, and that is
     what makes the status pill possible: the label below animates its own
     max-width from 0, the button's width follows it continuously, and the
     circle stretches into a pill instead of snapping to a new box. */
  .fab {
    position: relative;
    grid-auto-flow: column;
    align-items: center;
    justify-content: center;
    /* 48, which is the dock's width. The button and the panel it opens are one
       column stacked in the corner, the same width top to bottom, rather than a
       disc parked under a narrower object — 64px made it the largest thing in
       the corner and it read as the subject rather than the control. It is also
       still a 48px touch target, four over the minimum. */
    min-width: 48px;
    width: auto;
    height: 48px;
    padding: 0;
    border: 1px solid transparent;
    border-radius: 999px;
    /* Filled, in the accent. A white disc on a #f7f6f3 page is a white shape on
       an off-white page — it has a shadow and nothing else, and at 64px across
       with a 22px glyph inside it read as an empty plate. This is the only
       floating control on a phone and the only thing on the page that is purely
       an affordance, so it takes the one colour the site reserves for exactly
       that: the same #7c3aed as the armed tool in the dock.

       The rule the rest of this section follows from: purple is the control,
       charcoal is the status. The dock's own speech bubbles are already #2c2c2e
       — status has been charcoal on this site all along. */
    background: #7c3aed;
    /* a wide accent-tinted glow to float it, a neutral one to seat it */
    box-shadow:
      0 10px 24px -8px rgba(124, 58, 237, 0.58),
      0 2px 5px rgba(20, 16, 12, 0.10);
    color: #ffffff;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition:
      min-width 380ms var(--spring),
      height 380ms var(--spring),
      padding 380ms var(--spring),
      background 240ms var(--ease),
      color 240ms var(--ease),
      box-shadow 300ms var(--ease),
      transform 380ms var(--spring);
  }

  /* 16px, and the number comes from the dock rather than from the circle it
     sits in. The dock's own Move chip draws this exact cursor at 15px, and when
     the dock is up the two are on screen together, sixteen pixels apart — so
     sizing this one as a fraction of its button made the same glyph appear at
     two different sizes in one column.

     A pixel over the dock's 15 rather than equal to it: this one is white on a
     filled disc, and a light mark on a dark ground reads slightly smaller than
     the same mark drawn dark on white. */
  .fab__ico {
    display: grid;
    place-items: center;
    width: 16px;
    height: 16px;
  }
  .fab__ico svg { width: 16px; height: 16px; }

  .fab__say {
    max-width: 0;
    margin-left: 0;
    overflow: hidden;
    white-space: nowrap;
    font-size: 0.9375rem;
    font-weight: 500;
    letter-spacing: -0.01em;
    opacity: 0;
    transition:
      max-width 380ms var(--spring),
      margin 380ms var(--spring),
      opacity 200ms var(--ease);
  }

  /* a fine pointer inside this range — a small window on a laptop, a tablet in
     a stand — still gets the lift; a thumb never sees it */
  :root:not(.hover-cold) .fab:hover {
    background: #6d28d9;
    box-shadow:
      0 14px 30px -8px rgba(124, 58, 237, 0.68),
      0 2px 5px rgba(20, 16, 12, 0.10);
    transform: translateY(-2px);
  }
  /* 96% and back on the spring, over the 97.5% every other button gets: this
     one is 64px and needs a deeper dip to register as pressed at that size */
  .fab.is-press { transform: scale(0.96); transition-duration: 110ms; }

  /* The dock is up, so the button is the thing that puts it away — pressed in
     rather than floating out: a deeper purple and the glow pulled back in. */
  .tools.is-dock .fab {
    background: #5b21b6;
    box-shadow:
      0 5px 13px -6px rgba(124, 58, 237, 0.52),
      0 1px 3px rgba(20, 16, 12, 0.10);
  }

  /* --- the status pill --------------------------------------------------- */

  /* A live tool folds the dock away, and the button becomes the thing telling
     you what is in your hand — and the way to put it down. */
  /* Charcoal, which is what everything on this site that is telling you
     something is already made of — the welcome line and the shortcut flash are
     both #2c2c2e. Keeping the pill purple would have made purple mean both "tap
     me" and "here is what you are holding", and the two states would have
     differed only in outline. */
  /* Height is inherited now that the circle is 48 too, so the pill grows
     sideways out of the circle and the dock above it never moves. */
  .tools.is-live .fab {
    padding: 0 0.9375rem 0 0.8125rem;
    background: #1f1c19;
    color: #ffffff;
    box-shadow:
      0 10px 24px -8px rgba(20, 16, 12, 0.42),
      0 2px 5px rgba(20, 16, 12, 0.08);
  }
  :root:not(.hover-cold) .tools.is-live .fab:hover {
    background: #2c2823;
    box-shadow:
      0 14px 30px -8px rgba(20, 16, 12, 0.48),
      0 2px 5px rgba(20, 16, 12, 0.08);
  }

  .tools.is-live .fab__say {
    max-width: 10rem;
    margin-left: 0.5rem;
    opacity: 1;
  }
  /* The sticker tool leaves the dock up, and while the dock is up the button is
     simply the button — no pill. Two reasons. The dock already says which tool
     is live, on the same chip the desktop lights up, so a pill beside it is the
     same fact twice. And the pill is 16px shorter than the circle, which with
     the dock anchored to the top of this button would have slid the whole dock
     down the screen the moment you tapped Add. */
  .tools.is-dock.is-live .fab {
    padding: 0;
    background: #5b21b6;
    color: #ffffff;
    box-shadow:
      0 5px 13px -6px rgba(124, 58, 237, 0.52),
      0 1px 3px rgba(20, 16, 12, 0.10);
  }
  .tools.is-dock.is-live .fab__say { max-width: 0; margin-left: 0; opacity: 0; }

  /* --- dismissal --------------------------------------------------------- */

  /* Behind the dock and inside `.tools`, both deliberately. Behind, so it never
     eats a tap meant for a tool; inside, because the canvas places a note
     wherever you press and its listener already skips anything within the dock
     — so folding it away by tapping the page cannot also drop a note where you
     tapped. It is inert until the dock is up, so it can never be the reason the
     page stops scrolling. */
  .fab__veil {
    position: fixed;
    inset: 0;
    z-index: -1;
    background: transparent;
    pointer-events: none;
  }
  .tools.is-dock .fab__veil { pointer-events: auto; }

  /* --- the ink palette ----------------------------------------------------- */

  /* It flies out to the LEFT of the dock on a desktop, alongside the pen. Here
     the pen folds the dock away — you armed it to reach the canvas — so the
     palette has nowhere to be alongside, and it becomes a row above the button
     instead, level with the "Drawing" pill that replaced the dock. */
  .inks {
    right: 0;
    left: auto;
    top: auto;
    bottom: calc(100% + 12px);
    margin-right: 0;
    grid-auto-flow: column;
    gap: 12px;
    padding: 8px 12px;
    border-radius: 999px;
    transform-origin: 100% 100%;
    transform: translateY(8px) scale(0.92);
  }
  /* 20px is a swatch; 26 is a swatch you can hit with a thumb without it
     starting to read as a button */
  .inks button { width: 26px; height: 26px; }

  /* the two can only meet via a keyboard shortcut, but a row of swatches laid
     over the dock is not a state worth having */
  .tools.is-dock .inks { opacity: 0; pointer-events: none; }

  /* --- the rest of the hero ---------------------------------------------- */

  /* The rotating hint runs along the bottom of the hero and the button now sits
     at the end of that line, so the hint gets a right edge to wrap against
     rather than sliding underneath it. */
  .canvas__hint {
    /* 20 to the screen edge plus the button's own 48 */
    right: calc(68px + var(--sa-right));
    max-width: 24ch;
  }

  /* --- drawing under a finger -------------------------------------------- */

  /* The pen has to accept a finger on a phone, because there is no second
     pointer — and a finger only draws if the browser stops treating the press
     as the start of a scroll. This is the smallest surface that can be true of:
     the drawing surface itself, and only while the pen is actually armed. The
     bar, everything below the hero and the whole of every other page keep
     scrolling normally, and putting the pen down hands the hero back. */
  body[data-tool='marker'] .canvas { touch-action: none; }
}

/* ==========================================================================
   32. THE PHONE'S PROJECTS  —  the same card, uncollided
   ==========================================================================

   The showcase is a grid of preview panels, each with a caption under it. The
   caption is a two-column row: the title on the left, `meta` pushed to the
   right with `margin-left: auto` and set `text-align: right`.

   That row is the only thing about this section that does not survive a phone.
   At 390px the title needs two lines and the meta needs two lines, and they
   take them SIDE BY SIDE — "Designing the Digital / Identity of Cypherock X0"
   against a ragged-right "Product identity, / 2026, Bengaluru" — so the two
   read as one collided block with a hole down the middle. Right-aligned text
   in a 150px column is hard to read on its own; alongside a wrapping title it
   stops being a caption at all.

   The fix is the row, and only the row: the two parts stack instead of sitting
   beside each other. Same panel, same 4px radius, same 1.45 frame, same
   scroll-driven entrance, same type at the same sizes and the same colours.
   Nothing here is a mobile design — it is the desktop card with its caption
   given the second line it needs.

   767.98px, as in section 31: at 768 and above every rule here is
   inert, and the caption is the row it has always been.                     */

@media (max-width: 767.98px) {
  .wcard__meta {
    flex-direction: column;
    align-items: flex-start;
    /* the 1rem that separated two columns is far too much between two stacked
       lines — they belong to each other */
    gap: 0.125rem;
  }

  /* `margin-left: auto` is what pushed the meta to the far side of the row.
     In a column it pushes nothing, but it stays explicit here so the rule
     reads as the reversal of the one above it rather than a leftover. */
  .wcard__tags {
    margin-left: 0;
    text-align: left;
  }
}

/* ==========================================================================
   33. THE PHONE'S CASE STUDIES
   ==========================================================================

   Opening a project on a phone gave you the desktop study at desktop width:
   every heading, paragraph, block and figure 960px wide inside a 390px panel,
   with the right two thirds of every line off the screen.

   ONE CAUSE, INHERITED BY EVERYTHING. `.drawer__study` is a two-column grid
   that collapses to `grid-template-columns: 1fr` below 60rem. `1fr` is
   shorthand for `minmax(auto, 1fr)`, and that `auto` minimum is the largest
   min-content contribution of anything in the track. One block inside the
   study cannot get narrower than 960px, so the track cannot either — and every
   descendant then stretches to the track. Nothing else was wrong; there was one
   number, and a thousand elements repeating it.

   `minmax(0, 1fr)` removes the automatic minimum, so the track is the width of
   the panel and the study lays out against the screen. Anything that genuinely
   cannot fit then overflows on its own, where it can be seen and dealt with,
   instead of silently widening the page.                                    */

@media (max-width: 767.98px) {
  .drawer__study,
  .proj__grid { grid-template-columns: minmax(0, 1fr); }

  /* THE ONE THING THAT DID NOT FOLLOW FROM THE TRACK.

     A `shot` block writes its own caps inline, from the figures in content.js:
     `max-width: 900px; max-height: 640px; width: auto`. With the width auto it
     is the HEIGHT cap that sizes the image — 640px tall comes out 612px wide —
     and a 900px max-width never binds inside a 350px column, so the picture
     simply hangs out of the frame.

     `!important` because the cap it has to beat is an inline style, and that is
     the only thing that beats one. The height cap is left alone: once the width
     is bounded the aspect ratio takes care of the rest. */
  .shotimg { max-width: 100% !important; height: auto; }

  /* A table of long phrases has a min-content width that no column on a phone
     can satisfy, so it gets to scroll on its own rather than push the page —
     the columns stay aligned and readable, and the reader swipes the table
     instead of the article. No study carries one today; this is here so that
     the first one to do so does not repeat the overflow above. */
  .drawer__scroll table {
    display: block;
    max-width: 100%;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
  }

  /* --- the section rail, kept on screen ---------------------------------- *

     On a desktop the rail is a sticky column beside the article: you always
     know which section you are in and you can jump to any other. Below 60rem it
     becomes `position: static` and wraps to three lines at the very top, so it
     scrolls away with the first paragraph and never comes back — the one piece
     of navigation in a nine-section article, gone after two swipes.

     WHY IT CANNOT SIMPLY BE MADE STICKY. `position: sticky` on a GRID ITEM can
     only travel inside its own grid area. In the collapsed single-column grid
     that area is exactly the rail's own height, so it comes unstuck the instant
     it starts. The study is laid out as blocks here instead, which makes the
     rail's containing block the whole article — the same relationship it has to
     the content column on a desktop, arrived at differently.

     AND WHY IT DOES NOT BLOCK THE READING. It is one line, not three: the chips
     sit in a row that scrolls sideways, so nine sections cost 44px of height
     instead of 132. It is the pattern the standalone project page already uses
     at 46rem — a translucent blurred strip with a hairline under it — so this
     is the same object in a second place rather than a new one. */
  .drawer__study { display: block; }

  .drawer__study > .drawer__rail,
  .drawer__study--darktop .drawer__rail {
    position: sticky;
    /* NEGATIVE, by exactly the scroller's own top padding. A sticky element's
       constraint rectangle is inset by that padding, so `top: 0` pinned the
       rail 42px down the panel and left a strip above it that the article
       scrolled through — a gap that looked like a rendering fault. Pulling it
       back by the same amount puts it flush against the header bar. */
    top: calc(-1 * var(--drawer-top));
    z-index: 6;
    display: flex;
    flex-wrap: nowrap;
    align-items: center;
    /* the chips carry 0.7rem of their own padding a side now */
    gap: 0.15rem;
    /* full bleed, so the strip reads as chrome across the panel rather than a
       floating box — the negative margin matches the scroller's own padding and
       is clipped by its overflow-x */
    margin-inline: calc(-1 * clamp(1.25rem, 4vw, 2.5rem));
    margin-bottom: 1.5rem;
    padding: 0.7rem clamp(1.25rem, 4vw, 2.5rem);
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
    -webkit-backdrop-filter: blur(12px);
    backdrop-filter: blur(12px);
  }
  .drawer__rail::-webkit-scrollbar { display: none; }

  /* --- THE BAR FOLLOWS THE BAND ------------------------------------------- *

     The desktop rail is transparent and recolours its links per band. Here the
     bar has a background too, so BOTH have to move, and the hard part is that
     they move in opposite directions: the surface goes light while the label
     goes dark. Interpolate them over the same 320ms and their luminances cross
     at almost exactly the halfway point — 245→35 against 18→255 meet at t=0.51
     — so for one frame the label is the same brightness as the bar it is on.
     That was the grey-on-grey in the screenshot, and matching the durations is
     what causes it rather than what fixes it.

     So the two are deliberately out of step. The surface glides over 320ms; the
     ink waits 240ms and then switches in 80. By the time it moves, the bar is
     four fifths of the way to its new tone, and the worst contrast at any
     instant during the whole crossing is about 2.9:1 — on either side of the
     switch, never at the crossing point, because there no longer is one.

     ONE STATE, NOT TWO. Every colour here comes off `.is-dark` on the bar,
     including the ink. The links' own `data-ink` — the per-band signal the
     desktop uses — is explicitly overridden, because two systems colouring the
     same text from two separately-timed sources is what broke twice. */
  .drawer__study .drawer__rail,
  .drawer__study--darktop .drawer__rail {
    --bar: rgba(247, 246, 243, 0.92);
    --bar-line: var(--line-soft);
    --bar-ink: #6f6a63;
    --bar-on: #14100c;

    background: var(--bar);
    border-bottom: 1px solid var(--bar-line);
    transition:
      background-color 260ms var(--ease),
      border-color 260ms var(--ease);
  }

  .drawer__study .drawer__rail.is-dark,
  .drawer__study--darktop .drawer__rail.is-dark {
    --bar: rgba(18, 38, 51, 0.94);
    --bar-line: rgba(255, 255, 255, 0.12);
    --bar-on: #ffffff;
  }

  /* THE LABELS COME OFF THE BAR ENTIRELY, AND THAT IS THE WHOLE FIX.
     Three attempts failed here for one reason, and it is arithmetic rather than
     taste: text sitting directly on a surface that travels from light to dark
     cannot stay readable. The surface passes through every luminance between
     its two tones on the way — including the ink's own — so at some instant of
     every crossing the two match. Measured: rgb(126,134,138) on
     rgba(133,143,147) at 1.1:1. Crossfading the ink too only moves the
     collision; a single fixed ink still gets run over (rgb(132,128,122) on
     rgba(79,93,102), 2.4:1); staggering the timings cannot help because the
     band crossing and the section change are independent triggers arriving at
     arbitrary offsets.
     So every chip carries its own opaque pill and the ink lives on that. The
     pill and its ink are one fixed pair — 5.6:1, invariant, no transition, no
     midpoint — and the bar is then free to glide between tones behind them,
     which is the part that was asked to be smooth. It reads as a segmented
     control: nearly invisible on the light bar, a light track on the dark one. */
  .drawer__study .drawer__rail,
  .drawer__study .drawer__rail.is-dark,
  .drawer__study--darktop .drawer__rail,
  .drawer__study--darktop .drawer__rail.is-dark {
    --bar-chip: #efece6;
    --bar-ink: #5f5a53;
  }

  /* THE SELECTED CHIP DOES NOT TAKE PART IN ANY OF IT.

     Two staggered transitions was the second attempt and it was still wrong:
     the bar flips when you cross a band and the selected chip moves when you
     enter a section, two independent triggers at arbitrary offsets, so there is
     always some pair of moments where a half-faded label sits on a half-faded
     surface. The choreography cannot be tuned, because the events it is trying
     to choreograph do not arrive together.

     So the selected state stops being a text colour and becomes a filled pill —
     white on #7c3aed, the same purple that means "the current thing" on the
     phone's toolbar. Its contrast is 4.6:1 against its own background and owes
     the bar nothing, at any point in any transition. The bar underneath is then
     free to glide with the band, which is what was asked for.

     EVERY chip carries the padding, not just the selected one, so the pill
     appears and disappears without a single width changing — otherwise the strip
     would re-lay-out and jump sideways each time the active section moved. */
  .drawer__rail .rail__link,
  .drawer__rail .rail__link[data-ink='light'],
  .drawer__rail .rail__link[data-ink='dark'],
  /* aria-current is set by the same scroll-spy and the desktop paints it white
     on a light band — which over the light bar is white on off-white. It is
     covered explicitly here rather than left to specificity. */
  .drawer__rail .rail__link[data-ink='light'][aria-current='true'],
  .drawer__rail .rail__link[data-ink='dark'][aria-current='true'] {
    padding: 0.3rem 0.7rem;
    border-radius: 999px;
    background: var(--bar-chip);
    color: var(--bar-ink);
    /* and the pill does not fade either. A purple fading up from transparent is
       a half-purple, and white on half-purple over a light bar measures 2.4:1
       at the midpoint. The chip is a scroll-spy — it is meant to answer "where
       am I" instantly, so switching instantly is also the better interaction. */
    transition: none;
  }

  .drawer__rail .rail__link.is-active,
  .drawer__rail .rail__link[data-ink='light'].is-active,
  .drawer__rail .rail__link[data-ink='dark'].is-active {
    background: #7c3aed;
    color: #ffffff;
    font-weight: 600;
  }

  /* hover darkens the ink WITHIN the chip; --bar-on is the bar's own contrast
     colour and would put white on the light pill when the bar goes dark */
  :root:not(.hover-cold) .drawer__rail .rail__link:hover { color: #14100c; }

  .drawer__rail .rail__link {
    height: auto;
    line-height: 1.6;
    /* a chip that wraps is a chip that breaks the row */
    white-space: nowrap;
  }

  /* The heading has to land BELOW the strip now, not underneath it. --nav-land
     is the desktop allowance; the rail's own height is added to it here, so a
     tap on a chip still puts the heading on the reading line. */
  .drawer__scroll .sec__anchor { scroll-margin-top: calc(var(--nav-land) + 3rem); }
}

/* --- 34. the phone's footer ------------------------------------------------
   Shorter by a whole column, and the links lie down.

   PAGES GOES, and not because it is in the way. 48rem is the width at which the
   header's row of links is replaced by the hamburger, so below it every page in
   that column is already one tap away in the menu — the footer list is a second
   copy of the same array on the same screen. It is hidden rather than removed:
   the markup is what proves the header and the footer are generated from one
   source, and a harness checks exactly that.

   LINKS GOES TOO, for the same reason Pages does. Every mark in it is already
   on the screen: the address above it is the mail link written out in full, and
   the rest are in the menu behind the hamburger. Laid down as a row of four
   squares under a heading it read as a toolbar rather than as a footer — a
   second navigation, at the end of a page whose whole job by then is to hand
   you the sky.

   Both are hidden rather than removed: the markup is what proves the header and
   the footer are generated from one source, and a harness checks exactly that.

   What is left is the address, the note and the copyright. */
@media (max-width: 48rem) {
  .foot__col--pages,
  .foot__col--links { display: none; }
}

/* --- 35. the phone's end of the page --------------------------------------
   The paper detaches from the sky at the end: it insets 32px each side, rounds
   its bottom corners and lifts a shadow. Three properties, and one of them is
   ruinous here.

   `.sheet` IS THE PAPER, AND THE PAPER CONTAINS THE ENTIRE PAGE. Changing its
   horizontal margin re-lays-out every box in the document, and it was being
   changed continuously while you scrolled into the footer. Measured with
   Chromium's own layout counters, over a scroll from just above the footer to
   the end: 48 layout passes costing 74ms, against 3 costing 1ms with the margin
   held still. It is worse than one pass per write, because the frame loop reads
   geometry from several modules and a write between two reads makes the second
   one force its own pass. That stretch is exactly where the scrolling felt like
   it was dragging.

   So on a phone the margin still does not animate. The gap comes from
   `clip-path` instead, which trims the painted paper back from the screen edge
   without moving a single box — it is a paint operation, so the 48 layout
   passes stay at zero and the horizontal travel comes back.

   Two things follow from trimming rather than re-laying-out. The content does
   not re-centre, so the gutter has to be wide enough to give the gap away —
   see below. And `clip-path` cuts the box-shadow off with everything else,
   because a shadow is painted outside the border box and the border box is
   exactly what was just clipped away — so the lift is redrawn below, by the
   outro.

   THE GUTTER PAYS FOR THE GAP. On a desktop the paper can inset 32px without
   crowding anything, because `.col` is a fixed 38.5rem sitting in 1384px of
   paper — there is slack on both sides, and the text does not move at all as
   the edge comes in. A phone has no slack: `.col` is `100% - 2×gut`, so every
   pixel the paper trims is a pixel off the gutter.

   Giving that slack back by narrowing the sheet for real was measured and
   rejected. At 390px the footer's lead paragraph rewraps to a third line past
   a 24px inset, and the document height wanders 44px over the range — and the
   height is the input to the detachment itself, so it feeds back into the
   scroll and shakes. Instead the gutter is widened by the full trim up front,
   once, statically. The content is then laid out exactly where it will stand
   at the end, and holds that position for the whole scroll: the edge travels,
   the words never move, nothing rewraps, and at full detachment the paper sits
   the ordinary gutter away from the text.

   Above 48rem nothing changes at all: that is the composition the desktop was
   measured against, and it is not the one that was dragging. */
@media (max-width: 48rem) {
  :root {
    /* the one number to turn — how far the paper leaves the screen edge, and
       therefore how much wider the resting gutter has to be */
    --sheet-trim-max: 18px;
    --gut: calc(clamp(1.25rem, 4vw, 2.5rem) + var(--sheet-trim-max));
  }

  /* Driven off --sheet-lift rather than --sheet-inset: lift is unitless, so the
     trim can be a fraction of its own maximum instead of a fraction of the
     desktop's 32px. Declared on both because the outro is a sibling of the
     sheet, not a descendant — it cannot inherit the value, so the scroll loop
     writes the same three properties to each of them. */
  .sheet,
  .outro { --sheet-trim: calc(var(--sheet-lift, 0) * var(--sheet-trim-max)); }

  .sheet {
    margin: 0;
    clip-path: inset(
      0 var(--sheet-trim, 0px)
      round 0 0 var(--sheet-round, 0px) var(--sheet-round, 0px)
    );
  }

  /* The outro floats on the sky, not on the paper. Nothing trims it, so it is
     held at the plain gutter — the widened one would rewrap its sentence for a
     gap that never reaches it. */
  .outro {
    --outro-pad: calc(var(--gut) - var(--sheet-trim-max));
    padding-inline: var(--outro-pad);
  }

  /* The shadow the trim costs, given back. An empty box standing exactly where
     the sheet's last viewport is — same inset, same bottom radius — carrying
     nothing but the shadow the sheet can no longer paint. It is not clipped by
     anything, and a non-inset shadow never paints over its own box, so the only
     part of it that lands anywhere is the part on the sky: down the two gaps
     and out under the bottom edge. */
  .outro::after {
    content: '';
    position: absolute;
    /* An absolutely positioned box is offset from its containing block's
       PADDING box, so the outro's own gutter has to come back off before this
       lines up with the paper's edge. */
    left: calc(var(--sheet-trim, 0px) - var(--outro-pad));
    right: calc(var(--sheet-trim, 0px) - var(--outro-pad));
    bottom: 100%;
    height: 100vh;
    border-radius: 0 0 var(--sheet-round, 0px) var(--sheet-round, 0px);
    pointer-events: none;
    box-shadow:
      0 calc(var(--sheet-lift, 0) * 18px) calc(var(--sheet-lift, 0) * 46px)
        calc(var(--sheet-lift, 0) * -18px) rgba(20, 16, 12, calc(var(--sheet-lift, 0) * 0.28));
  }
}

/* --- 36. the phone's floating controls -------------------------------------
   `.mute` is declared bottom right, 20px in, which on a phone is underneath the
   toolbar's button to the pixel: two round controls of different sizes stacked
   on one corner. It used to be moved up by a dock's height to clear it.

   IT NO LONGER HAS TO CLEAR ANYTHING, because the dock is not there when the
   mute is. The mute only exists at the end of the page — its opacity IS --end —
   and by then the drawing surface has been taken away by `body.past-hero`
   anyway, so the dock is a button for a tool that cannot be used. It leaves on
   the same number the mute arrives on, read backwards, which puts a crossfade
   in the corner rather than a disappearance: the dock goes as the mute comes,
   in the same place, and the sky ends up with one control on each side.

   Above 48rem the dock stays where it is — the sheet detaches there too, but it
   detaches into a window with room for both. */
@media (max-width: 48rem) {
  .mute {
    right: calc(20px + var(--sa-right));
    bottom: calc(20px + var(--sa-bottom));
  }

  .tools {
    opacity: calc(1 - var(--end, 0));
    /* `pointer-events` is a keyword, so it cannot be derived from --end here —
       the scroll loop writes the opposite one. */
    pointer-events: var(--end-events-inv, auto);
    transition: opacity 420ms var(--ease);
  }

  /* and the slider clears the safe area on the other side */
  .controls {
    left: calc(20px + var(--sa-left));
    bottom: calc(20px + var(--sa-bottom));
  }

  /* BOTH PODS GO TO 44px. 32 is the measured desktop size and it is a size for
     a cursor: a pointer lands where it is pointed, so the pod only has to be as
     big as it looks. A finger does not, and 44px is the number the rest of this
     phone already uses — the footer's marks were squares of exactly this, and
     the dock's button was 48. Two 32px pods alone on the sky read as leftovers
     from a bigger screen. Everything inside scales with them so the pod does
     not just get taller around the same small parts. */
  .timepod {
    height: 2.75rem;
    gap: 0.7rem;
    padding: 0 0.85rem 0 0.7rem;
  }
  .timepod__orb { width: 1.15rem; height: 1.15rem; }
  .timepod__track { width: 6.5rem; }
  .timepod__ticks { height: 1.15rem; }
  .timepod__ticks i { height: 0.44rem; }
  .timepod__ticks i.on { height: 1.1rem; }

  .mute { width: 2.75rem; height: 2.75rem; }
  .mute svg { width: 1.15rem; height: 1.15rem; }
}

/* --- 36a. the phone's hero, upside down ------------------------------------
   The desktop hero reads top-left first: headline, tags, buttons, and the four
   objects arranged in the space around them. Straight onto a phone that becomes
   a wall of type against the top edge with everything else pushed below the
   fold, and the objects — the part that says this page is a surface you can
   move things on — end up in the dregs underneath.

   So on a phone the two swap. The intro is anchored to the BOTTOM of the hero
   and the objects take the upper two fifths, which is the order a phone screen
   actually wants: you land on an empty dotted field with four things lying on
   it, and the sentence is waiting at the bottom where your thumb already is.
   Their coordinates are in content.js, under `mobile`.

   The headline also stops being narrow. `18ch` is a desktop measure — it exists
   so the line breaks before it runs into the objects on its right, and there is
   nothing to its right here. At the bottom of a 390px screen a 226px column of
   type with 120px of nothing beside it reads as a mistake rather than as a
   margin, so it takes the full width between the gutters and wraps there.

   48rem, not 46, because that is where `.mbar` takes over and where Peel's own
   `narrowNow` switches to the mobile coordinates. All three have to agree or
   the text lands at the bottom while the objects are still arranged for a
   headline at the top. */
@media (max-width: 48rem) {
  body[data-page='home'] .canvas__intro {
    top: auto;
    bottom: clamp(4rem, 10vh, 5.5rem);
    left: 1.5rem;
    max-width: calc(100vw - 3rem);
    /* the bar's clearance was for a hero that started under it */
    padding-top: 0;
  }

  body[data-page='home'] .canvas__headline { max-width: 100%; }
}

/* --- 36b. the phone's scroll indicator -------------------------------------
   There isn't one. A narrow window in a desktop browser still draws the page's
   scrollbar over the right edge — a grey 6px bar that fades in the moment you
   start scrolling and sits directly on top of the gap the paper opens as it
   detaches, which is the one thing at the end of this page that is supposed to
   be sky. A real phone never draws it; this is the emulator's edge showing.

   Hidden the same way `.drawer__rail` hides its own, and only below 48rem: a
   desktop window keeps its scrollbar, because there it is the only thing
   telling you how much page is left. */
@media (max-width: 48rem) {
  html {
    scrollbar-width: none;
    -ms-overflow-style: none;
  }
  html::-webkit-scrollbar { display: none; }
}

/* --- 37. the paper --------------------------------------------------------
   The resume viewer. Built out of the site's own parts on purpose: the ink the
   menu is written on, the corner radius the project cards use, the spring
   everything else moves with. The point of showing the pages here rather than
   linking to the file is that it should not feel like leaving. */
.paper {
  position: fixed;
  inset: 0;
  z-index: 96;
  display: grid;
  place-items: center;
  padding: clamp(0.75rem, 3vw, 2.5rem);
  padding-top: max(clamp(0.75rem, 3vw, 2.5rem), var(--sa-top));
  padding-bottom: max(clamp(0.75rem, 3vw, 2.5rem), var(--sa-bottom));
}
.paper[hidden] { display: none; }

.paper__veil {
  position: absolute;
  inset: 0;
  background: rgba(20, 16, 12, 0.34);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  opacity: 0;
  transition: opacity 320ms var(--ease);
}
.paper.is-up .paper__veil { opacity: 1; }

/* One column, capped at a comfortable reading width for a letter page and
   never taller than the room it has. `min-height: 0` because a grid item's
   automatic minimum is its content, and without it a 1553px-tall stack of
   pages pushes the window past the bottom of the screen instead of scrolling
   inside it — the same auto-minimum that broke the case studies. */
.paper__win {
  position: relative;
  display: flex;
  flex-direction: column;
  width: min(100%, 54rem);
  max-height: 100%;
  min-height: 0;
  border-radius: clamp(12px, 2vw, 18px);
  background: var(--paper-2);
  border: 1px solid var(--line);
  box-shadow: 0 32px 80px -28px rgba(20, 16, 12, 0.32);
  overflow: hidden;
  opacity: 0;
  transform: translate3d(0, 18px, 0) scale(0.985);
  transition:
    opacity 300ms var(--ease),
    transform 520ms var(--spring-soft, var(--ease));
}
.paper.is-up .paper__win { opacity: 1; transform: none; }

.paper__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  flex: 0 0 auto;
  padding: 0.6875rem 0.6875rem 0.6875rem 1.125rem;
  border-bottom: 1px solid var(--line);
}
.paper__title {
  margin: 0;
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: rgba(58, 53, 47, 0.58);
}
.paper__acts { display: flex; align-items: center; gap: 0.5rem; }

.paper__get {
  display: inline-flex;
  align-items: center;
  min-height: 36px;
  padding: 0 0.875rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--ink);
  text-decoration: none;
  transition: background-color 200ms var(--ease), color 200ms var(--ease);
}
:root:not(.hover-cold) .paper__get:hover {
  background: var(--ink);
  border-color: var(--ink);
  color: var(--paper);
}

.paper__x {
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: rgba(58, 53, 47, 0.7);
  cursor: pointer;
  transition: background-color 200ms var(--ease), color 200ms var(--ease);
}
.paper__x svg { width: 16px; height: 16px; }
:root:not(.hover-cold) .paper__x:hover {
  background: rgba(20, 16, 12, 0.07);
  color: var(--ink);
}

/* The pages themselves. `overscroll-behavior: contain` is what stops a flick
   at the end of the last page from carrying on into the page underneath —
   the body is pinned while this is open, so without it the scroll simply
   stops dead and feels broken. */
.paper__scroll {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  display: grid;
  /* EVERY PAGE AT ITS FULL HEIGHT. The rows were implicit `auto` in a grid
     whose own height is capped by the window, and an item that takes its
     height from an aspect-ratio contributes nothing to that sizing — so the
     tracks collapsed and each page was clipped partway down. `max-content`
     sizes every row to the whole picture and lets the scroller scroll. */
  grid-auto-rows: max-content;
  align-content: start;
  gap: clamp(0.75rem, 2vw, 1.25rem);
  padding: clamp(0.75rem, 2vw, 1.25rem);
  background: var(--paper);
}

/* 100% of the width it is given, which is the whole point — a resume shown at
   a third of the screen in a grey plugin box is the thing this replaces. */
.paper__pg {
  position: relative;
  width: 100%;
  height: auto;
  border-radius: 6px;
  background: var(--paper-2);
  border: 1px solid var(--line);
  overflow: hidden;
  box-shadow: 0 6px 18px -12px rgba(20, 16, 12, 0.45);
}
.paper__pg img { display: block; width: 100%; height: auto; }

/* The link hotspots. Transparent, because the blue and the underline are
   already printed in the picture underneath — all this adds is the part a
   picture cannot have, which is somewhere to go. The radius and the tint on
   hover are the only marks it makes, and they are there so a link that is
   slightly off its word still reads as deliberate. */
.paper__lnk {
  position: absolute;
  display: block;
  border-radius: 3px;
  transition: background-color 160ms var(--ease);
}
:root:not(.hover-cold) .paper__lnk:hover { background: rgba(37, 99, 235, 0.14); }
.paper__lnk:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

/* The body was pinned here once, because a document scroller on iOS forgets
   where it was under `overflow: hidden`. `.app` is an element, it does not, and
   `App.lock()` is the whole of it now — see `body.is-locked .app`. */

@media (prefers-reduced-motion: reduce) {
  .paper__veil, .paper__win { transition-duration: 1ms; }
  .paper__win { transform: none; }
}

/* ==========================================================================
   38. THE SHELL AND THE DECK
   Above 48rem the menu is not drawn over the page. The page is slid off it, to
   the right, and what was already lying underneath on the left is uncovered.

   THE DIRECTION IS NOT ARBITRARY. Whichever way the shell goes, the far side of
   it leaves the screen — and everything this page leads with is in its top-left
   corner: the sentence, the tags, the two buttons. Going right pushes the empty
   right-hand half off instead and keeps all of that in view the whole way.

   Everything the site draws lives in one of three layers, and all three carry
   the same transform from the same two properties with the same transition, so
   there is nothing to keep in sync — see the comment over `App` in site.js for
   why it cannot be a single element.

   The scroll is pinned while it is out, which is what lets `.app` be clipped to
   a fixed rectangle in document coordinates: the corner radius has to be the
   window's, and the flow is several thousand pixels tall.
   ========================================================================== */

:root {
  /* how far the shell travels, and therefore how much deck there is */
  --deck-w: clamp(19rem, 30vw, 27rem);

  /* the shell's resting state — read by all three layers.

     TWO AXES, ONE TRANSFORM. Above the breakpoint the shell travels sideways
     and `--app-y` stays zero; below it the shell travels down and `--app-x`
     stays zero. It is one interaction with one set of layers, one curve and one
     open state — the only thing that differs between a laptop and a phone is
     which of these two numbers the open state writes to. */
  --app-x: 0px;
  --app-y: 0px;
  --app-s: 1;
  --app-r: 0px;
  --app-lit: 0;

  /* 600ms, decelerating the whole way, arriving at zero velocity. Not a spring
     in the overshoot sense — a spring that overshoots would bounce the entire
     page, which at this size reads as a glitch rather than as weight. */
  --deck-move: 600ms cubic-bezier(0.16, 1, 0.3, 1);
}

body.deck-open {
  --app-x: var(--deck-w);
  /* THE INSET IS BACK, THE DIALOG LIGHTING IS NOT.

     A uniform scale shrinks all four edges, so this is also the top and bottom
     padding: 0.935 puts 29px of sky above the workspace and 29px below it.

     That inset was blamed once for making the thing read as a floating modal
     and taken down to 0.99, which removed the padding along with the problem.
     The padding was not the problem. What made it a dialog was the light on it —
     180px of blur thrown straight down from a card hanging in the middle of the
     viewport. So the inset is restored and the shadow is not: it casts sideways
     off the leading edge now, which is the edge you can actually see, and reads
     as a sheet lifted at one side rather than a window floating in space. */
  --app-s: 0.935;
  --app-r: 20px;
  --app-lit: 1;
}

/* --- the three layers ----------------------------------------------------- */

/* THE WORLD, AND IT DOES NOT MOVE.

   This layer used to travel with the shell, carrying a sky of its own, while the
   deck carried a second sky underneath. Two skies with two sets of clouds at two
   different offsets, and the seam between them landed exactly where the footer
   met the menu — which is the one place the brief says must read as one place.
   Opening the menu at the bottom of the page showed you two weathers.

   There is one now, and it is here: fixed, untransformed, at the very back of
   everything. The page's paper floats on it, the footer is where the paper stops
   covering it, and the menu is more of the same sky with the shell moved off it.
   Nothing is revealed but more of one continuous thing. */
.pane {
  position: fixed;
  inset: 0;
  z-index: 0;
}

/* The scroll surface, and the window. Viewport-sized, so its own border-radius
   is the window's radius and its fixed descendants — of which there are none
   left, but still — would measure from the screen either way. */
.app {
  position: fixed;
  inset: 0;
  z-index: 2;
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: none;
  scroll-behavior: smooth;
  /* THE FRAME HANDS OVER TO THE PAPER AT THE FOOTER.

     This layer is the viewport, and framing the viewport is right at the top of
     the page, where the paper fills it — the frame and the paper are the same
     rectangle. At the end of the page they are not: the sheet has lifted off
     and half of what is inside this frame is sky. Drawing a rounded, shadowed
     box around sky, with the same sky continuing outside it, is what made the
     bottom read as two environments no matter how well the colours matched.

     So the frame fades out as the sheet detaches. --sheet-lift is the number the
     scroll loop already computes for that detachment — 0 up top, 1 when the
     paper is fully off the desk — and the frame is its inverse. By the time you
     are at the footer the only edge on screen is the sheet's own, which is the
     right one: the paper is the canvas, the weather is the desk, and there is
     one of each. */
  --frame: calc(1 - var(--sheet-lift, 0));
  border-radius: calc(var(--app-r) * var(--frame));
  /* Cast mostly sideways, off the leading edge, because that is the only edge
     that is on screen. The old chain threw 180px of blur downward from a card
     hanging in the middle of the viewport, which is the lighting of a dialog. */
  box-shadow:
    -1px 0 0 rgba(20, 16, 12, calc(0.05 * var(--frame))),
    -10px 0 26px -12px rgba(20, 16, 12, calc(0.16 * var(--frame))),
    -34px 0 70px -34px rgba(20, 16, 12, calc(0.26 * var(--frame)));
  transform: translate3d(var(--app-x), var(--app-y), 0) scale(var(--app-s));
  transition: transform var(--deck-move), border-radius var(--deck-move);
}

/* held still for a modal — the scrollTop survives, because this is an element
   and not the document */
body.is-locked .app { overflow: hidden; }

/* THE LAYER THAT STAYS PUT AND STILL PAINTS ON TOP. Only the two weather pods
   are in here. They cannot be in `.hud`, because that rides the shell and would
   carry them off the screen with it; they cannot be in `.deck`, because the
   deck is behind the page and the page was swallowing their clicks. Above the
   canvas, below the furniture, and never transformed. */
.free {
  position: fixed;
  inset: 0;
  z-index: 3;
  pointer-events: none;
}
:where(.free) > * { pointer-events: auto; }

.hud {
  position: fixed;
  inset: 0;
  z-index: 4;
  /* the layer itself is a hole; its children keep whatever they declared, and
     `:where` keeps this from outranking a `.drawer` that means to be inert */
  pointer-events: none;
  transition: transform var(--deck-move);
}
:where(.hud) > * { pointer-events: auto; }

/* THE LABEL LAYER, AND THE ONE LAYER THAT HOLDS EXACTLY ONE THING.
   A tooltip cannot live in `.hud` with the rest of the furniture. `.hud` is a
   stacking context in which the dock declares 90, so no z-index the tooltip
   could name would put it in front — which is why the dock's labels were being
   drawn behind the dock. `.hud` also carries the shell transform and a
   clip-path, either of which is enough to misplace an element positioned from a
   viewport coordinate.

   So the label gets the band above the shell: no transform, so the bounding
   rectangles it is fed (which are post-transform already) mean what they say;
   no clip; and nothing else in here to be behind. It stays a hole to the
   pointer all the way down — the label must never become the hover target,
   or the button it belongs to loses hover the moment the label reaches it. */
.tips {
  position: fixed;
  inset: 0;
  z-index: 5;
  pointer-events: none;
}
.tips > * { pointer-events: none; }

/* The furniture takes the shell's transform to the pixel — same expression,
   same curve — so there is no frame in which the dock and the page it belongs
   to disagree about where they are.

   THE CLIP IS ALWAYS ON, and that is a fix rather than an oversight. It used to
   be applied only while the deck was out, which meant it went from `none` to a
   20px rounded inset in one frame while the workspace's own radius was easing
   from zero over 600ms — the furniture's corners snapped, the page's corners
   travelled, and that gap is what read as segmented motion. Declared always, at
   a radius that is zero when closed, it interpolates with everything else. */
.hud {
  transform: translate3d(var(--app-x), var(--app-y), 0) scale(var(--app-s));
  clip-path: inset(0 round calc(var(--app-r) * (1 - var(--sheet-lift, 0))));
  transition: transform var(--deck-move), clip-path var(--deck-move);
}

/* EDGE LIGHT, NOT A SCRIM. There was a wash of dark over the whole canvas here,
   and it was the last thing in the interaction that behaved like a second
   surface arriving on top of the first. What replaces it is the paper's own
   edge: a hairline of light down the leading side and across the top, the way
   a lifted sheet catches the room. It is on the canvas, it moves with the
   canvas, and it is only there when the canvas has edges to catch anything. */
.hud::after {
  content: '';
  position: fixed;
  inset: 0;
  z-index: 40;
  pointer-events: none;
  border-radius: calc(var(--app-r) * (1 - var(--sheet-lift, 0)));
  box-shadow:
    inset 1px 0 0 rgba(255, 255, 255, 0.55),
    inset 0 1px 0 rgba(255, 255, 255, 0.32),
    inset -1px 0 0 rgba(20, 16, 12, 0.05);
  /* goes with the frame it belongs to */
  opacity: calc(var(--app-lit) * (1 - var(--sheet-lift, 0)));
  transition: opacity var(--deck-move);
}

/* --- what is underneath --------------------------------------------------- */

.deck {
  position: fixed;
  inset: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  /* THE LAYER STAYS, ITS CONTENTS HIDE. The whole deck used to switch to
     `visibility: hidden` when the shell was home — it is behind the page then,
     and a menu you can read through the gap the footer opens in the paper is a
     menu in the wrong place. But the two footer pods live in here now, and they
     have to be visible at the footer with the deck shut, so what hides is the
     menu rather than the layer holding it. */
  pointer-events: none;
}
body.deck-open .deck { pointer-events: auto; }

.deck__inner,
.deck__close { visibility: hidden; }
body.deck-open .deck__inner,
body.deck-open .deck__close,
body.deck-shut .deck__inner,
body.deck-shut .deck__close { visibility: visible; }

/* THE WAY OUT, IN THE DECK'S OWN VOICE.

   It has been two wrong things already: a bare 17px ✕ that assumed you knew the
   convention, then a filled pill with a key cap in it, which was a control
   borrowed from another interface and set down on a sunset. This is the third
   answer and the quiet one — the mark and the word, set in the same ink the
   links below it use, at the size the readout under them uses.

   No surface, and it does not need one: `--outro-ink-strong` is already chosen
   against the sky hour by hour, and the six links a few hundred pixels below
   are legible at every hour on exactly the same terms. The word is what makes
   it a target; it does not also need a box drawn round it. */
.deck__close {
  position: absolute;
  top: clamp(1.25rem, 3.5vh, 2rem);
  left: clamp(1.75rem, 3.2vw, 3.25rem);
  z-index: 2;
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  /* the padding is the target, not a visible box */
  margin: -0.5rem 0 0 -0.5rem;
  padding: 0.5rem;
  border: 0;
  background: none;
  color: var(--outro-ink-strong, #1c1a17);
  font-size: 0.875rem;
  font-weight: 500;
  letter-spacing: -0.006em;
  line-height: 1;
  opacity: 0.62;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  /* the same two things the links do, so it belongs to them */
  transition: opacity 260ms var(--ease), transform 420ms var(--spring);
}
.deck__close svg { width: 13px; height: 13px; }
.deck__close:hover,
.deck__close:focus-visible { opacity: 1; transform: translateX(3px); }
.deck__close:active { transform: translateX(3px) scale(0.97); }

.deck__inner {
  position: relative;
  z-index: 1;
  width: var(--deck-w);
  padding-inline: clamp(1.75rem, 3.2vw, 3.25rem);
  display: flex;
  flex-direction: column;
  gap: clamp(2rem, 5vh, 3.5rem);
}

/* --- the rail, the indicator, and the list beside them -------------------- */

.deck__navwrap {
  display: flex;
  align-items: flex-start;
  gap: 1.15rem;
}

/* SEGMENTS, NOT A LINE. A continuous stroke can only get brighter; it cannot
   say *where along itself* you are. Twenty-odd separate marks each answer that
   on their own, which is what makes the focus gradient legible — the rail reads
   as a ruler with a finger on it. Their width and opacity are written by the
   frame loop; everything here is the part that never changes. */
.deck__rail {
  position: relative;
  flex: none;
  width: 26px;
  align-self: stretch;
  /* the segments are absolutely placed, so the rail needs its own height from
     the list beside it */
  min-height: 100%;
}
.deck__rail i {
  position: absolute;
  left: 0;
  top: var(--y);
  height: 2px;
  width: 18px;
  border-radius: 1px;
  background: var(--outro-ink, #33302c);
  opacity: 0.18;
  /* no transition: the loop writes these every frame it needs to, and a
     transition on top would fight the spring for control of the same number */
}

/* THE ARROW IS GONE, AND THE INDICATOR IS NOT. Drawing a cursor here was a
   mistake of the literal kind: a second pointer parked in the interface reads
   as one that got stuck, not as a marker, and you cannot tell it from your own.
   The position it was reporting was already being reported better by the rail
   underneath it — the segments brighten and widen around wherever the spring
   currently is, which is the same information without a duplicate mouse in it.
   So the element stays, as the thing the spring writes to and the segments
   measure against, and it paints nothing. */
.deck__mark {
  position: absolute;
  left: 0;
  top: 0;
  width: 0;
  height: 0;
  visibility: hidden;
}

.deck__nav {
  display: flex;
  flex-direction: column;
  /* A plain pointer. A custom arrow in here competed with the one the rail was
     drawing and neither won. */
  cursor: pointer;

  /* ROOM. The list was set solid — six lines of 2.35rem type at 1.34 with
     nothing between them, so it read as a paragraph of place names rather than
     as six separate destinations you might choose between. The gap is what
     makes them choosable. */
  gap: 0.6rem;
}

/* Large, quiet, and NOT staggered. A list that flies in one item at a time is
   a menu animating itself, which is the one thing this interaction is not: the
   deck is already here, and lay here while you were reading. */
.deck__link {
  font-size: clamp(1.6rem, 2.2vw, 2.35rem);
  line-height: 1.22;
  letter-spacing: -0.02em;
  /* 500, not 600. At this size a semibold six deep is a wall — the weight was
     doing the work the spacing should have been doing, and now the spacing
     does it. The aimed item goes to 600, so the step between resting and
     chosen is still a full weight. */
  font-weight: 500;
  text-decoration: none;
  width: fit-content;
  /* chosen against the sky by Sky.set(), the same way the footer's copy is */
  color: var(--outro-ink, #33302c);
  opacity: 0.58;
  transition:
    opacity 260ms var(--ease),
    color 260ms var(--ease),
    font-variation-settings 320ms var(--ease),
    transform 420ms var(--spring);
}
/* Hover and the current page are the same state, because they mean the same
   thing to the rail: this is the destination. `is-aim` is written by the same
   call that re-points the indicator, so the item and the marker can never
   disagree about which one is lit. */
.deck__link.is-aim {
  opacity: 1;
  color: var(--outro-ink-strong, #1c1a17);
  font-variation-settings: 'wght' 600;
  transform: translateX(6px) scale(1.02);
}
.deck__link { transform-origin: 0 50%; }

/* the printed clock and condition line used to sit here; the slider below
   says the hour and the sky says the weather, so it was saying it twice */

/* THE TWO PODS DO NOT CHANGE LAYOUT, THEY TRANSLATE.

   They keep the fixed corners they have everywhere else on this site — bottom
   left and bottom right of the viewport — in every state. Opening the deck does
   not restyle them, re-parent them or re-anchor them; it gives them a transform
   that carries them into the deck's column, on the shell's own 600ms curve, so
   they start on the frame the workspace starts and stop on the frame it stops.

   THE DISTANCES ARE WRITTEN IN VIEWPORT UNITS so no measurement is needed and
   nothing has to be recomputed on resize. The slider is left-anchored at 20px
   and wants the deck's gutter, which is a few pixels. The mute is right-anchored
   at 20px and wants to be a gutter in from the deck's right-hand edge, so it
   crosses everything between: minus the viewport, plus the deck's width. Both
   rise from the page's 20px to the deck's own bottom margin. */
.deck__pods { display: none; }

body.deck-open .controls,
body.deck-open .mute {
  opacity: 1;
  pointer-events: auto;
}

body.deck-open .controls {
  transform: translate3d(
    calc(clamp(1.75rem, 3.2vw, 3.25rem) - 20px),
    calc(20px - clamp(1.25rem, 3.5vh, 2rem)),
    0);
}

body.deck-open .mute {
  transform: translate3d(
    calc(var(--deck-w) - clamp(1.75rem, 3.2vw, 3.25rem) - 100vw + 20px),
    calc(20px - clamp(1.25rem, 3.5vh, 2rem)),
    0);
}
body.deck-open .mute:hover {
  transform: translate3d(
    calc(var(--deck-w) - clamp(1.75rem, 3.2vw, 3.25rem) - 100vw + 20px),
    calc(20px - clamp(1.25rem, 3.5vh, 2rem)),
    0) scale(1.09);
}

/* One curve for the journey, the pods' own springs for everything else they do.
   Duration and easing are the shell's, which is what makes the three read as
   one object rather than as three things that happen to start together. */
body.deck-open .controls,
body.deck-open .mute,
body.deck-shut .controls,
body.deck-shut .mute {
  transition: transform var(--deck-move), opacity var(--deck-move);
}

/* --- the handle ----------------------------------------------------------- */

/* It is in `.hud`, so it carries the shell's transform and stays welded to the
   canvas's left edge for the whole journey. That is what makes the edge read as
   the edge of something you are pushing: the handle is the leading edge, and it
   is the first thing the deck appears from behind. */
.tab-menu {
  position: fixed;
  top: 50%;
  left: 0;
  z-index: 92;
  transform: translateY(-50%);
  display: grid;
  place-items: center;
  /* IT HAS TO LOOK LIKE A CONTROL FROM THE FIRST FRAME.

     It was paper-on-paper with a hairline of shadow — welded to the canvas,
     which was the point, and near enough invisible against it, which was not.
     A visitor who never notices the handle has no navigation at all.

     Nothing about it is redesigned: same place, same shape, same vertical
     word, same push interaction. It is simply given the panel language the
     dock and the builds shelf already use — white, a 1px inside stroke, a soft
     shadow — and a little more room to stand in. That is the difference
     between a fold in the paper and a thing you press. */
  padding: 1.35rem 0.6rem;
  border: 0;
  border-radius: 0 12px 12px 0;
  background: #fff;
  box-shadow:
    inset 0 0 0 1px var(--line, #e7e4de),
    2px 0 4px -2px rgba(20, 16, 12, 0.1),
    10px 0 28px -14px rgba(20, 16, 12, 0.32);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: padding-left 260ms var(--ease), background-color 260ms var(--ease);
}
.tab-menu span {
  writing-mode: vertical-rl;
  text-orientation: mixed;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink, #14100c);
}
.tab-menu:hover { background: #fff; }
.tab-menu:hover span { color: var(--ink); }
.tab-menu:hover { padding-left: 0.75rem; }
.tab-menu:active { transform: translateY(-50%) scale(0.97); }

/* IT IS GONE WHILE THE DECK IS OUT — not faded, not disabled, not there. With
   a navigation panel open beside it, a second control offering the same thing
   is duplicate navigation, and a duplicate that is merely transparent is still
   in the tab order and still under the pointer.

   THE TWO HANDOVERS DO NOT OVERLAP. The handle goes on `deck-open`, which is set
   in the same frame the shell starts moving, and the ✕ arrives with the deck it
   belongs to. Closing is the mirror: `deck-open` comes off and `deck-shut` goes
   on in the same frame, the ✕ leaves with it, and the handle does not come back
   until `deck-shut` clears — which is when the shell has landed. There is no
   frame with both on screen, in either direction. */
body.deck-open .tab-menu,
body.deck-shut .tab-menu { display: none; }

/* AND IT GOES AT THE FOOTER TOO, for the same reason it goes when the deck is
   out: by then the page is offering the same four links in the footer's own
   Pages column, a few hundred pixels to the right, and a floating handle over
   the sky beside them is the second copy.

   It rides --end — the number the scroll loop already uses to bring the time
   slider and the mute up at the end of the page — read backwards, so the handle
   leaves on exactly the frame those two arrive. One set of controls at the
   bottom of the page, and it is the set that belongs to the bottom of the page.
   `--end-events-inv` is the matching keyword, written for the dock, which does
   the same thing on a phone. */
.tab-menu {
  opacity: calc(1 - var(--end, 0));
  pointer-events: var(--end-events-inv, auto);
}

body.deck-shut .deck__close { display: none; }


/* The row of links is gone: the handle is the navigation now, and two of them
   on one screen is the second one explaining that the first is decoration. The
   markup stays, because the phone's sheet is generated from the same array and
   a harness checks that they match. Later than the rule that shows it on the
   hero, and at the same weight, so it wins on order. */
body[data-page] .nav { display: none; }

/* --- 38b. the same reveal, turned ninety degrees ---------------------------
   THE PHONE GETS THE INTERACTION, NOT A MENU.

   What used to be here was a second navigation entirely: four coloured panels
   that came down over the page, with their own type, their own physics and
   their own idea of what a menu is. It worked, and it was a different product
   on a phone than it is on a laptop — the one thing this page is about, that
   the interface is a sheet of paper on a desk and the menu is the desk, was
   simply not true below 48rem.

   There is nowhere to slide sideways on a 390px screen, and that is the only
   real constraint. So the shell goes DOWN instead, and everything else is
   already built: the same `.deck` behind the same `.app`, the same one sky in
   `.pane` underneath both, the same six links, the same segmented rail with the
   same spring on it, the same 600ms curve on all three layers. The open state
   below writes `--app-y` where the desktop's writes `--app-x`. That is the
   whole of the difference.

   THE CANVAS IS THE THING THAT MOVES, and it moves as one object because it
   always was one: the header, the hero, the bricks, the toolbar, the projects
   and the footer are all inside `.app`, and the furniture in `.hud` takes the
   identical transform on the identical curve. Nothing is re-parented, nothing
   is animated onto the screen, and there is no second surface anywhere. */
@media (max-width: 48rem) {
  :root {
    /* HOW FAR THE CANVAS TRAVELS. Enough menu for six links, the rail, the way
       out and the two pods, and NOT more: the canvas has to stay plainly
       visible underneath or the reveal reads as a page change rather than as a
       sheet pushed down.

       This is the travel, not the strip: the scale below adds about 22px of sky
       on top of it. But the travel is also the strip's HEIGHT, and the strip has
       a floor — the way out, six 44px rows, the rail and the two pods have to
       stand in it without touching. Trimming this to 57svh to pay for the scale
       cost the nav 22px it did not have, and the last link went under the
       brightness pod. So the number goes back and the floor is stated: 23rem is
       the shortest strip the contents fit in — measured, on a 320×568 screen,
       as the height at which the last link still clears the pods by 15px of
       ink — and every vertical measure below shrinks with the viewport so that
       floor is reachable there rather than merely asserted. */
    --deck-h: clamp(23rem, 62svh, 27.5rem);
    --deck-pad: 1.5rem;
    /* The strip's own gutters — the close sits in the top one, the pods in the
       bottom one, and the nav clears both. Both scale with the viewport for the
       reason above: on a short phone the gutters give way before the type does,
       because a cramped margin still reads and a clipped link does not. */
    --deck-top: clamp(0.9rem, 2.2svh, 1.35rem);
    --deck-bot: clamp(0.85rem, 1.9svh, 1.15rem);
  }

  body.deck-open {
    --app-x: 0px;
    --app-y: var(--deck-h);

    /* 0.935 — THE DESKTOP'S OWN NUMBER, and the second correction to it.

       It was 0.97 first, on the reasoning that a phone wants less of everything.
       Six pixels of sky down each side is not a margin, it is a seam: too thin
       to read as space left on purpose, wide enough to look like the canvas
       failed to reach the edge. Taking the scale out altogether fixed that and
       lost the margin with it, which is the wrong trade — the sheet then sits
       flush against the glass on three sides and lifted on one.

       So the mechanism was never the problem, the amount was, and the amount
       that works is the one the laptop already uses. At 390px it puts 12.7px of
       sky down each side, which is a margin you can see the intent of. It also
       means the sheet lifts by the same proportion at every width and only the
       direction of travel changes — which is the whole claim this interaction
       makes about itself.

       A uniform scale is also the only way to inset horizontally without either
       distorting the page or relaying it out: `width` and `left` would reflow
       every line of type in the workspace on the frame the animation starts. */
    --app-s: 0.935;
    --app-r: 22px;
    --app-lit: 1;
  }

  /* --- the handle, which is the desktop's handle -------------------------
     Not a hamburger, and not a second design. This is `.tab-menu` — the same
     element, the same paper, the same hairline and shadow, the same uppercase
     word — turned through the same ninety degrees as the interaction it opens.
     On a laptop it is welded to the canvas's left edge because that is the
     leading edge; here the canvas leads with its top, so that is where it goes.

     It rides `.hud`, so it travels with the canvas and is the first thing the
     menu appears from behind, exactly as it is on a laptop. And it leaves when
     the deck is out — `body.deck-open .tab-menu` is declared once, above, for
     both widths — so the way back is the ✕ in the menu and there is never a
     second control on screen. */
  .tab-menu {
    top: calc(var(--sa-top) + 0px);
    left: auto;
    right: calc(var(--sa-right) + 1.25rem);
    transform: none;
    padding: 0.55rem 0.85rem 0.6rem;
    border-radius: 0 0 10px 10px;
    box-shadow:
      0 1px 0 rgba(20, 16, 12, 0.05),
      0 6px 22px -12px rgba(20, 16, 12, 0.22);
    transition: padding-bottom 260ms var(--ease), background-color 260ms var(--ease);
  }
  .tab-menu span {
    writing-mode: horizontal-tb;
    font-size: 0.6875rem;
  }
  .tab-menu:hover { padding-left: 0.85rem; padding-bottom: 0.85rem; }
  .tab-menu:active { transform: scale(0.97); }

  /* --- the menu, at the top ---------------------------------------------- */

  /* Same layer, same paint order, pinned to the top of the viewport instead of
     the left of it. Its lower half stays under the canvas at all times, which
     is what makes the canvas read as lying ON it. */
  .deck {
    align-items: flex-start;
    justify-content: stretch;
  }

  /* THE WAY OUT IS IN THE MENU, in the corner the handle was in — so the thumb
     that opened it does not have to go anywhere else to close it. Mirrored from
     the desktop's left-hand corner for the same reason the handle is: this side
     is the one this layout leads from. */
  .deck__close {
    top: calc(var(--sa-top) + var(--deck-top));
    left: auto;
    right: calc(var(--sa-right) + var(--deck-pad) - 0.5rem);
    margin: 0;
  }
  .deck__close:hover,
  .deck__close:focus-visible { transform: none; }
  .deck__close:active { transform: scale(0.97); }

  .deck__inner {
    width: 100%;
    height: var(--deck-h);
    padding-inline: calc(var(--deck-pad) + var(--sa-left)) calc(var(--deck-pad) + var(--sa-right));
    /* Clears the ✕ above and the two pods below. Both reserves are measured in
       svh as well as rem, so the whole column compresses together instead of
       the nav being the only thing that gives. */
    padding-top: calc(var(--sa-top) + var(--deck-top) + clamp(1.9rem, 4.6svh, 2.5rem));
    padding-bottom: calc(var(--deck-bot) + clamp(3rem, 7.5svh, 3.5rem));
    gap: 0;
    justify-content: flex-start;
  }

  .deck__navwrap { gap: 0.9rem; }
  .deck__rail { width: 22px; }
  .deck__rail i { width: 14px; }

  .deck__nav { gap: 0; }

  /* THE TARGET IS THE ROW, NOT THE WORD. `width: fit-content` is what the
     desktop wants — a cursor lands where it is pointed and a hit area wider
     than the ink is a hit area you cannot see. A finger is not a cursor: the
     row is 44px tall and reaches the full width of the column, so the whole
     line answers rather than the eleven characters in it. */
  .deck__link {
    display: flex;
    align-items: center;
    width: 100%;
    /* 44px is the target, and on any phone tall enough to hold six of them it
       is what you get. Below that the row gives up to 4px rather than let the
       list run into the pods — 40px is still inside the thumb's own tolerance,
       and the rows are stacked flush so the reachable area is continuous. */
    min-height: clamp(40px, 6.6svh, 44px);
    font-size: clamp(1.4rem, 6.4vw, 1.85rem);
    line-height: 1.2;
  }
  /* tap feedback, on the aim transform so the two never fight */
  .deck__link:active { transform: translateX(6px) scale(0.985); }

  /* --- the two pods ------------------------------------------------------
     They keep their fixed corners and translate into the foot of the menu
     strip, on the shell's own curve, exactly as they do on a laptop. Sideways
     is pure arithmetic — both are anchored 20px in and want the strip's own
     gutter, and the safe area cancels. Upward is not: it depends on the pod's
     own height, on the strip's resolved height and on the bottom safe area, so
     `Deck.podFit()` measures it once and writes the answer here. */
  body.deck-open .controls {
    transform: translate3d(calc(var(--deck-pad) - 20px), var(--deck-podup, 0px), 0);
  }
  body.deck-open .mute,
  body.deck-open .mute:hover {
    transform: translate3d(calc(20px - var(--deck-pad)), var(--deck-podup, 0px), 0);
  }

  /* --- the canvas edge ---------------------------------------------------
     THREE SIDES ARE ON SCREEN NOW, not one. The top is the edge that comes into
     view and the scale puts sky down both flanks, so the hairline goes all the
     way round and the cast falls upward off the leading edge — the same
     asymmetry the desktop uses, turned ninety degrees with everything else. */
  .app {
    box-shadow:
      0 0 0 1px rgba(20, 16, 12, calc(0.045 * var(--frame))),
      0 -8px 22px -12px rgba(20, 16, 12, calc(0.16 * var(--frame))),
      0 -26px 60px -30px rgba(20, 16, 12, calc(0.24 * var(--frame)));
  }
  /* and the paper catches the light on all three, the top most of all */
  .hud::after {
    box-shadow:
      inset 0 1px 0 rgba(255, 255, 255, 0.5),
      inset 1px 0 0 rgba(255, 255, 255, 0.3),
      inset -1px 0 0 rgba(255, 255, 255, 0.3);
  }
}

@media (prefers-reduced-motion: reduce) {
  .pane, .hud, .hud::after,
  body.deck-open .app,
  body.deck-shut .app { transition-duration: 1ms; }
  .deck__link { transition-duration: 1ms; }
}

/* ==========================================================================
   39. THE PEEK
   The card behind the underlined words in the closing block. Its motion is in
   site.js — a spring in the page's frame loop, not a transition, for the reason
   written over the module. What is here is the surface and the two states the
   word itself has.
   ========================================================================== */

/* THE WORD, BEFORE ANYTHING HAPPENS. The underline is already drawn by the
   reveal; hovering only makes it heavier and the ink darker. Nothing moves —
   a word that shifts under the cursor is a word you then have to chase. */
.block mark.rule.is-peek { cursor: pointer; }

.block mark.rule.is-peek:hover,
.block mark.rule.is-peek:focus-visible {
  color: var(--ink);
  /* 1.5px → 2px, and the line goes from 72% ink to full. Two properties, both
     paint, and the transition below is what makes it read as a response rather
     than a swap. */
  background-image: linear-gradient(to right, var(--ink), var(--ink));
  background-size: 100% 2px;
}
.block mark.rule.is-peek {
  transition:
    background-size 220ms var(--ease),
    background-image 220ms linear,
    color 220ms var(--ease);
}

.peek {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 95;
  width: clamp(17.5rem, 22vw, 21rem);      /* 280–336px */
  /* The loop writes transform and opacity every frame; nothing else here is
     allowed to change, and neither of them costs layout. */
  opacity: 0;
  pointer-events: none;
  will-change: transform, opacity;
  padding: 1.15rem 1.25rem 1rem;
  border-radius: 20px;
  background: var(--paper);
  border: 1px solid rgba(20, 16, 12, 0.05);
  box-shadow:
    0 1px 2px rgba(20, 16, 12, 0.05),
    0 12px 28px -10px rgba(20, 16, 12, 0.16),
    0 40px 80px -32px rgba(20, 16, 12, 0.30);
}

/* X is a black site. A paper card in front of it would be this page talking
   about them rather than a glimpse of them. */
.peek[data-tone='dark'] {
  background: #141210;
  border-color: rgba(255, 255, 255, 0.09);
  color: #f4f2ef;
}

/* The tail. Small, and its only job is to say which word this belongs to when
   two of them are a line apart. */
.peek::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -5px;
  width: 11px; height: 11px;
  margin-left: -5.5px;
  border-radius: 0 0 3px 0;
  background: inherit;
  border-right: 1px solid rgba(20, 16, 12, 0.05);
  border-bottom: 1px solid rgba(20, 16, 12, 0.05);
  transform: rotate(45deg);
}
.peek[data-tone='dark']::after { border-color: rgba(255, 255, 255, 0.09); }

.peek__media {
  margin: -0.25rem -0.35rem 0.85rem;
  aspect-ratio: 16 / 10;
  overflow: hidden;
  border-radius: 12px;
  background: var(--paper-2, rgba(20, 16, 12, 0.04));
}
.peek__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  /* the parallax lean is written here; the 1.08 is the overscan that keeps an
     edge from showing when it does */
  transform: scale(1.08);
  will-change: transform;
}

.peek__title {
  font-size: 0.9375rem;
  font-weight: 600;
  letter-spacing: -0.012em;
  line-height: 1.35;
}
.peek__body {
  margin: 0.35rem 0 0;
  font-size: 0.8125rem;
  line-height: 1.55;
  color: var(--ink-2);
}
.peek[data-tone='dark'] .peek__body { color: rgba(244, 242, 239, 0.72); }

.peek__hint {
  margin-top: 0.7rem;
  font-size: 0.6875rem;
  letter-spacing: 0.02em;
  color: var(--ink-dim);
}
.peek[data-tone='dark'] .peek__hint { color: rgba(244, 242, 239, 0.5); }

/* Switching words does not rebuild the card, it replaces what is written on it.
   140ms is under the spring's own travel time, so the contents have changed by
   the time the card arrives at the new word. */
.peek__in.is-xf { animation: peekXf 140ms var(--ease); }
@keyframes peekXf {
  from { opacity: 0; transform: translateY(3px); }
  to   { opacity: 1; transform: none; }
}

/* --- the phone: a sheet, because there is no hover ------------------------ */
@media (hover: none), (pointer: coarse) {
  .peek {
    width: min(26rem, calc(100vw - 2rem));
    padding-bottom: calc(1rem + var(--sa-bottom));
    border-radius: 22px;
  }
  /* ONLY WHILE IT IS ACTUALLY OPEN.
     This used to be a flat `pointer-events: auto` on `.peek`, and that is the
     one line that made a third of the phone's hero dead to the touch. The card
     is built once at boot and parked — opacity 0, no transform yet, so it is
     lying at top:0 left:0 — and `tick()` returns before writing a transform
     while it is closed, so it never moves out of the way. A 336x250 invisible
     rectangle in the top-left corner of the viewport, taking every tap: which
     is exactly the set of bricks that would not respond. Measured with a
     hit-map sweep of the hero, 1391 of 4032 sample points landed on it.

     `aria-hidden` is already the module's own open/closed flag — `point()`
     removes it and `hide()` puts it back — so this hangs off the state that
     exists rather than adding a second one that could disagree with it. */
  .peek:not([aria-hidden='true']) { pointer-events: auto; }
  /* the tail points at a word the finger is covering, so it goes */
  .peek::after { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .peek__in.is-xf { animation-duration: 1ms; }
  .peek__media img { transform: scale(1.08) !important; }
}

/* --- the row you are still in --------------------------------------------
   "2025—" already says this, and says it so quietly that you have to know the
   convention to read it. This says it in words, at the size the rest of the
   table's secondary type is set in, so it reads as a fact about the row rather
   than as a badge stuck onto it.

   The dot breathes. Two seconds, and between a third and full opacity — slow
   and shallow enough that you notice it only after you have stopped reading,
   which is the right moment for a detail whose whole content is "this one is
   still true". */
/* A CHIP, NOT A FOOTNOTE. Set in the table's muted grey it disappeared into the
   column of dates and roles either side of it — the one thing on the row that is
   not simply a record was the quietest mark on it. It carries its own surface
   now, and takes the dot's colour into the type, so it reads as a state rather
   than as more metadata. Still small: the row is the subject, this is a flag on
   it. */
.row__now {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  margin-left: 0.65em;
  vertical-align: 0.12em;
  padding: 0.24em 0.55em 0.24em 0.45em;
  border-radius: 999px;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: #147a4e;
  background: rgba(33, 163, 107, 0.11);
  box-shadow: inset 0 0 0 1px rgba(33, 163, 107, 0.16);
  white-space: nowrap;
}
.row__now i {
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: #21a36b;
  animation: nowPulse 2s ease-in-out infinite;
}
@keyframes nowPulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.35; }
}

/* it belongs to the row, so it warms with it */
.row:hover .row__now { background: rgba(33, 163, 107, 0.16); }

@media (prefers-reduced-motion: reduce) {
  .row__now i { animation: none; }
}

/* --- the hero column on a phone ---------------------------------------------
   Below the breakpoint the column is not beside the scatter, it is under it —
   the existing rules move it to the foot of the canvas and the brick region
   follows. All that changes here is the measure: a stated width that is right
   at 1440 is not right at 390, and the headline needs the full column rather
   than 62% of a screen that is already narrow. */
@media (max-width: 48rem) {
  .canvas:not(.canvas--room) .canvas__intro {
    left: clamp(1rem, 5vw, 1.5rem);
    width: min(24rem, calc(100vw - 2.5rem));
    max-width: calc(100vw - 2.5rem);
  }
  .canvas__headline { max-width: 100%; letter-spacing: -0.024em; }
  .canvas__cta .btn { padding: 0.55rem 1.05rem; }
  /* the note is a pointer affordance; a phone has no pointer to hover with,
     and 78px of drawing under the buttons on a 390px screen is clutter */
  .nb { display: none; }
}

/* ==========================================================================
   41. THE 404 ROOM
   The same canvas as the hero, with a sign standing in the middle of it.

   Everything structural here is one rule: the column is CENTRED BY LAYOUT and
   not by a transform. `Bricks.zone()` derives the protected region from
   `offsetLeft/offsetTop`, deliberately, because a transform is exactly what it
   cannot see — the reveal is still carrying the type when the region is
   measured. Centring this with `translate(-50%, -50%)` would leave the engine
   protecting a rectangle half a column to the right of the words, and the
   bricks would settle over the headline while every measurement said they had
   not. `inset: 0; margin: auto` is the same centring expressed as layout, so
   the offsets tell the truth.
   ========================================================================== */

.canvas--room .canvas__intro {
  position: absolute;
  /* all four, and nothing may reset one of them afterwards — `inset: 0` plus
     `margin: auto` IS the centring, and cancelling top and left leaves the
     block hanging off the bottom-right corner instead */
  inset: 0;
  margin: auto;
  width: fit-content;
  height: fit-content;
  max-width: min(34rem, 82vw);
  z-index: 3;
  pointer-events: none;             /* only the links inside are interactive */

  /* THE PADDING AND THE HALO LIVE ON THE MEASURED BOX.

     They were on the block inside, pulled outward with negative margins so the
     glow sat behind the type without widening the column. It looked right and
     it lied to the engine: `Bricks.zone()` protects this element's
     `offsetWidth/offsetHeight`, and a child that reaches 24px past them on
     every side is 24px of painted block the region does not know exists. That
     is where the last overlaps were — always on the bottom two lines, always
     small, because the only part of the sign sticking out of its own box was
     the edge. Whatever the reader sees has to be inside what the physics
     measures, so the box grew instead of the child. */
  padding: 1.5rem 1.75rem;
  border-radius: 18px;
  /* the paper's own colour, fading out rather than ending — a border here
     would turn the sign into a card and the room into two rooms */
  background: radial-gradient(120% 120% at 50% 50%,
    rgba(251, 250, 250, 0.97) 55%,
    rgba(251, 250, 250, 0.86) 78%,
    rgba(251, 250, 250, 0) 100%);
}

.nf__in { text-align: left; }

/* --- THE NUMBER -------------------------------------------------------------
   The one thing on the page a visitor needs, set at the size that says so. It
   was a 13px mono eyebrow over a 38px sentence, which is the hierarchy exactly
   inverted: the smallest type on the page carrying the only fact on it.

   Tight tracking and a cut-in line-height because three digits set this large
   are a shape rather than a word — left at default they drift apart and the
   block stops reading as one mark. Still the site's own face and the site's own
   ink; nothing here is a display font borrowed for the occasion. */
.nf__big {
  margin: 0 0 0.55rem;
  font-size: clamp(4.5rem, 3rem + 7vw, 8.5rem);
  line-height: 0.86;
  font-weight: 600;
  letter-spacing: -0.05em;
  color: var(--ink, #14100c);
  font-variant-numeric: lining-nums tabular-nums;
}

/* And the sentence under it is a caption, not a title. Quiet enough that the
   numeral keeps the page, big enough to read over a pile of bricks. */
.nf__line {
  margin: 0 0 1.5rem;
  max-width: 26rem;
  font-size: clamp(1rem, 0.95rem + 0.2vw, 1.125rem);
  line-height: 1.5;
  color: var(--ink-2, #3a352f);
}

.nf__body {
  margin: 0 0 1.75rem;
  max-width: 30rem;
  font-size: 1.0625rem;
  line-height: 1.6;
  color: var(--ink-2, #3a352f);
}

.nf__links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  pointer-events: auto;             /* the one interactive part of the sign */
}

/* Same correction the hero's CTA row carries, and for the same reason: the
   ghost button's `--ink-dim` label is right in a dense row where the pair reads
   as one control, and unreadable out on open paper with bricks passing behind
   it. These two are half the ways off this page. */
.nf__links .btn--ghost {
  --btn-fg: var(--ink-2);
  --btn-bg: rgba(20, 16, 12, 0.028);
}
.nf__links .btn--ghost:hover { --btn-fg: var(--ink); }

/* The invitation. Deliberately the quietest thing on the page: the interaction
   teaches itself the moment a brick moves under the cursor, so this only has
   to hint that moving one is allowed. It arrives last, after the room has
   filled, because before that there is nothing to build with. */
.nf__aside {
  margin: 1.6rem 0 0;
  font-size: 0.875rem;
  line-height: 1.5;
  color: var(--ink-dim, #b9b4ad);
}

@media (max-width: 48rem) {
  .canvas--room .canvas__intro { max-width: min(24rem, 86vw); padding: 1.25rem 1.35rem; }
  .nf__body { margin-bottom: 1.4rem; font-size: 1rem; }
  .nf__line { margin-bottom: 1.25rem; }
  /* three buttons will not sit on one line at 390px, and a row that wraps to
     two-and-one reads as a mistake — let it wrap evenly instead */
  .nf__links { gap: 0.45rem; }
}

/* ==========================================================================
   41b. THE 404 CHALLENGE
   A game mode laid over the room, and the whole brief for it is that you
   should not be able to tell from a screenshot that it is one. Everything
   below is the page's own vocabulary: the paper, the dot grid, the hairline,
   the 999px pill, the mono numerals, the ink and the two greys. No accent
   colour is introduced, nothing glows, nothing is centred in a modal and the
   only saturated things on screen are still the bricks.
   ========================================================================== */

/* --- the sign steps aside ---------------------------------------------------
   It keeps its BOX. Opacity, ten pixels of lift and a hair of scale — the
   layout is untouched, so `Bricks.zone()` goes on protecting the middle of the
   room and the pile goes on banking away from where the stencil stands. A
   `display: none` here would drop the island and let the fall bury the target
   within a second of the game starting. */
.canvas--room .canvas__intro.is-gone {
  opacity: 0;
  transform: translateY(-10px) scale(0.985);
  pointer-events: none;
  transition:
    opacity 420ms var(--ease),
    transform 520ms var(--spring-soft, var(--ease));
}
.canvas--room .canvas__intro.is-gone .nf__links { pointer-events: none; }
.canvas--room .canvas__intro {
  transition:
    opacity 420ms var(--ease) 60ms,
    transform 520ms var(--spring-soft, var(--ease)) 60ms;
}

/* --- PRESETS ----------------------------------------------------------------
   Not a card and not a menu. A label and a row of four pieces of artwork
   sitting on the paper in the top right — the shelf from the toolbar, with the
   panel taken away, because in this room the builds are not a tool you reach
   for, they are the thing to do. The hover is the shelf's hover exactly: the
   ARTWORK lifts, not the row. */
/* BOTH CONTROLS SIT ON PAPER, NOT ON THE ROOM.

   The first pass put bare type straight onto the canvas, which is fine on the
   empty half of an empty page and illegible the moment sixty-four saturated
   bricks are behind it — the word "Presets" over a red 2x4 is not a label, it
   is texture. So each control carries its own small sheet: the same white, the
   same 12px radius, the same 1px inside stroke and the same soft shadow the
   dock and the builds shelf use. Nothing new is invented, the two read as a
   pair, and the type has something to be dark against. */
.nfp {
  position: absolute;
  top: clamp(1rem, 3.5vh, 2rem);
  right: clamp(1rem, 3vw, 2.5rem);
  /* THE CHROME BAND, not a number picked to be bigger than the bricks.
     `Drag.raise` hands the most recently touched object an ever-climbing
     z-index, so any control that lives on the canvas and expects to stay
     clickable after forty drags cannot sit at 4 — it has to be in the same
     band the dock is in. The stencil is the opposite case and stays at 1:
     it is a drawing the bricks must cover. */
  z-index: 42;
  display: grid;
  gap: 0.4rem;
  justify-items: stretch;
  padding: 0.5rem 0.5rem 0.4rem;
  border-radius: 13px;
  background: #fff;
  box-shadow:
    inset 0 0 0 1px var(--line, #e7e4de),
    0 2px 5px -2px rgba(20, 16, 12, 0.08),
    0 14px 30px -18px rgba(20, 16, 12, 0.3);
  transition: opacity 300ms var(--ease), transform 380ms var(--ease);
}

.nfp__lb {
  margin: 0 0 0 0.25rem;
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 0.625rem;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--ink-2, #3a352f);
  opacity: 0.62;
}

.nfp__row {
  display: flex;
  align-items: center;
  gap: 2px;
}

.nfp__it {
  position: relative;
  display: grid;
  place-items: center;
  width: 46px;
  height: 40px;
  padding: 0;
  border: 0;
  border-radius: 8px;
  background: transparent;
  cursor: pointer;
  transition: background 180ms var(--ease);
}
:root:not(.hover-cold) .nfp__it:hover { background: rgba(20, 16, 12, 0.05); }

.nfp__art {
  display: grid;
  place-items: center;
  filter: drop-shadow(0 1px 1.5px rgba(20, 16, 12, 0.16));
  transition:
    transform 240ms var(--spring-soft, var(--ease)),
    filter 240ms var(--ease);
}
.nfp__art svg { display: block; max-width: 32px; max-height: 28px; }
:root:not(.hover-cold) .nfp__it:hover .nfp__art {
  transform: translateY(-2px) scale(1.08);
  filter: drop-shadow(0 4px 6px rgba(20, 16, 12, 0.22));
}
.nfp__it.is-press .nfp__art { transform: scale(0.93); transition-duration: 90ms; }

/* the name, under the row rather than over the canvas — a tooltip that covers
   the bricks would be the one piece of chrome here that hides the toy */
.nfp__tip {
  position: absolute;
  top: calc(100% + 7px);
  right: 0;
  padding: 3px 7px;
  border-radius: 6px;
  background: var(--ink, #14100c);
  color: #fbfaf8;
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.005em;
  white-space: nowrap;
  opacity: 0;
  transform: translateY(-3px);
  pointer-events: none;
  transition: opacity 160ms var(--ease), transform 220ms var(--ease);
}
:root:not(.hover-cold) .nfp__it:hover .nfp__tip,
.nfp__it:focus-visible .nfp__tip { opacity: 1; transform: none; }

/* --- START GAME -------------------------------------------------------------
   Quieter than the sign by every measure: a ghost pill and one line of the
   dimmest grey on the page. It is an aside, not the reason the page exists. */
.nfs {
  position: absolute;
  top: 50%;
  /* clear of the nav's own edge tab, which lives at the left margin at exactly
     this height — the two are the only things on that side of the room */
  left: clamp(3.25rem, 7vw, 6rem);
  translate: 0 -50%;
  z-index: 42;
  display: grid;
  gap: 0.45rem;
  justify-items: start;
  max-width: 12.5rem;
  padding: 0.6rem 0.7rem 0.65rem;
  border-radius: 14px;
  background: #fff;
  box-shadow:
    inset 0 0 0 1px var(--line, #e7e4de),
    0 2px 5px -2px rgba(20, 16, 12, 0.08),
    0 14px 30px -18px rgba(20, 16, 12, 0.3);
  transition: opacity 300ms var(--ease), transform 380ms var(--ease);
}

/* THE ONE CONTROL THAT HAS TO BE FOUND.

   It was `.btn--ghost`, whose label is `--ink-dim` on a 3.5% wash — about 1.6:1
   against paper. That token is correct for a button sitting beside a primary
   one, where the pair is the affordance; here it is the ONLY thing inviting
   you into the game and it was effectively invisible.

   So the label is full ink at the site's own button size, on white with the
   same hairline the panel uses. Still not the black pill — Home is the primary
   action on this page and stays the loudest thing on it — but now legible on
   any background, which is the floor, not a style choice. */
.nfs__go {
  --btn-bg: #fff;
  --btn-fg: var(--ink, #14100c);
  font-weight: 550;
  box-shadow:
    inset 0 0 0 1px var(--line, #e7e4de),
    0 1px 2px rgba(20, 16, 12, 0.08);
}
.nfs__go:hover {
  --btn-bg: #fff;
  --btn-fg: var(--ink, #14100c);
  box-shadow:
    inset 0 0 0 1px #d9d5ce,
    0 2px 4px rgba(20, 16, 12, 0.1),
    0 10px 20px -10px rgba(20, 16, 12, 0.28);
}
.nfs__go .btn__label { display: inline-flex; align-items: center; gap: 0.45rem; }

/* drawn, not typed — a glyph triangle picks up the text baseline and the font's
   own idea of how big it should be, and is a different size in every fallback */
.nfs__pl {
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 4px 0 4px 6.5px;
  border-color: transparent transparent transparent currentColor;
  transition: transform 320ms var(--spring, var(--ease));
}
.nfs__go:hover .nfs__pl { transform: translateX(1.5px); }

.nfs__note {
  margin: 0 0 0 0.15rem;
  font-size: 0.8125rem;
  line-height: 1.4;
  color: var(--ink-2, #3a352f);
  opacity: 0.72;
}

/* both controls step out of the way for the duration */
body[data-game] .nfp,
body[data-game] .nfs {
  opacity: 0;
  transform: translateY(-6px);
  pointer-events: none;
}
/* and so does the dock's own drawer, so there is one objective on screen */
body[data-game] .tool--preset { opacity: 0.35; pointer-events: none; }
body[data-game] .shelf { display: none; }

/* --- THE STENCIL ------------------------------------------------------------
   A TECHNICAL DRAWING, NOT A TARGET ZONE. There is no hit region in here: it
   is `pointer-events: none`, it sits under the bricks, and the only thing that
   decides where a piece may land is the solver — the same solver that has been
   deciding it since the page loaded. What you see is the answer drawn in the
   language the rest of the site draws construction in; what you feel is the
   magnet you have been feeling all along.

   Each piece carries its own dashed silhouette because that is what states the
   ORIENTATION. One outline around the whole build would say where the object
   goes and nothing at all about which brick, which way round. */
.stn {
  position: absolute;
  z-index: 1;                       /* over the dots, under every .drg */
  pointer-events: none;
  opacity: 0;
  transform: scale(0.985);
  transition: opacity 420ms var(--ease), transform 520ms var(--spring-soft, var(--ease));
}
/* THE SAME HALO THE SIGN USES, for the same reason. The drawing is 6 studs
   wide on a canvas covered in bricks, and at that size a dashed outline is
   just more texture. Fading the dot grid out behind it is what makes it read
   as the thing you are meant to be looking at — without a border, a panel or
   a shadow, none of which this room has. */
.stn::before {
  content: '';
  position: absolute;
  inset: -46px;
  border-radius: 999px;
  background: radial-gradient(50% 50% at 50% 50%,
    rgba(251, 250, 250, 0.92) 40%,
    rgba(251, 250, 250, 0) 100%);
  z-index: -1;
}
.stn.is-on { opacity: 1; transform: none; }
.stn svg { display: block; overflow: visible; }

.stn__f { fill: rgba(20, 16, 12, 0.045); }
.stn__s { fill: rgba(20, 16, 12, 0.1); }
.stn__e {
  stroke: rgba(20, 16, 12, 0.38);
  stroke-width: 1;
  stroke-dasharray: 3 3.5;
  stroke-linecap: round;
}

/* SOLVED. The drawing gets out of the way of the brick standing on it — it has
   said what it had to say, and a dashed outline left under a seated piece is a
   drawing arguing with the object it asked for. */
.stn__p {
  transition: opacity 320ms var(--ease);
}
.stn__p.is-set { opacity: 0; }

.stn.is-done { opacity: 0; transition-duration: 500ms; }

/* --- THE CLOCK --------------------------------------------------------------
   Two lines and a hairline. The brief on the left, the count on the right, and
   a rule under both that drains — which is the part you read without looking,
   so the numerals never have to get big. */
.nfh {
  --t: 1;
  position: absolute;
  top: clamp(1rem, 3.5vh, 2rem);
  left: 50%;
  translate: -50% 0;
  z-index: 42;
  display: grid;
  grid-template-columns: auto auto;
  align-items: end;
  column-gap: clamp(1.25rem, 4vw, 2.75rem);
  padding-bottom: 0.5rem;
  opacity: 0;
  transform: translateY(-8px);
  transition: opacity 420ms var(--ease), transform 520ms var(--spring-soft, var(--ease));
}
.nfh.is-on { opacity: 1; transform: none; }
.nfh.is-out { opacity: 0.5; }
/* IT HAS SAID ITS PIECE. The result panel carries the time, so a frozen clock
   left over the finished object is the same number twice. */
.nfh.is-done { opacity: 0; transform: translateY(-8px); }

.nfh__eb {
  margin: 0 0 0.15rem;
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 0.6875rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-dim, #b9b4ad);
}
.nfh__nm {
  margin: 0;
  font-size: 1rem;
  font-weight: 550;
  letter-spacing: -0.01em;
  color: var(--ink, #14100c);
}

.nfh__t {
  margin: 0;
  justify-self: end;
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 1.5rem;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
  line-height: 1;
  color: var(--ink, #14100c);
  transition: color 300ms var(--ease);
}
/* the last five seconds, and this is the whole of the alarm — no flashing, no
   red, no shake. The number goes the colour of the ink it was already nearly */
.nfh__t.is-low { color: #b0342f; }

.nfh__bar {
  grid-column: 1 / -1;
  height: 1px;
  margin-top: 0.6rem;
  background: var(--line, #e7e4de);
  overflow: hidden;
}
.nfh__bar i {
  display: block;
  height: 100%;
  background: var(--ink-dim, #b9b4ad);
  transform-origin: 0 50%;
  transform: scaleX(var(--t));
}

/* --- THE RESULT -------------------------------------------------------------
   At the FOOT of the room, never over it. Whatever was managed stays visible
   behind the words, which is the only reason to show a result at all rather
   than silently resetting — "you were close" means nothing if the thing you
   were close to has been cleared away. */
/* A PANEL, IN THE DOCK'S OWN LANGUAGE. The first version was a soft halo on
   the paper, which works over an empty floor and fails over the one place this
   thing actually lands — the pile. So it is the shelf: white, 16px, one 1px
   inside stroke, one soft shadow. Nothing invented, and it reads cleanly with
   forty bricks behind it. */
.nfr {
  position: absolute;
  left: 50%;
  bottom: clamp(1.5rem, 6vh, 3.5rem);
  translate: -50% 0;
  z-index: 44;
  display: grid;
  justify-items: center;
  gap: 0.25rem;
  min-width: 15rem;
  padding: 1.15rem 1.75rem 1.25rem;
  border-radius: 16px;
  text-align: center;
  background: #fff;
  box-shadow:
    inset 0 0 0 1px var(--line, #e7e4de),
    0 2px 6px -2px rgba(20, 16, 12, 0.1),
    0 22px 48px -22px rgba(20, 16, 12, 0.34);
  opacity: 0;
  transform: translateY(14px) scale(0.96);
  transition: opacity 320ms var(--ease), transform 560ms var(--spring-snap, var(--ease));
}
.nfr.is-on { opacity: 1; transform: none; }

.nfr__eb {
  margin: 0 0 0.3rem;
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 0.6875rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-dim, #b9b4ad);
}
.nfr__h {
  margin: 0;
  font-size: 1.4375rem;
  font-weight: 650;
  letter-spacing: -0.02em;
  color: var(--ink, #14100c);
}

/* the clock, stopped. Same face, same tabular figures, same size it was
   counting at — so the result is the number you were already watching */
.nfr__t {
  margin: 0.35rem 0 0.1rem;
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 1.75rem;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
  line-height: 1;
  color: var(--ink, #14100c);
}

.nfr__s {
  margin: 0.3rem 0 0.85rem;
  font-size: 0.9375rem;
  color: var(--ink-2, #3a352f);
}
.nfr__a { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; }

/* --- THE POP ----------------------------------------------------------------
   One canvas, added at the win and gone three seconds later. Under the panel
   so the words are never behind chips, over everything else so the burst is
   the thing that happened. It never takes a pointer event and it never touches
   layout — see `Game.pop()` for why it is drawn rather than composed. */
.nfc {
  position: absolute;
  inset: 0;
  z-index: 43;
  pointer-events: none;
  opacity: 1;
  transition: opacity 400ms var(--ease);
}
.nfc.is-out { opacity: 0; }

/* --- NOT THAT ONE -----------------------------------------------------------
   The entire punishment. Four pixels of shake over a fifth of a second, on the
   piece and on nothing else — no outline, no colour, no counter, and the brick
   stays exactly where it was put down and stays draggable. */
@keyframes brkNope {
  0%, 100% { translate: 0 0; }
  20%      { translate: -3px 0; }
  45%      { translate: 3px 0; }
  70%      { translate: -1.5px 0; }
  88%      { translate: 1px 0; }
}
.drg.brk.is-nope { animation: brkNope 380ms var(--ease-io, ease-in-out); }

/* --- PHONES -----------------------------------------------------------------
   The same game, re-laid rather than shrunk. The room is taller than it is
   wide and the sign sits in the middle of it, so the two controls cannot stay
   on the left and right flanks — they go to the corners the thumb reaches,
   above the dock, and the clock keeps the top edge to itself. */
@media (max-width: 48rem) {
  /* THE EMPTY BAND, NOT THE BOTTOM CORNERS. On a phone the room is taller than
     it is wide: the sign sits in the middle, the pile builds along the floor,
     and the dock owns the bottom right — which leaves the strip under the nav
     as the only part of the canvas with nothing in it. Both controls go there,
     the start on the left and the builds on the right, keeping the relationship
     they have on a desktop without either of them landing on the bricks. */
  .nfp {
    top: calc(3.25rem + var(--sa-top, 0px));
    right: clamp(0.75rem, 4vw, 1.25rem);
    gap: 0.4rem;
  }
  .nfp { padding: 0.4rem 0.4rem 0.35rem; }
  .nfp__it { width: 38px; height: 34px; }
  .nfp__art svg { max-width: 26px; max-height: 23px; }
  .nfp__tip { top: calc(100% + 7px); right: 0; left: auto; }

  .nfs {
    top: calc(3.25rem + var(--sa-top, 0px));
    left: clamp(0.75rem, 4vw, 1.25rem);
    translate: none;
    max-width: 9.5rem;
    padding: 0.5rem 0.55rem 0.55rem;
  }
  .nfs__note { font-size: 0.75rem; line-height: 1.4; }

  /* clear of the nav, which is a full-width bar here rather than an edge tab */
  .nfh {
    top: calc(3.25rem + var(--sa-top, 0px));
    column-gap: 1.25rem;
  }
  .nfh__t { font-size: 1.375rem; }

  .nfr {
    bottom: calc(5.5rem + var(--sa-bottom, 0px));
    width: min(20rem, calc(100vw - 2rem));
    min-width: 0;
    padding: 1rem 1.1rem 1.1rem;
  }
  .nfr__h { font-size: 1.25rem; }
  .nfr__t { font-size: 1.5rem; }
}

@media (prefers-reduced-motion: reduce) {
  .canvas--room .canvas__intro,
  .canvas--room .canvas__intro.is-gone,
  .nfp, .nfs, .stn, .stn__p, .nfh, .nfr { transition-duration: 1ms; }
  .nfc { display: none; }
  .drg.brk.is-nope { animation: none; }
  .nfs__pl { transition: none; }
}

/* ==========================================================================
   42. WORK — THE ARCHIVE
   The same canvas as the home page and the 404: dotted paper, the same type,
   the same radius, the same restraint. What is different is the rhythm — four
   pieces of work at a size where you can actually read them, in two columns
   that do not travel at quite the same rate.
   ========================================================================== */

.wk {
  position: relative;
  min-height: 100svh;
  padding: clamp(5rem, 12vh, 8.5rem) clamp(1.25rem, 4vw, 3.5rem) clamp(4rem, 9vh, 7rem);
  overflow: clip;                       /* the drift must not open a scrollbar */
}

/* --- the opening ---------------------------------------------------------
   An editorial line, not a hero. The page says what it is and gets out of the
   way; the work is the thing that is supposed to be large here. */
.wk__head { max-width: 46rem; margin: 0 0 clamp(3rem, 7vh, 5rem); }

.wk__eyebrow {
  margin: 0 0 0.9rem;
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 0.75rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink-dim, #b9b4ad);
}

.wk__lede {
  margin: 0;
  font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2.125rem);
  line-height: 1.22;
  font-weight: 620;
  letter-spacing: -0.018em;
  color: var(--ink, #14100c);
  max-width: 30ch;
}

/* --- the filter ----------------------------------------------------------
   Removed. There was a row of pills under the headline — All, and one per
   discipline — that dimmed the tiles outside the set. With four pieces on the
   page there is nothing to find: every one of them is already visible, so the
   control could only take things away, and it was the first thing the eye hit
   under the sentence. The disciplines still read on each tile's metadata
   line. */

/* --- the columns ---------------------------------------------------------
   Two, because there are four pieces and each is meant to be read. The second
   one carries the drift; the first does not, so the offset between them is the
   thing you feel rather than the movement itself. */
.wk__cols {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(1.25rem, 3vw, 2.75rem);
  align-items: start;
}
.wk__col { display: flex; flex-direction: column; gap: clamp(2.5rem, 6vh, 4.5rem); }
.wk__col[data-col='1'] {
  transform: translate3d(0, var(--wk-drift, 0px), 0);
  will-change: transform;
}
/* the second column starts lower, so the two read as a mosaic rather than a
   pair of stacks that happen to be side by side */
@media (min-width: 48.0625rem) {
  .wk__col[data-col='1'] { margin-top: clamp(3rem, 8vh, 6rem); }
}

/* --- a piece of work -----------------------------------------------------
   No card. The visual sits on the paper and the caption sits under it, which
   is the whole difference between an archive and a grid of tiles. */
.wkt {
  display: block;
  text-decoration: none;
  color: inherit;
  position: relative;
  transition: transform 320ms var(--spring-soft, cubic-bezier(0.16, 1, 0.3, 1));
}

.wkt__media {
  position: relative;
  aspect-ratio: 4 / 3;
  border-radius: 16px;
  overflow: hidden;
  background: var(--paper-2, #fffefc);
  box-shadow:
    0 0 0 1px rgba(20, 16, 12, 0.055),
    0 10px 30px -18px rgba(20, 16, 12, 0.28);
  transition: box-shadow 320ms var(--ease), transform 420ms var(--spring-soft, cubic-bezier(0.16, 1, 0.3, 1));
}
/* the artwork fills the frame — everything except the pill, which is furniture
   sitting on top of it rather than another layer of the picture */
.wkt__media > *:not(.wkt__go) { width: 100%; height: 100%; }

/* THE HOVER IS THE DOCK'S HOVER, NOT A CARD'S. Two or three pixels of lift, a
   shadow that deepens rather than spreads, and the artwork easing a fraction
   further than the frame it sits in — the parallax you get from picking a
   photograph up off a table. No scaling of the whole tile: at this size a 1.03
   makes the type under it visibly resample. */
:root:not(.hover-cold) .wkt:hover { transform: translateY(-3px); }
:root:not(.hover-cold) .wkt:hover .wkt__media {
  box-shadow:
    0 0 0 1px rgba(20, 16, 12, 0.08),
    0 18px 44px -22px rgba(20, 16, 12, 0.36);
}
:root:not(.hover-cold) .wkt:hover .wkt__media > *:not(.wkt__go) { transform: scale(1.018); }
.wkt__media > *:not(.wkt__go) { transition: transform 620ms var(--spring-soft, cubic-bezier(0.16, 1, 0.3, 1)); }
.wkt:active { transform: translateY(-1px) scale(0.996); }

/* --- the caption ---------------------------------------------------------
   Number, title, and the metadata line the home page already writes — set in
   the mono face and uppercased, which is the one borrowing from the reference
   that belongs here: it separates the fact from the name without a rule. */
.wkt__cap {
  display: flex;
  gap: 0.85rem;
  margin-top: 1.05rem;
  align-items: baseline;
}
.wkt__no {
  flex: none;
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 0.75rem;
  color: var(--ink-dim, #b9b4ad);
  padding-top: 0.12rem;
}
.wkt__lines { display: flex; flex-direction: column; gap: 0.22rem; min-width: 0; }
.wkt__title {
  font-size: clamp(1.0625rem, 0.95rem + 0.4vw, 1.25rem);
  font-weight: 600;
  letter-spacing: -0.012em;
  line-height: 1.28;
  color: var(--ink, #14100c);
  transition: opacity 200ms var(--ease);
}
.wkt__meta {
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 0.6875rem;
  letter-spacing: 0.075em;
  text-transform: uppercase;
  color: var(--ink-dim, #b9b4ad);
  transition: color 220ms var(--ease);
}
:root:not(.hover-cold) .wkt:hover .wkt__meta { color: var(--ink-2, #3a352f); }

/* THE AFFORDANCE, ON THE ARTWORK, only where a case study exists. It is a
   child of `.wkt__media`, so this is the bottom-left of the picture. Parented
   to the tile — which is also positioned — `bottom` measured from under the
   caption instead, and the pill covered the title on hover. */
.wkt__go {
  position: absolute;
  z-index: 2;
  left: 0.9rem;
  bottom: 0.9rem;
  padding: 0.34rem 0.7rem;
  border-radius: 999px;
  background: var(--ink, #14100c);
  color: var(--paper, #f7f6f3);
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 0.6875rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  opacity: 0;
  transform: translateY(6px);
  pointer-events: none;
  transition: opacity 220ms var(--ease), transform 360ms var(--spring-soft, cubic-bezier(0.16, 1, 0.3, 1));
}
:root:not(.hover-cold) .wkt:hover .wkt__go { opacity: 1; transform: none; }
/* a piece with nothing behind it does not pretend otherwise */
.wkt.is-quiet { cursor: default; }

/* --- the credits ---------------------------------------------------------
   There is deliberately no CSS here. The five credits used to be a `.wk__also`
   list with its own heading, row grid, mono year column and right-aligned
   role — thirty lines that reproduced the home page's index table badly. They
   are the index table now (section 9, `.index / .tabs / .rows / .row`), built
   by the same `Tabs` module with Projects as the first tab, so there is one
   description of what a record row looks like on this site instead of two
   that drift apart. Nothing to style; deleting the rules was the change.

   Two lines are left, both about where it sits rather than what it is.

   It lives inside the canvas here, so it needs a layer: `.canvas__dots` is
   z-index 0 and the bricks sit above that, and without a stacking position
   the table would be painted under both.

   And it is centred. The home page pays 4.75rem off the right so the table
   lines up with the prose column above it; this page has no prose column, so
   that offset would just be 38px of drift with nothing to align to. */
.index--work {
  position: relative;
  z-index: 3;
  padding-inline: 0;
  /* the second column hangs lower than the first, so the archive's last
     caption ends much closer to this than the grid's own bottom suggests */
  padding-top: clamp(5rem, 13vh, 9rem);
}

/* --- the phone -----------------------------------------------------------
   One column, and the drift comes off with it: on a narrow screen the second
   column is underneath the first, so offsetting it is offsetting the whole
   page. The work stays the same size relative to the screen. */
@media (max-width: 48rem) {
  .wk { padding-top: clamp(4rem, 10vh, 5.5rem); }
  .wk__cols { grid-template-columns: minmax(0, 1fr); gap: clamp(2.5rem, 7vh, 3.5rem); }
  .wk__col[data-col='1'] { transform: none; margin-top: 0; }
  .wkt__media { aspect-ratio: 5 / 4; border-radius: 14px; }
  .wkt__go { opacity: 1; transform: none; }   /* no hover on a phone */
}

@media (prefers-reduced-motion: reduce) {
  .wkt, .wkt__media, .wkt__media > *, .wkt__go { transition-duration: 1ms; }
  .wk__col[data-col='1'] { transform: none; }
}
