/* ============================================================
   Crystal Zhang — "Quiet Precision" design system
   One source of truth for every page. Edit tokens first.
   ============================================================ */

/* ============ TOKENS ============ */
:root {
  /* canvas tints toward the accent's hue (cool), not away from it: the old
     #FAFAF7 leaned warm-yellow under a cold ultramarine accent system.
     Deliberately near-white. This was tried at #F2F3F6 to give cards more tone
     to separate against; the page read heavy and grey and it was reverted. The
     six points between this and --bg-raised are not meant to carry a card on
     their own — --shadow-rest does that job. Do not darken this to fix a card. */
  --bg: #F9F9FB; --bg-raised: #FFFFFF;
  /* --ink-2 ≥4.5:1 on both bgs (was #75747C, 4.42:1 on --bg);
     --ink-3 is a LARGE/decorative-only tier — ≥3:1 (was #A3A2AA, 2.42:1) */
  /* Text tiers — exactly three in running use, never ad-hoc grays:
     --ink       headings + emphasis
     --ink-body  ALL content prose (7.5:1 — content never drops below this)
     --ink-2     metadata only: labels (Rolle/Typ/Status), dates, captions
                 (pills moved to --ink-body when they gained a tinted fill —
                  see .pill; the chip raises their ground past what --ink-2 holds)
     --ink-3     large decorative numerals only, never running text */
  /* Hierarchy through value: near-black titles over a lighter gray body.
     --ink-body sits at ~5.6:1 on --bg, comfortably past the 4.5:1 AA floor. */
  --ink: #1F1E24; --ink-body: #5C5B64; --ink-2: #6F6E76; --ink-3: #8D8C96;
  --line: rgba(19,18,23,0.14); --line-strong: rgba(19,18,23,0.32);
  --accent: #2C39FF; --accent-dim: rgba(44,57,255,0.08);
  --accent-inverse: #8A91FF;
  /* One family, weight-contrasted: the serif display concept is retired,
     Switzer carries statements and body alike */
  --font-display: "Switzer",system-ui,-apple-system,sans-serif;
  --font-body: "Switzer",system-ui,-apple-system,sans-serif;
  /* Type scale — compressed modular ladder (~1.2 minor third), rebased on a
     16px body (the 18px base read oversized site-wide); every tier scaled by
     the same ~0.89 factor so the ratios hold. */
  /* 56px, not 64: 64 does not fit the 888px measure. At 64 the German
     headline sets 953px and the English 935, so both wrap a word onto a
     third line the break was never written for. 56 is the largest ceiling
     that keeps the written two-line break in both languages, and the hero
     A block in index.html restates exactly this clamp. */
  --text-hero: clamp(2.0625rem, 1.35rem + 2.5vw, 3.5rem); /* 33 → 56px */
  --text-h1: clamp(1.875rem, 1.5rem + 2.3vw, 2.625rem);  /* 30 → 42px */
  --text-h2: clamp(1.5rem, 1.25rem + 1.6vw, 2rem);       /* 24 → 32px */
  --text-h3: clamp(1.25rem, 1.05rem + 0.9vw, 1.5rem);    /* 20 → 24px */
  --text-lede: clamp(1.125rem, 1rem + 0.6vw, 1.3125rem); /* 18 → 21px */
  --text-body: 1rem;                                      /* 16px */
  --text-small: 1rem;                                     /* 16px — already at the new base, stays; differs from body by weight and ink */
  --text-label: 0.8125rem;                                /* 13px */
  /* Display tracking. Applies to Switzer at 600, which is where every
     statement on the site is set; body text keeps its natural tracking. */
  --track-display: -0.022em;  /* 48px and up: the hero */
  --track-lg: -0.02em;        /* 24 to 48px: page titles, figures, footer */
  --track-md: -0.01em;        /* 20 to 24px: section and card headings */
  /* Spacing — shadcn-style scale, strict 4pt grid */
  --space-2xs: 0.25rem;  /*  4px — label→value, tightest related pairs */
  --space-xs: 0.5rem;    /*  8px — within a text cluster */
  --space-sm: 0.75rem;   /* 12px */
  --space-md: 1rem;      /* 16px — default gap */
  --space-lg: 1.5rem;    /* 24px */
  --space-xl: 2rem;      /* 32px — between distinct clusters */
  --space-2xl: 2.5rem;   /* 40px — scaled with the 16px type rebase */
  --space-3xl: 3.5rem;   /* 56px */
  /* tightened toward the Stripe cadence: segments hold less inner air so
     content, not padding, sets the rhythm */
  --space-block: clamp(1rem,2vw,1.75rem);       /* 16→28px, fluid */
  --space-section: clamp(2rem,3.4vw,3rem);      /* 32→48px, fluid */
  /* 16px floor on phones; ~58px at 1440, 96px cap on wide screens. The old
     1.5rem cap was a phone value doing desktop duty: sides sat at 24px while
     the vertical rhythm ran 48-72px, and the column rules grazed the text. */
  /* Stripe-leaning gutter: content owns more of the frame; the old 6rem cap
     left wide dead margins inside the column rules */
  --max-width: 1440px; --gutter: clamp(1rem,2.5vw,3rem);
  /* ONE grid for the whole site. The frame is 888 - the nav, every
     homepage section, the footer and the related-cases block all sit on
     it, and case pages do too. Inside that frame a case page splits:

         888  =  684 reading column  +  24 gap  +  180 section rail

     684 is ~74 characters of Switzer at 16px, so the column is a real
     measure rather than a frame filled with text. Everything in a case
     page takes it - prose, headings, diagrams, covers alike - so no
     figure is ever wider than the paragraph above it, and the column
     starts on the SAME left edge as the homepage and the cards below.
     The 204 it stops short of on the right is not leftover: it is the
     rail's track. Consumed by shell.css, which owns case-page geometry. */
  --cs-rail-track: 204px;   /* 180px rail + 24px gap */
  --cs-measure: calc(888px - var(--cs-rail-track));   /* 684px */
  /* Hairline grid colour, shared by the homepage rules and the case rail.
     (index.html re-declares it; the values must stay in step.) */
  --rule: rgba(19,18,23,0.07);
  /* section tint: the accent at a whisper, so a band can replace hairlines */
  /* Stripe's band is a cool blue-GREY, not a tint of the accent: mixing in
     a violet-blue accent at any readable strength turns lavender. This is a
     clear step off the page without shouting. */
  --section-tint: #EFF3FA;
  /* the page's one dark beat: navy pulled from the accent, not a stray hue */
  --invert-bg: color-mix(in srgb, var(--accent) 16%, #0A0C16);
  --icon-size: 16px;
  /* Globe mark for the language toggle, carried as a mask so it inherits
     currentColor along with the rest of the control. */
  --globe: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.25' stroke-linecap='round'%3E%3Ccircle cx='8' cy='8' r='6.25'/%3E%3Cellipse cx='8' cy='8' rx='2.9' ry='6.25'/%3E%3Cpath d='M2.05 5.9h11.9M2.05 10.1h11.9'/%3E%3C/svg%3E");
  /* Radius system — four stops, nothing in between:
     tag pills fully round, buttons squared to 8px (Stripe posture),
     media generously round, panels softly round */
  --radius-pill: 999px;
  --radius-btn: 4px;
  --radius-media: 20px;
  --radius-panel: 12px;
  /* Button arrow, two states: the resting chevron and the full arrow it
     fades into on hover. Colour comes from the mask, so both inherit the
     label colour in every theme. */
  --arrow-head-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M13 6l6 6-6 6'/%3E%3C/svg%3E");
  --arrow-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14M13 6l6 6-6 6'/%3E%3C/svg%3E");
  /* Content colour for the cursor chip. Deliberately NOT re-declared under
     the dark theme: the chip's ground is fixed black glass in both themes,
     so its ink is fixed too. */
  --chip-ink: #F9F9FB;
  /* Pointer glyph for the laptop "Try it" chip; a mask, so it inherits the
     label colour like every other icon */
  --cursor-click-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z'/%3E%3Cpath d='M13 13l6 6'/%3E%3C/svg%3E");
  /* CTA disc arrow. Same path, stroke and caps as --arrow-svg above: the disc
     must not introduce a second arrow drawing. It carries a literal colour
     rather than currentColor because a background-image cannot inherit one,
     and the disc is an inversion — a light disc on the ink button needs an ink
     arrow, so it can never just take the label's colour. Hence one token per
     ground, each re-declared under the dark theme. */
  --cta-arrow-on-light: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%231F1E24' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14M13 6l6 6-6 6'/%3E%3C/svg%3E");
  --cta-arrow-on-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23F9F9FB' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14M13 6l6 6-6 6'/%3E%3C/svg%3E");
  --ease-out-expo: cubic-bezier(0.16,1,0.3,1);
  /* The hero entrance only. --ease-out-expo spends 80% of its distance in the
     first quarter, which is right for a hover but kills a defocus: the blur
     would be gone before the eye registers it. This curve decelerates gently
     enough that the haze is still readable halfway through. */
  --ease-emerge: cubic-bezier(0.25,0.46,0.45,0.94);
  --dur-fast: 300ms; --dur-med: 700ms; --dur-slow: 1100ms;
  /* Elevation, three tiers. Each is a tight contact shadow plus a wide soft
     one, so a surface reads as an object above the page instead of a slab
     printed on it. The tiers are ordered by how far the shadow throws, not by
     how dark it is:
       --shadow-float  the floating layer: the nav bar and the dock
       --shadow-chip   controls riding inside that bar, their own small volume
       --shadow-rest   surfaces that sit ON the page rather than over it —
                       case cards, the portrait frame
     --shadow-rest is the ONLY thing separating a white card from the page:
     --bg is near-white by choice, so there is no tone to help. That is why it
     runs three layers (contact, mid, ambient) rather than two. It still throws
     wider and softer than --shadow-float, whose tight -12px spread is what
     keeps the bar reading as higher than anything resting under it. */
  --shadow-float: 0 1px 2px rgba(19,18,23,0.05), 0 10px 28px -12px rgba(19,18,23,0.22);
  --shadow-chip: 0 1px 2px rgba(19,18,23,0.07), 0 2px 6px -2px rgba(19,18,23,0.10);
  /* the two states a chip can be in under a pointer: lifted, and pressed */
  --shadow-chip-hover: 0 1px 2px rgba(19,18,23,0.08), 0 6px 14px -4px rgba(19,18,23,0.18);
  --shadow-chip-press: 0 1px 1px rgba(19,18,23,0.10);
  --shadow-rest: 0 1px 2px rgba(19,18,23,0.05), 0 4px 10px -4px rgba(19,18,23,0.05), 0 24px 56px -24px rgba(19,18,23,0.22);
  /* --shadow-rest at small scale. Same three-layer structure so a small
     surface belongs to the same family as a case card, but a 56px ambient
     thrown by a quote card reads as a shadow looking for a bigger object.
     The throw is pulled in and every layer lightened. */
  --shadow-rest-sm: 0 1px 2px rgba(19,18,23,0.04), 0 3px 8px -4px rgba(19,18,23,0.04), 0 14px 32px -18px rgba(19,18,23,0.16);
}
[data-theme="dark"] {
  --bg: #0E0E11; --bg-raised: #17171B;
  /* dark --ink-3 ≥3:1 large/decorative tier (was #5C5B63, 2.87:1);
     dark --accent lifted from #5560FF (4.13:1 on --bg) to #757DFF —
     5.6:1 as link text on --bg AND 5.6:1 under dark text on accent fills */
  --ink: #F2F1ED; --ink-body: #ACABB4; --ink-2: #A2A1A9; --ink-3: #6C6B73;
  --line: rgba(242,242,239,0.14); --line-strong: rgba(242,242,239,0.32);
  --rule: rgba(255,255,255,0.08);
  --section-tint: color-mix(in srgb, var(--accent) 6%, var(--bg));
  --invert-bg: color-mix(in srgb, var(--accent) 12%, #06070E);
  --accent: #757DFF; --accent-dim: rgba(117,125,255,0.12);
  --accent-inverse: #2C39FF;
  /* the disc inverts with the theme, so both arrows swap grounds with it */
  --cta-arrow-on-light: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23F2F1ED' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14M13 6l6 6-6 6'/%3E%3C/svg%3E");
  --cta-arrow-on-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230E0E11' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14M13 6l6 6-6 6'/%3E%3C/svg%3E");
  /* on a near-black page a grey shadow disappears; go darker and tighter */
  --shadow-float: 0 1px 2px rgba(0,0,0,0.4), 0 12px 32px -14px rgba(0,0,0,0.7);
  --shadow-chip: 0 1px 2px rgba(0,0,0,0.35), 0 2px 6px -2px rgba(0,0,0,0.45);
  --shadow-chip-hover: 0 1px 2px rgba(0,0,0,0.4), 0 6px 16px -4px rgba(0,0,0,0.6);
  --shadow-chip-press: 0 1px 1px rgba(0,0,0,0.5);
  --shadow-rest: 0 1px 2px rgba(0,0,0,0.35), 0 4px 10px -4px rgba(0,0,0,0.4), 0 24px 56px -24px rgba(0,0,0,0.7);
  --shadow-rest-sm: 0 1px 2px rgba(0,0,0,0.3), 0 3px 8px -4px rgba(0,0,0,0.32), 0 14px 32px -18px rgba(0,0,0,0.55);
}

/* ============ BASE ============ */
*,*::before,*::after { box-sizing: border-box; margin: 0; }
html { scroll-behavior: smooth; }
/* every anchor target clears the fixed 72px bar plus breathing room */
[id] { scroll-margin-top: calc(72px + var(--space-lg)); }
html.theme-switching body { transition: background-color 400ms, color 400ms; }
body {
  background: var(--bg); color: var(--ink-body);
  font-family: var(--font-body); font-size: var(--text-body); line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}
img { max-width: 100%; display: block; }
a { color: inherit; text-decoration: none; }
/* selection text = --bg: light #FAFAF7-on-#2C39FF 6.4:1; dark #0E0E11-on-#757DFF 5.6:1
   (hardcoded #FAFAF7 was 4.46:1 on the dark accent) */
::selection { background: var(--accent); color: var(--bg); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 2px; }
.container { max-width: var(--max-width); margin-inline: auto; padding-inline: var(--gutter); }
/* Colour alone. The italic was a second axis of emphasis on top of the
   colour, and at hero size it broke the headline into two styles: the roman
   read as the statement and the italic as an aside inside it. One weight,
   one style, one colour change. */
.accent-word { font-family: var(--font-display); font-style: normal; color: var(--accent); }

/* Film grain */
body::after {
  content: ""; position: fixed; inset: 0; z-index: 9999; pointer-events: none;
  opacity: 0.035;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* Cursor follower (glass lens; gated in JS) — trails the real pointer */
.cursor-dot {
  /* Authored at LARGEST size (40px) and scaled DOWN in JS (0.26 rest / 0.75 hover) —
     scaling a small element UP rasterizes at the small size and blurs.
     Everything inside scales with the element, so the ring, blur and shadow are
     authored heavy: at the 0.75 hover scale they land on the nav's glass recipe
     (~1.5px ring, ~13px blur), and at the 0.26 rest scale they thin to a
     hairline lens rather than disappearing. */
  position: fixed; left: 0; top: 0; width: 40px; height: 40px; border-radius: 50%;
  /* Smoked glass, not a lifted one: it darkens what it passes over instead of
     brightening it. The tint is a FIXED tonal near-black rather than --ink, so
     the lens stays black under the dark theme too instead of flipping to a
     white puck. brightness() carries most of the darkening, the tint just
     seats it; the thin white inset is the specular edge that keeps it reading
     as glass rather than as a hole punched in the page. */
  border: 2px solid rgba(255,255,255,0.2);
  background: rgba(19,18,23,0.58);
  backdrop-filter: blur(16px) saturate(150%) brightness(0.4);
  -webkit-backdrop-filter: blur(16px) saturate(150%) brightness(0.4);
  box-shadow: inset 0 2px 3px rgba(255,255,255,0.18),
              0 2px 10px -4px rgba(19,18,23,0.35);
  z-index: 10001; pointer-events: none; opacity: 0;
  transform: translate(-50%,-50%) scale(0.26);
  transition: opacity 300ms var(--ease-out-expo);
  will-change: transform;
}
/* Dark ground: still black glass, but there is less light left to take away,
   so the rim does more of the work and the darkening eases off. */
[data-theme="dark"] .cursor-dot {
  border-color: rgba(255,255,255,0.26);
  background: rgba(0,0,0,0.55);
  backdrop-filter: blur(16px) saturate(150%) brightness(0.55);
  -webkit-backdrop-filter: blur(16px) saturate(150%) brightness(0.55);
  box-shadow: inset 0 2px 3px rgba(255,255,255,0.2),
              0 2px 10px -4px rgba(0,0,0,0.5);
}
/* Without backdrop-filter there is nothing to refract, so the white tint alone
   would be a pale blob following the pointer. Drop to the rim only. */
@supports not (backdrop-filter: blur(1px)) {
  .cursor-dot { background: transparent; box-shadow: none; }
}
/* The system cursor stays VISIBLE. The dot used to replace it (cursor: none),
   which meant the dot had to sit exactly on the pointer — it could never trail,
   because a trail with no arrow in front of it is just a slow cursor. Now the
   arrow marks the true position and the dot follows it. */

/* Cursor chip — the pointer itself becomes a pill that names the action.
   Same anatomy as every other pill on the page (glyph, then the action's
   own words, fully round), sitting on .cursor-dot's black-glass recipe so
   the lens and the chip read as one pointer system rather than two. One
   element for the whole page; site.js hands it to whichever surface the
   pointer is over and writes the position straight in, so only presence is eased. */
.cursor-chip {
  position: fixed; left: 0; top: 0; z-index: 10002;
  display: inline-flex; align-items: center; gap: var(--space-xs);
  padding: 10px 16px; border-radius: var(--radius-pill);
  font-family: var(--font-body); font-size: var(--text-body); font-weight: 500;
  line-height: 1; white-space: nowrap;
  /* The ground is fixed black glass in BOTH themes, exactly like the lens,
     so the content colour has to be fixed too: --bg would flip to near-black
     under the dark theme and disappear into the glass. */
  color: var(--chip-ink);
  border: 1.5px solid rgba(255,255,255,0.2);
  background: rgba(19,18,23,0.58);
  backdrop-filter: blur(16px) saturate(150%) brightness(0.4);
  -webkit-backdrop-filter: blur(16px) saturate(150%) brightness(0.4);
  box-shadow: inset 0 2px 3px rgba(255,255,255,0.18),
              0 2px 10px -4px rgba(19,18,23,0.35);
  pointer-events: none; opacity: 0;
  transform: translate(-50%,-50%);
  transition: opacity var(--dur-fast) var(--ease-out-expo);
  /* site.js moves this with translate3d, not left/top: geometry writes at
     pointer rate relayout the page, a transform stays on the compositor */
  will-change: transform;
}
[data-theme="dark"] .cursor-chip {
  border-color: rgba(255,255,255,0.26);
  background: rgba(0,0,0,0.55);
  backdrop-filter: blur(16px) saturate(150%) brightness(0.55);
  -webkit-backdrop-filter: blur(16px) saturate(150%) brightness(0.55);
  box-shadow: inset 0 2px 3px rgba(255,255,255,0.2),
              0 2px 10px -4px rgba(0,0,0,0.5);
}
/* Nothing to refract without backdrop-filter, so the glass becomes a solid */
@supports not (backdrop-filter: blur(1px)) {
  .cursor-chip { background: rgba(19,18,23,0.92); }
}
.cursor-chip.is-on { opacity: 1; }
/* The glyph, masked so it takes the label colour. Defaults to the house
   arrow; a surface can swap in another via --chip-icon. */
.cursor-chip::before {
  content: ""; width: var(--icon-size); height: var(--icon-size); flex: none;
  background: currentColor;
  -webkit-mask: var(--chip-icon, var(--arrow-svg)) center / contain no-repeat;
  mask: var(--chip-icon, var(--arrow-svg)) center / contain no-repeat;
}
/* The lens stands down while the chip is up: two pointers is one too many */
.cursor-dot.is-suppressed { opacity: 0 !important; }

/* ============ LABEL (house signature) ============ */
.label {
  /* functional labels: 16px — the 14px --text-label tier is reserved for rare captions */
  font-family: var(--font-body); font-size: var(--text-small); font-weight: 600;
  color: var(--ink-2);
}
.label--accent { color: var(--accent); }

/* ============ PILLS (ported from card-tag) ============ */
.pills { display: flex; flex-wrap: wrap; gap: var(--space-xs); }
/* A fill, not an outline. The tint is mixed from --ink rather than being a
   fixed hex so one rule works on every ground the pill lands on: it darkens
   white card surfaces and the page grey alike, and inverts to a light chip
   under the dark theme without a second declaration.
   The label runs at --ink-body, one tier below where pills used to sit. The
   chip raises the ground under the text, and --ink-2 could not clear AA on
   it anywhere off a white card (4.27:1 on --bg at every tint that was worth
   having). --ink-body holds 6.20:1 on a card and 5.57:1 on the page. */
.pill {
  display: inline-block;
  font-family: var(--font-body); font-size: var(--text-small); font-weight: 600;
  color: var(--ink-body);
  background: color-mix(in srgb, var(--ink) 4%, transparent);
  border-radius: var(--radius-pill);
  padding: var(--space-2xs) var(--space-sm); line-height: 1.5;
}
/* Same mechanic, spent in colour: one per case card, on the lead capability.
   Four accents across the page reads as emphasis; twelve read as wallpaper
   and would compete with the CTA sweep directly beneath them. */
.pill--accent { color: var(--accent); background: var(--accent-dim); }

/* ============ BUTTONS (native to this system: label-set type, accent sweep) ============ */
.btn {
  position: relative; overflow: hidden; isolation: isolate;
  display: inline-flex; align-items: center;
  /* Auto-layout spaced at the end: the label sits at one edge and the disc at
     the other. space-between only bites once the button has width to give (the
     phone rule below stretches it), so the gap sets the minimum separation and
     keeps the shape when the button is content-sized. */
  justify-content: space-between; gap: var(--space-lg);
  /* Asymmetric by necessity. The label needs its 20px inset, but the disc is a
     34px object that should sit almost flush: 8px around it, which is also the
     vertical padding, so the disc is evenly inset on the three edges it meets.
     34 + 8 + 8 lands the button back on its original 50px height. */
  padding: var(--space-xs) var(--space-xs) var(--space-xs) 20px;
  border-radius: var(--radius-pill);
  font-family: var(--font-body); font-size: var(--text-small); font-weight: 600;
  letter-spacing: -0.01em;
  cursor: pointer; border: none;
  transition: color var(--dur-med) var(--ease-out-expo), border-color var(--dur-med) var(--ease-out-expo);
}
/* The accent sweep is gone. Hover is a tonal step in the button's own ink
   instead: the disc already turns on hover, so the surface only has to
   acknowledge the pointer, not perform. This also retires the sweep's whole
   support system — the label no longer has to be --bg to survive a colour
   passing under it, and nothing needs to sit at z-index -1. */
.btn-primary {
  background: var(--ink); color: var(--bg);
  transition: background-color var(--dur-fast) var(--ease-out-expo);
}
.btn-primary:hover, .btn-primary:focus-visible {
  background: color-mix(in srgb, var(--ink) 86%, var(--bg));
}
/* Primary CTAs end in a disc: a filled circle carrying the system's own arrow,
   the same path and 2.25 stroke as --arrow-svg, never a second arrow drawing.
   It rests pointing north-east and swings to due east on hover. Rotating the
   whole pseudo-element is safe because a circle is rotationally symmetric, so
   only the arrow appears to move. `rotate` rather than `transform` so a future
   nudge or scale can be composed without either overwriting the other. */
.btn-primary::after, .btn-outline::after {
  content: ""; width: 34px; height: 34px; flex: none;
  border-radius: 50%;
  background-repeat: no-repeat; background-position: center; background-size: 18px 18px;
  rotate: -45deg;
  transition: rotate var(--dur-fast) var(--ease-out-expo);
}
/* The disc inverts its button: a light disc on the ink primary, a dark one on
   the outline, so the arrow always reads against it. */
.btn-primary::after { background-color: var(--bg); background-image: var(--cta-arrow-on-light); }
.btn-outline::after { background-color: var(--ink); background-image: var(--cta-arrow-on-dark); }
/* Only the button itself turns the arrow; a card-wide trigger made the icon
   react to hovers that had nothing to do with it. */
.btn-primary:hover::after, .btn-primary:focus-visible::after,
.btn-outline:hover::after, .btn-outline:focus-visible::after {
  rotate: 0deg;
}
@media (prefers-reduced-motion: reduce) {
  .btn-primary::after, .btn-outline::after { transition: none; }
}
/* Outline buttons stay in the primary's family: our primary is ink, so the
   secondary is neutral too, and the accent stays reserved for sweeps,
   numerals, and active states. (A blue-mixed border was tried and read as a
   stray against the dark CTA.) Hover deepens: border to full ink, plus a
   whisper of ink in the fill. */
.btn-outline { background: none; border: 1px solid var(--line-strong); color: var(--ink); }
.btn-outline:hover, .btn-outline:focus-visible { border-color: var(--ink); }
/* The footer's primary is the inverse: light fill, ink label. Its disc has to
   invert with it, or a --bg circle would vanish into a --bg button. */
.footer .btn-primary { background: var(--bg); color: var(--ink); }
.footer .btn-primary::after {
  background-color: var(--ink); background-image: var(--cta-arrow-on-dark);
}
/* This used to flip the label to --bg so it could survive the accent passing
   underneath. With the sweep retired that would paint light text on a light
   button, so hover is the same tonal step the dark primary takes, mirrored. */
.footer .btn-primary:hover, .footer .btn-primary:focus-visible {
  background: color-mix(in srgb, var(--bg) 86%, var(--ink));
}
/* the default accent outline is 2.04:1 against the light-theme footer panel —
   accent-inverse holds ≥4.9:1 (light) / ≥5.4:1 (dark) there */
.footer :focus-visible { outline-color: var(--accent-inverse); }
/* Card CTAs are real buttons (compact outline pills), not bare labels —
   one button language everywhere. Rendered as <span> inside the card link. */
.project-cta { transition: color var(--dur-med) var(--ease-out-expo), border-color var(--dur-med) var(--ease-out-expo); }
/* Container hover deepens the outline CTA's border only. Never restyle the
   CTA's TEXT color from a container hover — accent text over the primary
   button's accent sweep makes the label vanish. */
.project:hover .project-cta, .project:focus-visible .project-cta,
.mini-card:hover .project-cta, .mini-card:focus-visible .project-cta {
  border-color: var(--ink);
}

/* ============ SCROLL PROGRESS ============ */
/* A hairline of accent along the very top edge, tracking read position. It sits
   above the floating nav pill (z-index 10) and under the grain, and it is the
   only element allowed to touch the viewport edge — everything else respects
   the container gutters. Driven by scaleX in JS, so it costs one composited
   transform per frame and never triggers layout. */
.scroll-progress {
  position: fixed; top: 0; left: 0; right: 0; height: 2px;
  z-index: 20; pointer-events: none;
  background: var(--accent);
  transform: scaleX(0); transform-origin: left center;
  will-change: transform;
}

/* ============ NAV ============ */
/* Floating glass pill. The bar no longer spans the viewport or carries a
   hairline: it is an object sitting on the page, frosted so the content
   scrolling beneath it stays faintly readable. */
.nav {
  position: fixed; top: 14px; left: 50%; transform: translateX(-50%);
  z-index: 10; width: max-content; max-width: calc(100vw - var(--space-lg) * 2);
  display: flex; align-items: center; gap: var(--space-xl);
  padding: 8px 10px 8px 22px;
  border-radius: 999px;
  /* Glass with a floor under it: the tint carries enough surface for the
     soft shadow to belong to something, and the blur still lets the page
     read through. Matched to the dock so the two bars are one family. */
  border: 1px solid color-mix(in srgb, var(--ink) 7%, transparent);
  background: color-mix(in srgb, var(--bg-raised) 55%, transparent);
  backdrop-filter: blur(20px) saturate(180%);
  -webkit-backdrop-filter: blur(20px) saturate(180%);
  box-shadow: var(--shadow-float);
  transition: background-color var(--dur-med) var(--ease-out-expo),
              gap var(--dur-med) var(--ease-out-expo);
}
[data-theme="dark"] .nav {
  border-color: rgba(255,255,255,0.09);
  background: color-mix(in srgb, var(--bg-raised) 42%, transparent);
}
/* the old full-bleed hairline has no place on a floating object */
.nav::after { display: none; }

/* ---- brand: avatar + name, as one target ---- */
/* min-width:0 so the brand is the part that gives when the pill runs out of
   room. Without it the name's nowrap kept the whole bar at max-content and the
   controls slid out past the glass on narrow phones. */
.nav-brand { display: inline-flex; align-items: center; gap: var(--space-xs); min-width: 0; }
.nav-avatar {
  width: 30px; height: 30px; flex: none; border-radius: 50%;
  display: grid; place-items: center;
  background: var(--ink); color: var(--bg);
  font-size: 11px; font-weight: 600; letter-spacing: 0.02em;
}
.nav-name { font-weight: 600; font-size: 0.875rem; color: var(--ink); }

/* ---- Contact: the one solid object in the bar ---- */
/* 14px: the bar sits below the content type, matching the reference.
   nowrap: the pill is width:max-content, so a wrapped name doubles its height. */
.nav .nav-name { font-weight: 600; font-size: var(--text-body); color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nav-links {
  /* the controls never compress or get pushed out; the name yields first */
  flex: none;
  /* 12px box gap; links carry 12px inline padding, so text-to-text rhythm
     stays generous while every adjacent pair in the bar is spaced evenly */
  --nav-gap: var(--space-sm);
  display: flex; align-items: center; gap: var(--nav-gap);
  position: relative;
}
/* 16px: one step below the name, so the bar reads as navigation and the
   name stays the anchor. 18px links made the header feel like a template. */
.nav-link { position: relative; padding-block: var(--space-2xs); font-size: var(--text-small); transition: color var(--dur-med) var(--ease-out-expo); }
/* every bar item is the same 36px flex box, so text and toggles share one
   vertical center instead of near-miss baselines */
.nav .nav-link { display: inline-flex; align-items: center; height: 36px; padding-block: 0; }
/* Header nav links read at full ink like the name — muted nav reads as faded,
   not quiet. (Footer nav-links keep their own inverted colors.) */
.nav .nav-link { color: var(--ink); }
/* Underline stays for the footer's inverted nav and for keyboard focus;
   the header bar uses a plain hover tint instead (see below). */
.nav-link::after {
  content: ""; position: absolute; left: 0; bottom: 0; width: 100%; height: 1px;
  background: var(--accent); transform: scaleX(0); transform-origin: left;
  transition: transform var(--dur-med) var(--ease-out-expo);
}
.nav .nav-link:hover::after { transform: scaleX(0); }
.nav .nav-link { padding-inline: var(--space-sm); }
.nav-link:hover, .nav-link:focus-visible { color: var(--ink); }
.nav-link:hover::after, .nav-link:focus-visible::after { transform: scaleX(1); }
/* Hover colours the WORD, not a box behind it: a filled chip inside a glass
   pill reads as a second surface. The word takes the accent rather than
   greying to --ink-3, so pointing at a control reads as the brand answering
   instead of the label dimming out. */
.nav .nav-link {
  border-radius: var(--radius-btn);
  /* The one place that stays at 500: the nav sits over content all the way
     down the page, and at 600 the links pulled focus from whatever they were
     floating on top of. */
  font-size: var(--text-small); font-weight: 500;
  transition: color var(--dur-med) var(--ease-out-expo), opacity var(--dur-fast) var(--ease-out-expo);
}
.nav .nav-link:hover, .nav .nav-link:focus-visible {
  background: transparent; color: var(--accent);
}
/* The links hold their words at every scroll position on desktop: folding
   them to dots on scroll moved the whole row, so the language and theme
   controls slid out from under the pointer mid-reach. The dots survive only
   as the mobile burger below, where they stand in for links that are
   genuinely hidden. */
.nav .nav-link { white-space: nowrap; }
/* SVG circles take fill, not background */
@keyframes navHopFill {
  0%, 50%, 100% { transform: translateY(0); fill: var(--ink-3); }
  25%           { transform: translateY(-2.5px); fill: var(--ink); }
}
@media (prefers-reduced-motion: reduce) {
  .nav, .nav .nav-link { transition: none; }
  .nav-burger .burger-lines circle { animation: none; transform: none; }
}
/* Nav controls — both 36px so every object in the bar shares one height, and
   both carry the same surface + hairline + --shadow-chip volume, so they read
   as one pair of controls sitting ON the glass. Only the outline differs with
   the content: the theme toggle is icon-only so it stays a circle, the
   language toggle carries a globe plus a word so it widens into a pill.
   Spacing comes from the shared flex gap alone; no per-control margins. */
.theme-toggle, .lang-toggle {
  cursor: pointer; color: var(--ink);
  height: 36px; display: inline-flex; align-items: center; justify-content: center;
  background: var(--bg-raised);
  border: 1px solid color-mix(in srgb, var(--ink) 8%, transparent);
  box-shadow: var(--shadow-chip);
  transition: border-color var(--dur-med) var(--ease-out-expo),
              box-shadow var(--dur-med) var(--ease-out-expo),
              transform var(--dur-med) var(--ease-out-expo);
}
[data-theme="dark"] .theme-toggle,
[data-theme="dark"] .lang-toggle { border-color: rgba(255,255,255,0.10); }
/* Hover LIFTS, it does not recolour. A soft button's whole story is the
   shadow, and a tinted fill flattens it: the accent wash sat in front of the
   volume, so the control stopped reading as an object on the glass and
   started reading as a flat blue chip. Surface and ink hold still; only the
   elevation moves — deeper shadow and 1px of rise on hover, then the shadow
   collapses and it settles back down on press, the way an object under a
   finger behaves. The accent still arrives on keyboard focus through the
   global :focus-visible ring, so nothing is lost for a11y. */
.theme-toggle:hover, .theme-toggle:focus-visible,
.lang-toggle:hover, .lang-toggle:focus-visible {
  border-color: color-mix(in srgb, var(--ink) 16%, transparent);
  box-shadow: var(--shadow-chip-hover);
  transform: translateY(-1px);
}
[data-theme="dark"] .theme-toggle:hover, [data-theme="dark"] .theme-toggle:focus-visible,
[data-theme="dark"] .lang-toggle:hover, [data-theme="dark"] .lang-toggle:focus-visible {
  border-color: rgba(255,255,255,0.20);
}
.theme-toggle:active, .lang-toggle:active {
  transform: translateY(0); box-shadow: var(--shadow-chip-press);
}
/* Round icon button: same circle and same volume as the dock's controls, so
   every icon-only button on the site is one object. */
.theme-toggle {
  width: 36px; padding: 0; border-radius: 50%;
  font-size: var(--icon-size); line-height: 1;
}
/* Pill icon button: the round toggle's surface widened to hold a globe and
   the target language. The globe is a CSS mask painted in currentColor, so it
   lives outside the button's text node — the language toggle rewrites that
   text on every switch and would otherwise erase an inline SVG. The word
   drops to 500 against the nav links' 600: inside a bordered pill the same
   weight sat too heavy and pulled focus off the nav proper. */
.lang-toggle {
  gap: var(--space-2xs);
  padding: 0 var(--space-sm) 0 var(--space-xs);
  border-radius: var(--radius-pill);
  font-weight: 500;
}
.lang-toggle::before {
  content: ""; flex: none;
  width: var(--icon-size); height: var(--icon-size);
  background: currentColor;
  -webkit-mask: var(--globe) center / contain no-repeat;
  mask: var(--globe) center / contain no-repeat;
}

/* ---- mobile nav: the links collapse into a hamburger sheet; DE and the
   theme toggle stay in the bar. The burger is the shared pager control. ---- */
/* scoped under .nav so it outranks .pager-btn's later display:grid */
.nav .nav-burger { display: none; }
.nav-sheet {
  /* Edge to edge, top included. It used to start at 74px, which left a
     strip of page showing between the bar and the sheet whenever the bar
     did not measure exactly 74. Starting at 0 and padding the content down
     past the bar means the two can never disagree. The bar stays above it
     (z-index 10 against 9) so the close control is always reachable. */
  position: fixed; inset: 0; z-index: 9;
  background: var(--bg);
  display: flex; flex-direction: column;
  padding: calc(56px + var(--space-lg)) var(--gutter) var(--space-lg);
  overflow-y: auto; overscroll-behavior: contain;
  opacity: 0; transform: translateY(-8px); pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease-out-expo), transform var(--dur-med) var(--ease-out-expo);
}
.nav-open .nav-sheet, .nav-sheet.is-open { opacity: 1; transform: none; pointer-events: auto; }
.nav.nav-open { background: var(--bg); }
.nav-sheet .nav-link { display: block; padding-block: var(--space-sm); font-size: var(--text-body); }
/* the lines crossfade into a true x: rotating offset lines never lands as
   a clean close glyph */
.nav-burger .burger-lines, .nav-burger .burger-x { transition: opacity var(--dur-fast) var(--ease-out-expo); }
.nav-burger .burger-x { opacity: 0; }
.nav-open .nav-burger .burger-lines { opacity: 0; }
.nav-open .nav-burger .burger-x { opacity: 1; }
@media (max-width: 760px) {
  .nav .nav-links .nav-link { display: none; }
  /* the glyph itself is the trigger, not a filled square: on the glass
     pill a solid control read as a second surface */
  .nav .nav-burger {
    display: grid; width: 40px; height: 34px;
    background: transparent; border: 0; color: var(--ink);
  }
  .nav .nav-burger:hover, .nav .nav-burger:focus-visible {
    background: transparent; color: var(--accent);
  }
  /* the bar itself tightens up */
  .nav { top: 10px; padding: 6px 8px 6px 14px; gap: var(--space-md); }
  .nav-avatar { width: 26px; height: 26px; font-size: 10px; }
}
@media (min-width: 761px) { .nav-sheet { display: none; } }
@media (prefers-reduced-motion: reduce) {
  .nav-sheet, .nav-burger .burger-lines, .nav-burger .burger-x { transition: none; }
}

/* ============ HERO (homepage) ============ */
.hero {
  /* ~76svh, not 100 — the first case study peeks above the fold */
  min-height: 76svh; display: flex; flex-direction: column;
  justify-content: center; position: relative; padding-block: 72px 40px;
}
/* status line is metadata, not a claim: caption tier, quieter than the body */
.hero-status { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--space-xs); margin-bottom: var(--space-lg); font-size: var(--text-label); }
/* the employer name links out; it stays in the status line's voice and only
   the rule inks on hover, same behaviour as .term */
.status-link {
  color: inherit; text-decoration: underline;
  /* Thickness and offset are stated, not left to the browser. Undeclared,
     `auto` derives the rule from Switzer's metrics and lands near 1px, so the
     same affordance read at half the weight of .term's rule two columns over.
     These two values are .term's; the pair has to move together. */
  text-decoration-thickness: 2px;
  text-decoration-color: var(--line-strong); text-underline-offset: 4px;
  transition: text-decoration-color var(--dur-med) var(--ease-out-expo);
}
.status-link:hover, .status-link:focus-visible { text-decoration-color: var(--accent); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: pulse 2.4s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
.hero-title {
  color: var(--ink); font-family: var(--font-display); font-weight: 600;
  font-size: var(--text-hero); line-height: 1.02; letter-spacing: var(--track-display);
  margin-bottom: var(--space-lg);
}
/* No overflow clip here: the entrance blurs, and blur bleeds past the line
   box, so a hidden overflow would shear the haze into a hard edge. */
.line { display: block; padding-block: 0.06em; }
/* Focus pull, not a slide: each line starts a touch oversized and out of
   focus, then settles sharp on the baseline it already occupies. Scaling from
   the left edge keeps the line anchored to the column rule instead of
   drifting into the margin on the way in. */
/* The blurred state lives in the keyframe and the fill is `backwards`, not in
   the rule with a `forwards` fill. Two reasons: the element's resting style
   stays the finished one, so a stylesheet that loads late or an animation that
   never attaches leaves readable text rather than a permanent haze; and
   nothing keeps a filter on the element once the animation ends, which would
   otherwise leave every line a compositing layer and a containing block. */
.line-inner {
  display: inline-block; transform-origin: 0% 50%;
  animation: emerge var(--dur-slow) var(--ease-emerge) backwards;
}
.line:nth-child(2) .line-inner { animation-delay: 120ms; }
.line:nth-child(3) .line-inner { animation-delay: 240ms; }
@keyframes emerge { from { opacity: 0; filter: blur(7px); transform: scale(1.045); } }
/* The supporting text takes the same defocus at half strength and no scale,
   so it settles under the headline rather than competing with it. */
.hero-status, .hero-sub, .hero-ctas {
  animation: emerge-soft var(--dur-slow) var(--ease-emerge) 450ms backwards;
}
@keyframes emerge-soft { from { opacity: 0; filter: blur(4px); } }
/* Runs past the half-width mark so the hero doesn't read as a narrow column
   against empty space. Capped at 68% of the container so it never becomes a
   full-bleed wall of text on very wide screens. */
.hero-sub {
  font-size: var(--text-body); line-height: 1.6;
  max-width: min(80ch, 68%); color: var(--ink-body);
}
/* the hero speaks in the site's decision-list grammar: accent mark,
   bold value statement, plain evidence tail */
.hero-points { list-style: none; padding: 0; }
.hero-points li { position: relative; padding-left: var(--space-lg); margin-bottom: var(--space-sm); }
.hero-points li:last-child { margin-bottom: 0; }
.hero-points li::before { content: "✦"; position: absolute; left: 0; top: 2px; color: var(--accent); font-size: var(--text-label); }
.hero-sub strong { color: var(--ink); font-weight: 600; }
@media (max-width: 900px) { .hero-sub { max-width: 100%; } }
.hero-ctas { display: flex; gap: var(--space-md); margin-top: var(--space-lg); animation-delay: 550ms; }

/* Screen-reader-only text — fixes the H1→H3 jump in the outline without
   adding a visible heading the layout doesn't need. */
.visually-hidden {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

/* Work in progress: hidden by default, so unfinished work can never reach a
   visitor. On localhost the head script strips the attribute outright, which
   restores each element's own display value instead of guessing at it. Failure
   modes (no JS, script error, wrong host) all fall on the hidden side.
   To ship a section for real, delete its data-wip attribute. */
[data-wip] { display: none !important; }

/* ============ SECTIONS ============ */
/* Section separators run edge to edge: each section is marked across the
   full viewport, crossing the vertical frame rather than stopping at it.
   --rule, not --line: the column rules, the case-row separators and these
   all belong to one hairline grid and must be the same value. --line is
   twice as dark and made the section splits read as a heavier tier. */
.section { position: relative; padding-block: var(--space-section); }
.section::before {
  content: ""; position: absolute; top: 0; left: 0; right: 0;
  height: 1px; background: var(--rule);
}
/* Content sits 16px inside the frame in every band. The hero, the proof
   stats and the footer keep the wider gutter: those three are the page's
   opening and closing statements and want the air. */
.section > .container { padding-inline: 16px; }
/* Section titles share the case-study title voice: Erode 600. One display
   face for every display statement — no third heading style.
   No max-width cap: "The systems beneath the products" stays on one line. */
/* Section header rhythm — one rule everywhere:
   content starts 32px (--space-xl) below the header block in every section;
   a title and its intro read as a pair at 12px (--space-sm). */
.section-title {
  font-family: var(--font-display); font-weight: 600; color: var(--ink);
  /* h3 tier: with 16px body prose, h2 section titles shouted; sections and
     case headings now share one statement size */
  font-size: var(--text-h3); line-height: 1.2; letter-spacing: var(--track-md);
  margin-bottom: var(--space-lg);
}
.section-title:has(+ .section-intro) { margin-bottom: var(--space-sm); }
.section-intro { font-size: var(--text-body); color: var(--ink-body); max-width: 60ch; margin: 0 0 var(--space-xl); }

/* ---- section pill: the marker that opens a section ----
   One per section, above the heading. Not a kicker label: a kicker is set
   type floating over a title and adds a second, quieter heading. This is an
   object — the theme toggle's surface recipe (--bg-raised, an 8% hairline,
   --shadow-chip), widened out of a circle to hold a mark and a word. That
   trio is what makes a control sit ON the page rather than being cut out of
   it, and it is why the pill reads as a tab on the section instead of extra
   copy above it.

   Fixed 40px height, not vertical padding: the marks vary and padding would
   leave every pill a slightly different size. 40px is on the 4pt grid and one
   step off the toggle's 36px, close enough to belong to it.

   Colour is spent in the mark alone, never in the surface or the label. Six
   sections, six hues, each desaturated to roughly the same value so the set
   reads as one family and no section shouts louder than another. --tone is
   the light-ground value and --tone-dark its pair for the dark theme and the
   navy band; the label stays --ink so the pill can never read as decoration. */
.section-pill {
  display: inline-flex; align-items: center; gap: var(--space-xs);
  height: 40px; padding: 0 var(--space-md) 0 var(--space-sm);
  border-radius: var(--radius-pill);
  background: var(--bg-raised);
  border: 1px solid color-mix(in srgb, var(--ink) 8%, transparent);
  box-shadow: var(--shadow-chip);
  font-family: var(--font-body); font-size: var(--text-small); font-weight: 500;
  line-height: 1; color: var(--ink);
  margin-bottom: var(--space-md);
}
/* 16px is the distance from a pill to the TITLE under it. Where the pill is
   the whole header — no visible title, because it would only repeat the pill —
   it inherits the title's own 24px so the content below reads as the section's
   body rather than as something stuck to the chip. */
.section-pill:not(:has(+ .section-title)) { margin-bottom: var(--space-lg); }
/* Duotone marks: a soft mass of the tone sitting under crisp hairline strokes
   of the same tone. One drawing, two weights — that is what separates these
   from stock line icons, and it is where the softness comes from, since the
   fill never reaches full strength.
   Strokes run 1.5 rather than the 2 the section markers use elsewhere: at
   18px a 2px stroke closes up the counters and the mark reads as a blob.
   .duo is fill only (a mass with no outline of its own); .duo-s keeps its
   stroke, for shapes that are both filled and drawn. */
.section-pill svg { flex: none; color: var(--tone, var(--accent)); }
.section-pill svg .duo { fill: currentColor; fill-opacity: 0.16; stroke: none; }
.section-pill svg .duo-s { fill: currentColor; fill-opacity: 0.16; }
[data-theme="dark"] .section-pill { border-color: rgba(255,255,255,0.10); }
/* the navy band is dark under both themes, so it takes the dark surface and
   the band's own blue-grey label, the same move .pill already makes here */
.section--invert .section-pill {
  background: rgba(255,255,255,0.06);
  border-color: color-mix(in srgb, var(--invert-tint) 26%, transparent);
  color: #F4F5FB;
}
/* work keeps the site's own accent rather than a hue of its own: five muted
   marks around one true accent is what makes the case studies read as the
   primary section without anything shouting. Both tones point at --accent,
   which already swaps per theme (#2C39FF light, #757DFF dark) — pointing the
   dark tone at --accent-inverse would fetch "the accent for the OPPOSITE
   ground", i.e. the dark blue, and bury the mark on the dark page. */
.section-pill[data-tone="work"]         { --tone: var(--accent); --tone-dark: var(--accent); }
.section-pill[data-tone="studio"]       { --tone: #B2703A; --tone-dark: #E3AC7C; }
.section-pill[data-tone="systems"]      { --tone: #2F7D6B; --tone-dark: #7FC9B4; }
.section-pill[data-tone="process"]      { --tone: #6B4EC7; --tone-dark: #A896F0; }
.section-pill[data-tone="experience"]   { --tone: #4A6B8A; --tone-dark: #9BB8D6; }
.section-pill[data-tone="testimonials"] { --tone: #A85A6B; --tone-dark: #E0919F; }
/* one swap, not six: --tone-dark is already on the element, so the dark
   theme and the navy band only have to repoint --tone at it */
[data-theme="dark"] .section-pill,
.section--invert .section-pill { --tone: var(--tone-dark); }

/* ============ PROOF BAND ============
   Three numbers between the hero claim and the first case study, so the
   evidence is read before the work rather than after it.
   The rules sit on the CONTAINER, not the section, so they stop at the
   gutter like the reference instead of bleeding to the viewport edge. No
   dividers between the figures: the columns are held by the grid alone. */
.statband {
  /* z-index 0 keeps the band under the hero fragments that deliberately
     overhang the section boundary; without it a later positioned sibling
     paints over the floating doc card. */
  position: relative; z-index: 0;
}
.statband > .container {
  position: relative;
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  padding-block: var(--space-2xl);
}
/* The band's bottom rule and the next section's top rule would sit a few
   pixels apart as a double hairline; the section yields. */
.statband + .section::before { display: none; }
.stats {
  list-style: none; padding: 0; margin: 0;
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stat {
  position: relative;
  display: flex; flex-direction: column; align-items: center;
  gap: var(--space-2xs); text-align: center;
  padding-inline: var(--space-lg);
}
/* Hovering ONE stat lights only its own stretch of the band's rules: an
   accent segment the width of that column, laid exactly over the container's
   top and bottom hairlines, fading out at its ends so neighbours stay quiet.
   The offset is the container's block padding plus the 1px border. */
.stat::before, .stat::after {
  content: ""; position: absolute; left: 0; right: 0; height: 1px;
  pointer-events: none; opacity: 0;
  background: linear-gradient(90deg,
    transparent,
    color-mix(in srgb, var(--accent) 60%, transparent) 30%,
    var(--accent) 50%,
    color-mix(in srgb, var(--accent) 60%, transparent) 70%,
    transparent);
  transition: opacity var(--dur-med) var(--ease-out-expo);
}
.stat::before { top: calc(-1 * var(--space-2xl) - 1px); }
.stat::after { bottom: calc(-1 * var(--space-2xl) - 1px); }
.stat:hover::before, .stat:hover::after { opacity: 1; }
/* The pair rests grey and inks up together on hover, on the site's hover
   tempo. --ink-3 is the large-decorative tier and is only legal on the figure
   because it is display-sized; the label stays at --ink-body so small text
   never drops below the contrast floor. */
.stat-fig {
  font-family: var(--font-display); font-weight: 600;
  font-size: var(--text-h1); line-height: 1.1; letter-spacing: var(--track-lg);
  color: var(--ink-3);
  transition: color var(--dur-med) var(--ease-out-expo);
}
/* Slot reveal: site.js splits each figure into per-character slots; digit
   slots hold an 11-step reel (0-9 then the real digit) that rolls up into
   place, symbols fade in behind their digits. Reduced motion skips the build
   entirely and the plain text stays. */
.stat-fig .slot { display: inline-block; overflow: hidden; height: 1em; line-height: 1; vertical-align: top; }
.slot-reel { display: block; transition: transform 1.1s var(--ease-out-expo); will-change: transform; }
.slot-reel span { display: block; height: 1em; line-height: 1; }
.stat-label {
  font-size: var(--text-small); line-height: 1.45;
  color: var(--ink-body); max-width: 24ch;
  transition: color var(--dur-med) var(--ease-out-expo);
}
.stat:hover .stat-fig, .stat:hover .stat-label { color: var(--ink); }
@media (prefers-reduced-motion: reduce) {
  .stat-fig, .stat-label, .stat::before, .stat::after { transition-duration: 0.01ms; }
}
/* Tablet and phone: the row folds to a 2-up grid with a rule between the
   rows and still no vertical dividers. The odd third figure spans the full
   width rather than leaving a hole in the grid. Row padding sits on both
   sides of the rule so the gap reads symmetrical; row-gap stays at 0 or it
   would stack on top of that padding. */
@media (max-width: 820px) {
  .statband > .container { padding-block: var(--space-xl); }
  .stats { grid-template-columns: 1fr; row-gap: 0; }
  .stat { padding-inline: var(--space-sm); padding-block: var(--space-md); }
  .stat + .stat { border-top: 1px solid var(--rule); }
  /* stacked rows no longer align with the band's rules; the segment glow is
     a desktop-pointer device */
  .stat::before, .stat::after { display: none; }
}

/* ============ BRAND BAND ============
   Replaces the proof band under the hero: one slow lane of client marks that
   drifts left and dissolves at both ends, so the band reads as a continuous
   run rather than a list with a first and last item. The proof-band CSS above
   is kept intact so the three figures can be reinstated by swapping the
   markup back.
   The marks are stripped to black and white on purpose: colour here would
   out-shout the accent, which the page spends sparingly. */
.brandband { position: relative; z-index: 0; }
.brandband > .container {
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  padding-block: var(--space-lg);
  /* Same colour as the page, but painted rather than inherited: the mask
     below makes this an isolated group, so a mix-blend-mode mark inside it
     has no backdrop to blend against unless the group itself paints one.
     Without this the AXA plate knockout silently does nothing. */
  background: var(--bg);
  /* the lane runs the full container width; the gutters are the fade, not
     padding, so a mark can sit half-dissolved in the margin */
  padding-inline: 0;
  overflow: hidden;
  /* the fade. Short ramps: long ones eat the two end marks entirely and the
     lane reads half-empty at rest. */
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
          mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.brandband + .section::before { display: none; }
/* Four identical tracks laid end to end; the run slides exactly one track's
   width (25% of four) and snaps back, which is invisible because track 2 has
   arrived where track 1 started. Four rather than two because a four-mark set
   is narrower than a desktop container: two would leave the lane visibly
   short of a full width. If the brand list changes, keep
   translate % = 100 / number of tracks. */
.marquee { display: flex; width: max-content; animation: brand-drift 46s linear infinite; }
.marquee-track {
  list-style: none; margin: 0; padding: 0;
  display: flex; align-items: center;
  /* Four marks is a short set: at a tight gap one full set is half a desktop
     container and the same logo sits on screen twice. The gap is opened until
     a set nearly fills the container, so the repeat falls inside the edge
     fade rather than in plain sight. */
  gap: clamp(3.5rem, 11vw, 10rem);
  padding-inline: calc(clamp(3.5rem, 11vw, 10rem) / 2);
}
@keyframes brand-drift { to { transform: translate3d(-25%, 0, 0); } }
/* Marks are set by height, never width: the lane's rhythm depends on every
   mark sharing one optical weight, and wordmarks differ wildly in aspect. */
.brand-logo {
  display: block; height: 26px; width: auto; max-width: 160px;
  object-fit: contain;
  filter: grayscale(1);
  opacity: 0.55;
}
/* Dark theme: the supplied marks are dark-on-transparent, so they are flipped
   rather than recoloured. A mark that already ships light opts out with
   .is-light. */
[data-theme="dark"] .brand-logo { filter: grayscale(1) invert(1); opacity: 0.5; }
[data-theme="dark"] .brand-logo.is-light { filter: grayscale(1); }
/* A mark supplied on an opaque white plate instead of transparency (AXA).
   The plate is knocked out by blending rather than by editing the file:
   on the light page multiply drops white to the background and leaves the
   lettering; in dark the mark has already been inverted to light-on-black,
   so screen drops the black. Both are lossless and survive a file swap, so
   a transparent version can replace it later by deleting one class. */
.brand-logo.is-matted { mix-blend-mode: multiply; }
[data-theme="dark"] .brand-logo.is-matted { mix-blend-mode: screen; }
@media (max-width: 820px) {
  .brandband > .container { padding-block: var(--space-md); }
  .marquee-track { gap: var(--space-2xl); padding-inline: var(--space-lg); }
  .brand-logo { height: 22px; }
}
@media (prefers-reduced-motion: reduce) {
  .marquee { animation: none; }
}

/* ============ PROJECT ROWS ============ */
/* Each case study reads big: the media takes ~1.5x the copy's width, so the
   cover dominates the row and its height follows from the 16/9 ratio alone —
   no forced row height, which only strands empty bands above and below the
   content. The covers refit to the wider slot via the ResizeObserver in
   index.html, so the embedded canvases grow with the column. */
.project {
  display: grid; grid-template-columns: minmax(0,0.9fr) minmax(0,1.4fr);
  /* text centers against the taller media so the row never reads top-heavy */
  gap: var(--space-xl); align-items: center;
  padding-block: var(--space-block); border-top: 1px solid var(--line);
}
.project:first-of-type { border-top: none; padding-top: 0; }
/* only the cover stays centred, so a tall image no longer drags the text down */
.project > div { align-self: start; }
/* The case numerals were removed: the row now leads with the title. */
.project-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-md); margin-bottom: var(--space-xs); }
/* 14px: metadata should sit under the description, not compete with it.
   At body size the company line read as loud as the impact copy. */
.project-meta .label { font-size: var(--text-label); }
.project-title { color: var(--ink); font-family: var(--font-display); font-weight: 600; font-size: var(--text-h3); line-height: 1.3; letter-spacing: var(--track-md); margin-bottom: var(--space-xs); }
.project-impact { font-size: var(--text-body); line-height: 1.6; color: var(--ink-body); max-width: 48ch; margin-bottom: var(--space-md); }
.project-impact strong { color: var(--ink); font-weight: 600; }
.project .pills { margin-bottom: var(--space-md); }
/* Layout comes from .btn — this rule sits downstream of it, so re-declaring
   display/gap/align here silently overrode the button's own auto-layout and
   collapsed the disc back against the label. Only the transition belongs here. */
.project-cta { transition: color var(--dur-med) var(--ease-out-expo); }
.project-media {
  /* position:relative is load-bearing: the .cover-zoom layer inside the
     animated covers is absolutely positioned against this box. It used to
     arrive via the glow-ring rule; that rule no longer covers .project-media,
     so it is declared here where the covers actually depend on it. */
  position: relative; overflow: hidden; border-radius: var(--radius-media);
  background: var(--bg-raised); aspect-ratio: 16/9;   /* clearly rectangular, never square */
}
/* No scale swell here: at this column width a 1.02 scale shifts the cover's
   edges visibly and reads as a lurch. The row's hover answer is the numeral
   highlight and the CTA arrow. */
.project-media img { width: 100%; height: 100%; object-fit: cover; }
/* A cover is an artifact, not an app — the same policy .live-frame iframe
   already states, which these were simply missed by. An iframe is a separate
   document, so while it takes pointer events it swallows every move and click
   over the image: elementFromPoint returns null there, the row's own hover
   never fires, the cursor dot freezes because no mousemove reaches this page,
   and the largest target in the row does not reach the link that wraps it. */
.project-media iframe { pointer-events: none; }
.media-note { margin-top: var(--space-xs); font-size: var(--text-label); color: var(--ink-2); } /* caption tier — permitted 14px; ink-2 not ink-3: 14px text needs 4.5:1 */

/* ============ CARD GRID (Studio / AI Experiments) ============ */
/* 16px gaps: card grids tightened with the type rebase (studio, next-case) */
.card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-md); }
/* Studio canvas — Stripe's card grammar: the imagery IS the card. The
   banner stretches over the whole canvas, a short line of copy sits on top,
   and pills/CTA stand down (the card is one link; status lives inside the
   case page). Copy stays legible because the banners are dark; revisit the
   overlay if light imagery ever lands here. */
/* Rows: a wide lead with companion, a trio, then one big landscape. The
   closing card spans the full row at near-lead height, a canvas, not the
   thin band it briefly was. */
#studio .card-grid { grid-template-columns: repeat(6, 1fr); }
#studio .card-grid > :nth-child(1) { grid-column: span 4; }
#studio .card-grid > :nth-child(2) { grid-column: span 2; }
#studio .card-grid > :nth-child(3),
#studio .card-grid > :nth-child(4),
#studio .card-grid > :nth-child(5) { grid-column: span 2; }
#studio .card-grid > :nth-child(6) { grid-column: span 6; }
#studio .mini-card { padding: 0; overflow: hidden; min-height: 460px; }
#studio .card-grid > :nth-child(1),
#studio .card-grid > :nth-child(2) { min-height: 560px; }
#studio .card-grid > :nth-child(6) { min-height: 500px; }
#studio .mini-card .mini-banner {
  position: absolute; inset: 0; height: 100%; margin: 0;
  aspect-ratio: auto; border-radius: 0;
}
#studio .mini-card .pills,
#studio .mini-card .project-cta { display: none; }
#studio .mini-card-title,
#studio .mini-card-desc { position: relative; z-index: 1; }
/* Corner expand affordance, Stripe's: a small accent-tinted disc whose icon
   grows very slowly on card hover, same curve as the button arrows. Same
   36px circle as the pager arrows and the theme toggle, because on this site
   a circle IS the icon-only control and the 4px square is reserved for
   buttons carrying a word. Icon colours are baked per theme because a mask
   would eat the tinted background it sits on. */
#studio .mini-card::before {
  content: ""; position: absolute; top: 16px; right: 16px; z-index: 2;
  width: 36px; height: 36px; border-radius: 50%;
  background-color: color-mix(in srgb, var(--accent) 10%, #ffffff);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232C39FF' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h6v6M21 3l-7.5 7.5M9 21H3v-6M3 21l7.5-7.5'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: center; background-size: 13px;
  /* 700ms matches the card's own expand, so icon and box move as one */
  transition: background-size 700ms var(--ease-out-expo), background-color 700ms var(--ease-out-expo);
}
/* On hover the button inverts, Stripe's move: the icon's colour becomes the
   ground and the ground becomes the icon. The size still grows slowly; the
   image swap crossfades in Blink and snaps cleanly elsewhere. */
#studio .mini-card:hover::before, #studio .mini-card:focus-visible::before {
  background-size: 17px;
  background-color: var(--accent);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h6v6M21 3l-7.5 7.5M9 21H3v-6M3 21l7.5-7.5'/%3E%3C/svg%3E");
}
/* On the navy band a near-white chip and a solid-accent hover both punch a
   hole in the card. The affordance goes to frosted glass instead: it belongs
   to the image it sits on and only firms up on hover. */
.section--invert .mini-card::before {
  background-color: rgba(255,255,255,0.13);
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h6v6M21 3l-7.5 7.5M9 21H3v-6M3 21l7.5-7.5'/%3E%3C/svg%3E");
}
.section--invert .mini-card:hover::before,
.section--invert .mini-card:focus-visible::before {
  background-color: rgba(255,255,255,0.26);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h6v6M21 3l-7.5 7.5M9 21H3v-6M3 21l7.5-7.5'/%3E%3C/svg%3E");
}
[data-theme="dark"] #studio .mini-card::before {
  background-color: color-mix(in srgb, var(--accent) 20%, #17171B);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23757DFF' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h6v6M21 3l-7.5 7.5M9 21H3v-6M3 21l7.5-7.5'/%3E%3C/svg%3E");
}
/* Dark accent is light itself, so the inverted icon goes near-black for
   contrast (white on #757DFF is under 3:1; ink clears 5:1). */
[data-theme="dark"] #studio .mini-card:hover::before,
[data-theme="dark"] #studio .mini-card:focus-visible::before {
  background-color: var(--accent);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230E0E11' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h6v6M21 3l-7.5 7.5M9 21H3v-6M3 21l7.5-7.5'/%3E%3C/svg%3E");
}
#studio .mini-card-title { color: #fff; padding: var(--space-lg) var(--space-lg) 0; }
#studio .mini-card-desc { color: var(--invert-tint); flex: none; padding-inline: var(--space-lg); }
/* :nth-child(n) matches everything but keeps the same specificity as the
   desktop span rules above; a bare * would lose to them at every width. */
@media (max-width: 960px) {
  #studio .card-grid > :nth-child(n) { grid-column: span 3; min-height: 480px; }
}
/* single column on phones: the canvas keeps Stripe's tall-rectangle
   proportion instead of collapsing into a short band */
@media (max-width: 720px) {
  #studio .card-grid > :nth-child(n) { grid-column: span 6; min-height: 540px; }
}
.mini-card {
  /* gap matches .project-title's margin so title→description spacing is
     identical across case studies, studio, and systems cards */
  display: flex; flex-direction: column; gap: var(--space-xs);
  border: 1px solid var(--line); border-radius: var(--radius-panel); padding: var(--space-lg);
  background: var(--bg); min-height: 200px;
  transition: border-color var(--dur-med) var(--ease-out-expo), transform var(--dur-med) var(--ease-out-expo);
}
/* The gradient ring is the hover; a darkened border on top of it would say
   the same thing twice. The motion is Stripe's: a soft, slow zoom that has
   started before you notice it, never a pop. */
.mini-card { transition: border-color var(--dur-fast), transform 700ms var(--ease-out-expo); }
/* identical to the case-study frame hover: same size, same speed, no lift.
   Scoped under .card-grid so it outranks .reveal.is-visible's transform:none,
   which otherwise wins the cascade and freezes the card while the corner
   icon still animates. */
.card-grid .mini-card:hover, .card-grid .mini-card:focus-visible { transform: scale(1.02); }
/* Cursor-lit border. A radial gradient tracks the pointer and is masked down
   to the 1px ring, so only the stretch of border nearest the cursor lights up
   rather than the whole outline switching on at once. Sitting at inset -1px
   puts the ring exactly over the existing border instead of just inside it,
   since an absolutely positioned child otherwise aligns to the padding box.
   The nav toggles deliberately stay out of this system: utility controls
   take a plain darken-and-tint hover instead. */
.mini-card, .live-frame, .ds-card-media { position: relative; --glow-r: 240px; }
.live-frame, .ds-card-media { --glow-r: 320px; }
.mini-card::after, .live-frame::after, .ds-card-media::after {
  /* inset 0, NOT -1px: these cards all clip with overflow:hidden, so a ring
     sitting outside the padding box is cut away and the effect never paints.
     Flush inside is the only position that survives the clip. */
  content: ""; position: absolute; inset: 0; border-radius: inherit;
  padding: 1px; pointer-events: none;
  background: radial-gradient(
    var(--glow-r) circle at var(--mx, 50%) var(--my, -20%),
    var(--accent) 0%,
    color-mix(in srgb, var(--accent) 62%, transparent) 18%,
    color-mix(in srgb, var(--accent) 22%, transparent) 40%,
    transparent 68%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
  opacity: 0; transition: opacity var(--dur-med) var(--ease-out-expo);
}
.mini-card:hover::after, .mini-card:focus-visible::after,
.live-frame:hover::after,
.ds-card:hover .ds-card-media::after, .ds-card:focus-visible .ds-card-media::after { opacity: 1; }
/* Touch has no pointer to follow, so the flat border hover is the whole
   interaction there. */
@media (hover: none) { .mini-card::after, .live-frame::after, .ds-card-media::after { display: none; } }
.mini-banner {
  display: grid; place-items: center; aspect-ratio: 16/7;
  border-radius: calc(var(--radius-panel) - 4px);
  color: #fff; font-size: 1.75rem; margin-bottom: var(--space-xs);
}
.mini-card-title { color: var(--ink); font-family: var(--font-display); font-weight: 600; font-size: var(--text-h3); line-height: 1.3; letter-spacing: var(--track-md); }
.mini-card-desc { font-size: var(--text-small); color: var(--ink-body); flex: 1; }
.mini-card .project-cta { margin-top: auto; align-self: flex-start; }

/* ============ PAGER / EXPAND BUTTONS ============
   One icon-control family: the design-system carousel arrows, the experience
   plus toggles, the nav burger, the modal close, and the studio cards' corner
   expand affordance above. All 36px, all round, like the theme toggle: on
   this site a circle means an icon-only control and the 4px square is
   reserved for buttons that carry a word. The accent-tint fill is what marks
   these as in-content rather than chrome. */
.pager-btn {
  /* one shape and ONE size for every icon-only control: 36px, matching the
     nav's theme toggle */
  width: 36px; height: 36px; border-radius: 50%; border: 0; cursor: pointer;
  display: grid; place-items: center; flex: none; color: var(--accent);
  background: color-mix(in srgb, var(--accent) 10%, var(--bg-raised));
  transition: background-color var(--dur-med) var(--ease-out-expo), color var(--dur-med) var(--ease-out-expo), opacity var(--dur-fast);
}
.pager-btn:hover:not(:disabled), .pager-btn:focus-visible:not(:disabled) { background: var(--accent); color: var(--bg-raised); }
[data-theme="dark"] .pager-btn { background: color-mix(in srgb, var(--accent) 20%, var(--bg-raised)); }
[data-theme="dark"] .pager-btn:hover:not(:disabled), [data-theme="dark"] .pager-btn:focus-visible:not(:disabled) { background: var(--accent); color: var(--bg); }
.pager-btn:disabled { opacity: 0.35; cursor: default; }
.pager-btn svg { display: block; }

/* ============ DESIGN SYSTEM CAROUSEL ============
   Stripe's partner-card grammar: the artwork is the object, the words sit
   under it on the page. Hovering opens a card: the frame is already cut
   --ds-reveal wider than the card on each side and clipped back at rest, so
   hover releases the clip and MORE of the same artwork shows. Nothing scales,
   so the pixels never soften. Every frame to the left leans left and every
   frame to the right leans right, by exactly the width the open frame gains,
   so the 16px gaps hold; the titles below stay planted, only the artwork
   moves. Same duration and curve as the button arrows opening. */
.ds-pager { display: flex; gap: var(--space-xs); justify-content: flex-end; margin-bottom: var(--space-md); }
.ds-row {
  --ds-reveal: 8px;
  /* the sliver of the fourth card left showing at rest. It is the only free
     number in the rail's width equation, so it is the one held to the 4pt
     grid: 56px = 14 x 4. Everything else falls out of it. */
  --ds-peek: 56px;
  display: flex; gap: var(--space-md);
  overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none;
  /* clip slack: the end cards lean past the row's box; without this the
     scrollport shaves them and the corners read as cut. scroll-padding keeps
     the snap points off the slack so it stays visible. */
  padding: 10px; margin: -10px; scroll-padding-inline: 10px;
}
.ds-row::-webkit-scrollbar { display: none; }
/* Three systems sit fully in the row and the fourth shows --ds-peek of itself,
   so the rail matches the three-up rhythm of the sections above and still says
   there is more, instead of a fourth card landing flush with the margin and
   reading as the end. Width = (row - the 3 gaps - the peek) / 3. */
.ds-card {
  flex: 0 0 calc((100% - var(--space-md) * 3 - var(--ds-peek)) / 3);
  min-width: min(280px, 80vw); scroll-snap-align: start;
}
.ds-card-media {
  /* the box hangs --ds-reveal past the card on both sides; aspect-ratio (not
     the old padding-top trick) holds the shape while letting the specimen
     inside lay out as normal flow.
     Landscape, not the old 4:5. The specimen is a fixed stack — ramp, type
     pairing, components, bar — and a portrait box left a dead column of empty
     ground between the type and the buttons on every card. 3:2 is close to
     what the content actually occupies, so the box ends where the sheet does. */
  width: calc(100% + var(--ds-reveal) * 2);
  margin-inline: calc(var(--ds-reveal) * -1);
  /* NO aspect-ratio. The specimen is a fixed five-row stack, so its own
     content height is the only honest height for the box: at the ~390px the
     cards get in the rail, 3:2 gave 262px against the 293px the stack needs
     and clip-path ate the difference, which is the field bar and the bottom
     inset. min-height: fit-content does NOT rescue a ratio — with one set,
     intrinsic block sizes resolve FROM the ratio, so the keyword returns the
     same 262px. Letting the content size it also fixes the narrow end, where
     a ratio box shrinks with the card while the stack inside cannot.
     All four cards carry the identical stack, so the rail stays even. */
  margin-bottom: var(--space-md);
  border-radius: var(--radius-panel);
  background: var(--ds-bg);
  clip-path: inset(0 var(--ds-reveal) round var(--radius-panel));
  transition: clip-path var(--dur-med) var(--ease-out-expo), transform var(--dur-med) var(--ease-out-expo);
}
/* ---- token specimen ----
   The card shows the system, not a screenshot of a product built with it:
   the real ramp, the real type pairing, real components in real tokens.
   Every value is a CSS custom property set per card, so the card IS the
   token sheet rather than a picture of one. */
.ds-spec {
  /* min-, not fixed: the box can now be taller than the ratio, and the spec
     still fills it when the ratio wins so .ds-comps can push to the bottom. */
  min-height: 100%; display: flex; flex-direction: column; gap: var(--space-md);
  /* One inset, 24px, on all four sides — as SEEN. The sides carry +8 because
     clip-path shaves --ds-reveal off each edge, so a flat 24px there paints as
     16 and the specimen sat closer to the left wall than to the top. */
  padding: var(--space-lg) calc(var(--space-lg) + var(--ds-reveal));
  color: var(--ds-fg); font-size: var(--text-label); line-height: 1.3;
}
.ds-spec-top { display: flex; flex-direction: column; gap: 4px; }
.ds-spec-name { font-weight: 600; font-size: 1.0625rem; letter-spacing: -0.01em; }
.ds-spec-tier { color: var(--ds-mut); font-size: 0.75rem; }
.ds-ramp { display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; }
.ds-ramp i { display: block; height: 26px; border-radius: 3px; }
.ds-type { display: flex; align-items: baseline; gap: var(--space-sm); }
.ds-type-aa { font-size: 2.25rem; font-weight: 600; line-height: 1; letter-spacing: var(--track-lg); }
.ds-type-meta { color: var(--ds-mut); font-size: 0.75rem; line-height: 1.5; }
.ds-comps { display: flex; align-items: center; gap: var(--space-xs); flex-wrap: wrap; margin-top: auto; }
.ds-btn {
  background: var(--ds-accent); color: var(--ds-on);
  font-weight: 600; font-size: 0.75rem;
  padding: 7px 12px; border-radius: 4px;
}
.ds-chip {
  border: 1px solid var(--ds-line); color: var(--ds-mut);
  font-size: 0.75rem; padding: 6px 10px; border-radius: 999px;
}
.ds-field {
  height: 30px; border: 1px solid var(--ds-line); border-radius: 4px;
  display: flex; align-items: center; padding-inline: 10px;
}
.ds-field span { display: block; width: 42%; height: 5px; border-radius: 3px; background: var(--ds-mut); opacity: 0.55; }
.ds-card:hover .ds-card-media, .ds-card:focus-visible .ds-card-media {
  clip-path: inset(0 0 round var(--radius-panel));
}
.ds-card:hover ~ .ds-card .ds-card-media,
.ds-card:focus-visible ~ .ds-card .ds-card-media { transform: translateX(var(--ds-reveal)); }
.ds-card:has(~ .ds-card:hover) .ds-card-media,
.ds-card:has(~ .ds-card:focus-visible) .ds-card-media { transform: translateX(calc(var(--ds-reveal) * -1)); }
.ds-card .mini-card-title { margin-bottom: var(--space-2xs); }

/* ============ HOW I WORK ============
   Stripe-pattern 2x2: statement title, one-line lede, ✦ points. A single
   vertical hairline between the columns; rows separate by whitespace alone. */
/* ---- inverted band ----
   A deep navy section drawn from the accent, so the page has one dark beat.
   Its own separator and the vertical frame both switch to a light hairline
   as they cross: same rule, read against the opposite ground. */
.section--invert {
  position: relative;
  background: var(--invert-bg);
  /* One tint drives the whole band: the supporting copy, both hairline
     tiers, and the card edges. Plain white rims read as cut-outs on navy;
     the band's own blue keeps them part of the surface. */
  /* Cards sit a step LIGHTER than the band, Stripe's move: the surface is
     what separates them, not a rim. One blue for all five, so no card
     arrives with a hue of its own. */
  --invert-card: color-mix(in srgb, var(--accent) 20%, #141C4E);
  /* Supporting copy in the same blue-grey family as the band, not a tint of
     the raw accent, which goes lavender at any readable strength. */
  --invert-tint: #A9B4CE;
  --rule: color-mix(in srgb, var(--invert-tint) 20%, transparent);
  /* the rim is a hairline, not a highlight: the blue-grey at low alpha, so
     the card is separated by its surface and the edge merely closes it */
  --line: color-mix(in srgb, var(--invert-tint) 26%, transparent);
  /* This band is navy under BOTH themes, so neither accent token fits it.
     --accent is the dark blue in the light theme, and --accent-inverse means
     "the accent for the opposite ground", so it swaps to that same dark blue
     once the page goes dark (2.45:1 on this navy). The band pins one light
     blue instead, which is the only value that holds in both. */
  --invert-accent: #8A91FF;
}
.section--invert::before { background: color-mix(in srgb, var(--invert-tint) 24%, transparent); }
.section--invert .section-title { color: #F4F5FB; }
/* Supporting copy carries the band's own blue rather than a neutral grey,
   the way the reference does: the tint is what makes it read as one surface
   instead of grey text dropped onto navy. */
.section--invert .section-intro,
#studio.section--invert .mini-card-desc { color: var(--invert-tint); }
/* Pills need the band's palette too. This band re-declares --line and --rule
   but never the ink tiers, so a pill kept the light theme's dark grey label on
   navy (~3.5:1, and worse once it gained a fill mixed from the equally dark
   --ink: the chip painted a hole rather than a chip). Both now derive from the
   band's own blue-grey, and the accent pill takes --accent-inverse, which is
   the token that exists for exactly this ground — raw --accent is 2.70:1 here. */
.section--invert .pill {
  color: var(--invert-tint);
  background: color-mix(in srgb, var(--invert-tint) 12%, transparent);
}
.section--invert .pill--accent {
  color: var(--invert-accent);
  background: color-mix(in srgb, var(--invert-accent) 14%, transparent);
}
/* the opaque ground would swallow .col-rules (z-index 0, under main), so the
   band redraws the vertical frame in its own lighter hairline */
@media (min-width: 1201px) {
  .section--invert::after {
    content: ""; position: absolute; top: 0; bottom: 0; left: 50%;
    width: min(100%, var(--max-width)); transform: translateX(-50%);
    pointer-events: none;
    border-left: 1px solid var(--rule);
    border-right: 1px solid var(--rule);
  }
}
/* ---- dot field ----------------------------------------------------------
   Stripe's dotted ground, behind the studio cards. It gets its own element
   rather than a pseudo: the section's ::before is the top hairline and its
   ::after redraws the column rules, so both are spoken for.

   The grid is one radial-gradient tile. A mask fades it out toward the edges
   so it never meets the band's boundary as a hard cut, and a slow opacity
   cycle makes it drift rather than sit. Colour comes from --invert-tint, the
   same blue-grey the band already uses for copy and hairlines, so the dots
   belong to the surface instead of being white specks on navy. */
#studio.section--invert { isolation: isolate; }
#studio .dot-field {
  position: absolute; inset: 0; z-index: 0; pointer-events: none;
  color: color-mix(in srgb, var(--invert-tint) 45%, transparent);
  background-image: radial-gradient(circle, currentColor 1px, transparent 1px);
  background-size: 26px 26px;
  /* Centred on the band: strongest in the middle and falling off evenly in
     every direction, so the field reads as one pool of dots rather than a
     gradient running off an edge. Once the card artwork carries alpha the
     middle is exactly where the dots show through. */
  -webkit-mask-image: radial-gradient(ellipse 68% 64% at 50% 50%, #000 34%, transparent 86%);
          mask-image: radial-gradient(ellipse 68% 64% at 50% 50%, #000 34%, transparent 86%);
  animation: dot-breathe 12s ease-in-out infinite;
}
/* the card grid and the headings ride above the field */
#studio.section--invert > .container { position: relative; z-index: 1; }
@keyframes dot-breathe { 0%, 100% { opacity: 0.45; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  #studio .dot-field { animation: none; opacity: 0.7; }
}

/* Cards keep their own imagery: against navy they need a defined edge and a
   little lift, not a different picture. */
/* The white rim was never the border: it was the card's own --bg ground
   showing through at the rounded corners, behind the dark banner. On the
   navy band the card carries no ground of its own. */
/* The card carries no ground of its own: the banner art is rendered with alpha
   now, so the dotted band reads straight through the surround while the windows
   and phones stay opaque. The hairline is what still closes the card, and the
   drop shadow goes with the plate, since a shadow cast by a transparent box
   reads as a smudge around nothing. */
#studio.section--invert .mini-card {
  background: transparent;
  border-color: var(--line);
}

/* Tinted band: a faint accent wash marks the section, and it keeps the
   hairlines top and bottom so the band still sits on the same section grid
   as everything else. The top rule is its own ::before; the bottom rule is
   the following section's ::before. --section-tint reads as light blue in
   light mode and a lifted slate in dark, both drawn from --accent. */
.section--tint {
  position: relative;
  background: var(--section-tint);
  padding-block: var(--space-section);
}
/* The page's column rules live under `main` (.col-rules is z-index 0, main is
   1), which is invisible while sections are transparent but not once a band
   paints a background over them. The band redraws them itself so the vertical
   grid runs unbroken through the tint. Gated to the same width at which
   .col-rules appears. */
@media (min-width: 1201px) {
  .section--tint::after {
    content: ""; position: absolute; top: 0; bottom: 0; left: 50%;
    width: min(100%, var(--max-width)); transform: translateX(-50%);
    pointer-events: none;
    border-left: 1px solid var(--rule); border-right: 1px solid var(--rule);
  }
}
.hiw-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.hiw-cell { padding: var(--space-lg) var(--space-xl); }
.hiw-cell:nth-child(odd) { padding-left: 0; }
.hiw-cell:nth-child(-n+2) { padding-top: 0; }
/* Transparent tile, hairline rim, gradient mark: on a band this light a
   filled tile reads as a patch. The rim does the framing and the stroke
   carries the accent-to-signal ramp. */
.hiw-icon {
  display: inline-grid; place-items: center;
  width: 40px; height: 40px;
  border-radius: 4px; background: transparent;
  border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
  margin-bottom: var(--space-sm);
}
.hiw-icon svg { stroke: url(#hiwGrad); }
[data-theme="dark"] .hiw-icon { border-color: color-mix(in srgb, var(--accent) 34%, transparent); }
.hiw-cell:nth-child(n+3) { padding-bottom: 0; }
.hiw-cell .hiw-body { margin-bottom: var(--space-md); }
.hiw-cell .decisions li { margin-bottom: var(--space-xs); }
.hiw-cell .decisions li:last-child { margin-bottom: 0; }
@media (max-width: 860px) {
  .hiw-grid { grid-template-columns: 1fr; }
  .hiw-cell { padding: var(--space-md) 0; border-left: 0; }
}
/* claim tier, not display tier: same voice as the bolded statement lines
   (hero points, decisions), so the grid reads as content, not four headers */
.hiw-title { color: var(--ink); font-family: var(--font-body); font-weight: 600; font-size: var(--text-body); line-height: 1.4; margin-bottom: var(--space-xs); }
.hiw-body { color: var(--ink-body); max-width: 65ch; }
@media (prefers-reduced-motion: reduce) {
  .ds-card-media { transition: none; }
}

/* ============ EXPERIENCE ============
   A ruled ledger rather than a stack of cards. The company opens the line
   with its own mark, the role and the dates close it, and a single --rule
   hairline is all that separates one job from the next; at this density the
   card surface was drawing three boxes around six words. The whole line is
   still the control - one tab stop, named by the company, role and dates it
   holds - and the caret is demoted to the state indicator it looked like. */
.xp-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--rule); }
.xp-row { border-bottom: 1px solid var(--rule); }
button.xp-head {
  width: 100%; display: flex; align-items: center; gap: var(--space-md);
  padding: var(--space-md) 0; border: 0; background: none;
  font: inherit; color: inherit; text-align: left; cursor: pointer;
}
/* the ring follows a row, not a square, so it takes the panel corner */
.xp-head:focus-visible { border-radius: var(--radius-panel); outline-offset: 4px; }
.xp-co { display: flex; align-items: center; gap: var(--space-xs); min-width: 0; }
/* 22px mark, the same optical weight as the row's text. --crop is for the
   brand assets that ship as a lockup: the box clips the wordmark away and
   keeps the symbol, so one file serves both the marquee and this list. */
.xp-logo {
  flex: 0 0 auto; width: 22px; height: 22px; overflow: hidden;
  display: grid; place-items: center; border-radius: 4px;
}
.xp-logo img, .xp-logo svg { display: block; height: 22px; width: auto; max-width: none; }
.xp-logo--crop { justify-items: start; }
.xp-co-name { font-weight: 600; font-size: var(--text-body); color: var(--ink); }
.xp-meta { margin-left: auto; display: flex; align-items: baseline; gap: var(--space-md); min-width: 0; }
.xp-role { font-size: var(--text-body); color: var(--ink-body); }
/* dates are a reading aid, not a heading: quietest ink on the row, tabular so
   the three lines align digit under digit, and never wrapped */
.xp-dates { font-size: var(--text-small); font-weight: 400; color: var(--ink-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
.xp-caret {
  flex: 0 0 auto; display: grid; place-items: center; color: var(--ink-3);
  transition: transform var(--dur-med) var(--ease-out-expo), color var(--dur-med) var(--ease-out-expo);
}
.xp-row.is-open .xp-caret { transform: rotate(180deg); }
.xp-head:hover .xp-caret, .xp-head:focus-visible .xp-caret { color: var(--accent); }
.xp-head:hover .xp-co-name { color: var(--accent); }
.xp-detail {
  display: grid; grid-template-rows: 0fr;
  transition: grid-template-rows var(--dur-med) var(--ease-out-expo);
}
.xp-row.is-open .xp-detail { grid-template-rows: 1fr; }
/* the note belongs to the company, so it starts on the company name's left
   edge, past the mark and the gap it sits in */
.xp-detail .xp-note {
  overflow: hidden; min-height: 0; display: block; max-width: 65ch;
  padding-left: calc(22px + var(--space-xs));
  color: var(--ink-body); font-size: var(--text-small);
  opacity: 0; transition: opacity var(--dur-med) var(--ease-out-expo);
}
/* both spacers live inside the clipped box so they fold away with it */
.xp-detail .xp-note::before { content: ""; display: block; height: var(--space-2xs); }
.xp-detail .xp-note::after { content: ""; display: block; height: var(--space-md); }
.xp-row.is-open .xp-detail .xp-note { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .xp-detail, .xp-detail .xp-note, .xp-caret { transition: none; }
}

/* ============ ENDORSEMENTS ============ */
/* One rail, not a brick wall. The endorsements now sit directly under the
   case studies, where a five-card masonry would push the studio a screen and
   a half down the page, so they run as the same scroll-snap rail the systems
   carousel uses: three cards in view, the fourth peeking to say there is
   more, and the shared pager driving both. Each card reads title first -
   what the person is actually saying, in four words - then the quote, then
   who said it, so the row is scannable without reading five paragraphs. */
.quote-row {
  /* the sliver of the fourth card left showing at rest, on the 4pt grid */
  --q-peek: 56px;
  display: flex; gap: var(--space-md);
  overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none;
  /* clip slack for the cards' ambient shadow, matched by a negative margin so
     the rail still starts on the container's edge */
  padding: 10px; margin: -10px; scroll-padding-inline: 10px;
}
.quote-row::-webkit-scrollbar { display: none; }
/* Same surface grammar as the case cards on the project pages, at small
   scale: no outline, a fill a shade above --bg rather than pure white, and
   the edge drawn as a lit rim instead of a border - an inset ring plus a
   highlight along the top, which is what gives the card thickness and the
   soft, glassy read the nav bar has.
   --card-rim is light in BOTH themes (it is a highlight, not a line), so the
   dark theme only pulls it down to a whisper rather than inverting it.
   The workflow panel's gains row rides the same surface, so those two read as
   one family and there is one definition to change rather than two copies. */
.quote-card,
.wf-gains {
  position: relative;
  border: none;
  border-radius: var(--radius-media);
  padding: var(--space-lg);
  background: color-mix(in srgb, var(--bg-raised) 50%, var(--bg));
  --card-rim: rgba(255,255,255,0.9);
  /* --shadow-rest-sm, not --shadow-rest: these are small components and the
     full case-card ambient throws further than a card this size can carry. */
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, var(--ink) 3.5%, transparent),
    inset 0 1px 0 var(--card-rim),
    var(--shadow-rest-sm);
}
/* the rail sits on the tinted band, so the cards go fully raised: a 50% fill
   over a tinted ground reads as a stain rather than a card */
#reviews .quote-card { background: var(--bg-raised); }
[data-theme="dark"] .quote-card,
[data-theme="dark"] .wf-gains {
  /* depth in the dark runs upward: the card climbs off the page instead of
     sinking below it, and the lit edge drops to a whisper so it does not
     draw a white line across every card */
  background: color-mix(in srgb, var(--ink) 8%, var(--bg));
  --card-rim: rgba(255,255,255,0.06);
}
[data-theme="dark"] #reviews .quote-card { background: color-mix(in srgb, var(--ink) 8%, var(--bg)); }
/* Three cards sit fully in the rail and the fourth shows --q-peek of itself.
   Width = (rail - the 3 gaps - the peek) / 3, the same equation as .ds-card. */
.quote-card {
  flex: 0 0 calc((100% - var(--space-md) * 3 - var(--q-peek)) / 3);
  min-width: min(280px, 80vw); scroll-snap-align: start;
  display: flex; flex-direction: column;
}
/* The whole card is the target, not just the mark. The click is forwarded to
   the anchor in site.js rather than by stretching the anchor over the card:
   an overlay that covers the quote also makes the quote impossible to select,
   and a recommendation is exactly the kind of text people copy. So the card
   only carries the affordance (pointer, and the mark lighting up from
   anywhere) while the anchor stays a real link the keyboard can reach. */
.quote-card { cursor: pointer; }
/* the claim, in the person's own words but compressed to a line. Body size,
   not a heading size: it has to sit above the quote without outranking the
   section's own pill. */
.quote-title {
  font-weight: 600; font-size: var(--text-body); line-height: 1.35;
  color: var(--ink); margin-bottom: var(--space-sm);
  /* clears the mark that sits in the same corner of the padding box */
  padding-right: calc(24px + var(--space-xs));
}
.quote-text {
  margin: 0 0 var(--space-lg);
  font-size: var(--text-small); line-height: 1.6; color: var(--ink-body);
}
/* the attribution is pinned to the foot of the card so all five names sit on
   one line across the rail, however long the quotes run */
.quote-by {
  /* top-aligned, not centred: a role that wraps to three lines would drop the
     avatar half a line below the name it belongs to */
  margin-top: auto; display: flex; align-items: flex-start; gap: var(--space-xs);
}
.quote-avatar {
  flex: 0 0 auto; width: 40px; height: 40px; border-radius: 50%;
  display: grid; place-items: center;
  font-size: 0.8125rem; font-weight: 600; letter-spacing: var(--track-md);
  color: var(--accent);
  background: color-mix(in srgb, var(--accent) 12%, var(--bg-raised));
}
[data-theme="dark"] .quote-avatar { background: color-mix(in srgb, var(--accent) 22%, var(--bg-raised)); }
.quote-id { display: flex; flex-direction: column; min-width: 0; }
.quote-author { font-weight: 600; font-size: var(--text-small); color: var(--ink); }
.quote-role { font-size: 0.8125rem; line-height: 1.4; color: var(--ink-2); margin-top: 2px; }
/* LinkedIn mark sits in the card's top corner, sized to the small square
   controls. Quiet at rest so it never competes with the quote, and out of the
   attribution row so the role gets the full width before it wraps. */
.quote-li {
  position: absolute; top: var(--space-lg); right: var(--space-lg);
  display: grid; place-items: center; width: 24px; height: 24px;
  color: color-mix(in srgb, var(--accent) 55%, var(--ink-3));
  transition: color var(--dur-med) var(--ease-out-expo);
}
.quote-card:hover .quote-li,
.quote-li:hover, .quote-li:focus-visible { color: var(--accent); }

/* ============ SKILLS ============ */
.skills-row { display: flex; flex-wrap: wrap; gap: var(--space-xs); }

/* ============ FOOTER (inverted) ============ */
.footer { position: relative; background: var(--ink); color: var(--bg); padding-block: var(--space-section) var(--space-2xl); }
/* .col-rules sits under main and the footer paints over it, so the footer
   redraws the frame itself, in a light hairline against the ink. */
@media (min-width: 1201px) {
  .footer::before {
    content: ""; position: absolute; top: 0; bottom: 0; left: 50%;
    width: min(100%, var(--max-width)); transform: translateX(-50%);
    pointer-events: none; z-index: 1;
    border-left: 1px solid rgba(255,255,255,0.12);
    border-right: 1px solid rgba(255,255,255,0.12);
  }
}
.footer--slim { padding-block: var(--space-2xl); }
/* ---- footer wordmark ----
   The name set enormous and cropped by the page edge, then dissolved by a
   PROGRESSIVE blur: four stacked backdrop-filter layers, each blurring
   harder and each masked to start lower than the last. One blurred copy
   would band; overlapping masked layers ramp smoothly, which is the effect
   in the reference. Pure decoration, so aria-hidden and pointer-events off. */
.footer-wordmark {
  container-type: inline-size;
  position: relative; margin-top: var(--space-2xl);
  margin-bottom: calc(var(--space-2xl) * -1);
  overflow: hidden; pointer-events: none; user-select: none;
}
.footer-wordmark-text {
  display: block; text-align: center; white-space: nowrap;
  font-family: var(--font-display); font-weight: 600;
  /* sized off the viewport so it always reaches both edges */
  /* cqw, not vw: the mark is sized by the frame it sits in, so it keeps
     fitting between the vertical rules once the page caps at --max-width */
  font-size: 25.5cqw; line-height: 0.82; letter-spacing: -0.03em;
  color: var(--bg);
  /* cropped by the page edge, exactly as in the reference */
  margin-bottom: -0.17em;
}
/* no backdrop-filter (older Safari/Firefox): fall back to a plain fade so
   the wordmark still dissolves instead of sitting there hard-edged */
@supports not (backdrop-filter: blur(1px)) {
  .footer-wordmark-text {
    mask-image: linear-gradient(to bottom, #000 45%, transparent 92%);
    -webkit-mask-image: linear-gradient(to bottom, #000 45%, transparent 92%);
  }
}
.footer .label { color: color-mix(in srgb, var(--bg) 62%, transparent); } /* 62% not 55%: dark-theme labels were 4.03:1 on the inverted panel */
.footer .label--accent { color: var(--accent-inverse); }
.footer .nav-link:hover, .footer .nav-link:focus-visible { color: var(--bg); }
.footer .nav-link::after { background: var(--accent-inverse); }
.footer-line {
  font-family: var(--font-display); font-weight: 600;
  font-size: var(--text-h2); letter-spacing: var(--track-lg); line-height: 1.1;
  max-width: 18ch; margin-block: var(--space-md) var(--space-lg);
}
.footer-sub { color: color-mix(in srgb, var(--bg) 65%, transparent); max-width: 50ch; margin-bottom: var(--space-lg); }
.footer-email {
  position: relative; color: var(--accent-inverse);
  display: inline-block; margin-bottom: var(--space-3xl);
  /* Switzer, not Erode — serif is reserved for hero headline, case titles,
     footer statement + italic accent words; the email is a link, not the statement */
  font-family: var(--font-body); font-weight: 600; letter-spacing: -0.01em;
  font-size: var(--text-h3); overflow-wrap: anywhere;
}
.footer-email::after {
  content: ""; position: absolute; left: 0; bottom: 2px; width: 100%; height: 2px;
  background: var(--accent-inverse); transform: scaleX(0); transform-origin: left;
  transition: transform var(--dur-med) var(--ease-out-expo);
}
.footer-email:hover::after, .footer-email:focus-visible::after { transform: scaleX(1); }
.footer-meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-lg); }
/* home footer: the email carries the row on its own, so the copyright sits on
   its baseline at the far end instead of on a line of its own below it */
.footer-meta--email { align-items: baseline; }
.footer-meta--email .footer-email { margin-bottom: 0; }
.footer-links { display: flex; gap: var(--space-lg); }

/* ============ CASE-STUDY PAGES ============ */
/* Narrower container + two-track sections: sticky label rail left, content right.
   Kills the dead right column a lone 65ch column leaves in a wide container. */
.cs-main { max-width: 1120px; position: relative; }
/* ---- case-study section rail ----
   A sticky list of the page's sections, living in a track of its own on the
   LEFT of the content column. This is also the answer to the wide gutter on
   case pages: the margin is not dead space, it is where the navigation
   lives, and the content column keeps its full 1120px for prose and images. */
.cs-toc { display: none; }
@media (min-width: 1200px) {
  .cs-main { max-width: 1324px; padding-left: 204px; }
  .cs-toc {
    display: block; position: absolute; left: 0; top: 0; bottom: 0; width: 180px;
  }
  .cs-toc-inner {
    position: sticky; top: calc(72px + var(--space-2xl));
    display: flex; flex-direction: column; gap: var(--space-xs);
    /* aligns the rail's first label with the first section, past the hero */
    margin-top: var(--space-3xl);
  }
  /* One continuous hairline behind every label instead of per-link border
     segments; a single accent marker drifts along it to the active slot.
     --dur-med is the studio cards' expand-button speed, so the two move
     at the same tempo. */
  .cs-toc-inner::before {
    content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 1px;
    background: var(--rule, var(--line));
  }
  .cs-toc-marker {
    position: absolute; left: -0.5px; top: 0; width: 2px; height: 0;
    background: var(--accent); border-radius: 2px;
    transition: translate var(--dur-med) var(--ease-out-expo), height var(--dur-med) var(--ease-out-expo);
  }
  .cs-toc a {
    /* stays at its pre-rebase 14px: 13 was too small for wayfinding */
    font-size: 0.875rem; color: var(--ink-2); line-height: 1.4;
    padding-left: var(--space-sm);
    transition: color var(--dur-med) var(--ease-out-expo);
  }
  .cs-toc a:hover { color: var(--ink); }
  .cs-toc a.is-active { color: var(--ink); }
}
/* One track, not two. The number and section name used to sit in a sticky
   168px rail to the left of the content, which cost 208px of every row on
   the page and repeated the .cs-toc that already tracks position on wide
   screens. They now sit on top of the section, as one horizontal line, and
   the content gets the width back. Row-gap MUST stay 0: some labels still
   carry an inline grid-row span, and any row-gap multiplies into phantom
   space after the section. Vertical rhythm comes from the sibling margins. */
.cs-main .cs-section {
  display: grid; grid-template-columns: minmax(0, 1fr);
  gap: 0; align-items: start;
}
.cs-main .cs-section > * { grid-column: 1; }
.cs-main .cs-section > * + *:not(.cs-section-label) { margin-top: var(--space-md); }
.cs-main .cs-section > .cs-section-label + * { margin-top: 0; }
/* the heading's own 12px is the whole gap; the sibling margin would stack
   on top of it and inflate the pair */
.cs-main .cs-section > .cs-heading + * { margin-top: 0; }
.cs-hero { padding-top: calc(72px + var(--space-xl)); padding-bottom: var(--space-block); }
.cs-back { display: inline-flex; align-items: center; gap: var(--space-xs); margin-bottom: var(--space-lg); }
/* Same opening move as the buttons, mirrored: rests on a bare left chevron,
   hover fades it into the full arrow with a step back. The 180° rotation
   reuses the shared right-facing arrow art. */
.cs-back::before {
  content: ""; width: 14px; height: 14px; flex: none; background: currentColor;
  -webkit-mask: var(--arrow-head-svg) center / contain no-repeat;
          mask: var(--arrow-head-svg) center / contain no-repeat;
  transform: rotate(180deg);
  transition: -webkit-mask-image var(--dur-med) var(--ease-out-expo), translate var(--dur-med) var(--ease-out-expo);
  transition: mask-image var(--dur-med) var(--ease-out-expo), translate var(--dur-med) var(--ease-out-expo);
}
.cs-back:hover::before, .cs-back:focus-visible::before {
  -webkit-mask-image: var(--arrow-svg);
          mask-image: var(--arrow-svg);
  translate: -2px 0;
}
/* the opening arrow is the whole hover; the nav-link underline on top of it
   said the same thing twice */
.cs-back::after { display: none; }
.cs-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-md); margin-bottom: var(--space-md); }
.cs-title {
  color: var(--ink); font-family: var(--font-display); font-weight: 600;
  font-size: clamp(2.1875rem, 1.75rem + 2.7vw, 3.0625rem); /* 35 → 49px */
  line-height: 1.05; letter-spacing: var(--track-lg);
  max-width: 18ch; margin-bottom: var(--space-md);
}
.cs-sub { font-size: var(--text-body); line-height: 1.6; color: var(--ink-body); max-width: 60ch; margin-bottom: var(--space-md); }
/* The named deliverables are the only thing in the hero a recruiter scans
   for, so they carry the emphasis: 600 at full ink, the same weight the
   prose and the impact copy use, never a heavier one. */
.cs-sub strong { color: var(--ink); font-weight: 600; }
/* The case hero ran two columns, the claim on the left and the principles in
   a 382px rail on the right. The rail was the problem. To fit there the
   principles were set at --text-label, 13px, which is below the 16px floor
   this site holds for functional text, and they still ran three and four
   lines each against a 49px title beside them. Stacked below 900 they went
   the other way and set 110 to 131 characters a line, nearly double a
   readable measure.
   They sit under the title and description now, full width, two up. That is
   16px at roughly 53 characters, which is inside the measure at both ends. */
.cs-hero-grid {
  display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
  gap: var(--space-2xl); align-items: start;
}
/* Collapsed by default, one claim per row.
   Laid side by side the four ran to different depths and the block ragged
   badly, because the explanations are 22 to 30 words and no two are the same
   length. Collapsed, every row is exactly its claim, so the four are uniform
   and the rail reads as a list of four positions rather than four paragraphs.
   The explanation is one tap away and opens in place.

   <details>, not a script: the open state, the keyboard, the screen reader
   announcement and the print behaviour all come with the element, and there
   is no enhancement to re-run when the language toggle rewrites the text.
   Both labels carry their own data-i18n so the swap lands inside the summary
   and inside the panel, leaving the structure alone.

   Opening one grows the rail into space the hero already had: the claim
   column beside it runs taller than four collapsed rows, so the section
   height does not move until the second one is open. */
.cs-principles > .label { display: block; margin-bottom: var(--space-sm); color: var(--ink); font-weight: 600; }
.cs-principles .decisions {
  max-width: none;
  /* The space for one open panel is reserved, so opening one moves nothing.
     Measured in the 382px rail: four collapsed rows are 138px, and the
     tallest panel of the four takes the block to 245. German and English
     both top out at 245, German because its compounds run the longest, so
     one number covers both. With <details name> making the accordion
     exclusive, 245 is the ceiling rather than a starting point: a second
     click closes the first panel.
     Only above 900, where the rail is a fixed 382px. Stacked below that the
     panel width tracks the viewport and no single number is right. */
  min-height: 245px;
}
@media (max-width: 900px) { .cs-principles .decisions { min-height: 0; } }
.cs-principles .decisions li { margin-bottom: var(--space-sm); font-size: var(--text-small); }
.cs-principles .decisions li:last-child { margin-bottom: 0; }
.cs-principles summary {
  display: flex; align-items: flex-start; gap: var(--space-sm);
  cursor: pointer; list-style: none;
  color: var(--ink-body); font-weight: 500;
}
/* the UA triangle, in both dialects */
.cs-principles summary::-webkit-details-marker { display: none; }
.cs-principles summary::marker { content: ""; }
/* A plus that loses its upright to become a minus. One glyph drawn in CSS
   rather than two swapped characters, so the sign animates instead of
   flickering, and it never needs a font that has both. */
.cs-principles summary::after {
  content: ""; flex: none; margin-left: auto;
  position: relative; top: 0.45em;
  width: 11px; height: 11px;
  background:
    linear-gradient(var(--accent), var(--accent)) center / 11px 1.5px no-repeat,
    linear-gradient(var(--accent), var(--accent)) center / 1.5px 11px no-repeat;
  transition: transform var(--dur-fast) var(--ease-out-expo);
}
/* Half a turn, not a quarter. At 90deg the bar that survives is rotated with
   everything else, so the minus stood on end as a vertical stroke. 180deg
   leaves a horizontal bar horizontal and still animates. */
.cs-principles details[open] summary::after {
  transform: rotate(180deg);
  background: linear-gradient(var(--accent), var(--accent)) center / 11px 1.5px no-repeat;
}
.cs-principles summary:hover, .cs-principles summary:focus-visible { color: var(--accent); }
.cs-principles details p {
  margin: var(--space-2xs) 0 0;
  max-width: 62ch;   /* stacked below 900 the rail runs full width */
  color: var(--ink-body);
}
@media (prefers-reduced-motion: reduce) {
  .cs-principles summary::after { transition: none; }
}
.cs-principles .decisions li strong { font-weight: 600; }
@media (max-width: 900px) {
  .cs-hero-grid { grid-template-columns: 1fr; gap: var(--space-lg); }
}
.cs-meta, .cs-back, .cs-sub, .cs-hero .pills { opacity: 0; animation: fade 600ms var(--ease-out-expo) 450ms forwards; }
@keyframes fade { to { opacity: 1; } }

/* Cover / banner */
.cs-cover { margin-bottom: var(--space-block); }
.cs-cover .frame { overflow: hidden; border-radius: var(--radius-media); background: var(--bg-raised); }
/* Every cover shows WHOLE, at the ratio the cover pipeline rendered it.
   The pipeline already frames the shot, so a second crop here can only
   take something away: the studio covers are product screens composed on
   a 1400x820 canvas, the Shopify cover a 2x2 of four merchant screens, and a band
   forced across either one keeps a slice and loses the rest.
   `height: auto` is the part that does the work, and it is not optional.
   An img's width/height attributes are presentational hints that map to
   CSS width and height. A rule that sets only width leaves the attribute
   height standing, and an explicit height beats aspect-ratio, so the box
   silently becomes the number in the markup (820px) rather than anything
   the stylesheet asked for. Handing height back to auto is what lets the
   image's own ratio set the box. */
.cs-cover img { width: 100%; height: auto; display: block; }
/* A cover that is a bare image gets the corners its neighbours already have.
   Every cover wrapped in .frame or .live-frame is cut to --radius-media, so a
   raster cover dropped straight into the figure was the only square-cornered
   picture on the site. Direct child only: an image inside .frame is already
   clipped by the wrapper. */
.cs-cover > img { border-radius: var(--radius-media); }
.cs-banner {
  aspect-ratio: 21/9; border-radius: var(--radius-media); display: grid; place-items: center;
  font-size: clamp(2.625rem, 5.3vw, 4rem); color: #fff; /* capped at the 64px display ceiling */
}

/* TL;DR */
.tldr {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--space-lg);
  border: 1px solid var(--line); border-radius: var(--radius-panel);
  padding: var(--space-lg); margin-bottom: var(--space-block);
}
.tldr dt { margin-bottom: var(--space-2xs); }
.tldr dd { margin: 0; font-weight: 600; font-size: var(--text-body); color: var(--ink); }

/* NDA notice (ported) */
.nda {
  display: flex; gap: var(--space-lg); align-items: flex-start;
  border: 1px solid var(--line); border-left: 3px solid var(--accent);
  border-radius: var(--radius-panel); padding: var(--space-md) var(--space-lg);
  margin-bottom: var(--space-block); max-width: 72ch;
}
.nda-body { font-size: var(--text-small); color: var(--ink-body); }
.nda-body a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.nda .label { display: block; margin-bottom: var(--space-2xs); color: var(--ink); }

/* Sections & prose */
.cs-section { margin-bottom: var(--space-section); }
.cs-section-label { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-xs); }
/* the section name reads at full ink; only the numeral and rule stay quiet */
.cs-section-label .label:not(.label--accent) { color: var(--ink); }
/* numeral reads as a plate mark: small, muted, followed by a hairline that
   runs into the column. No oversized figures. */
.cs-section-label .label--accent {
  font-size: var(--text-label); font-weight: 600; color: var(--ink-3);
  letter-spacing: 0.04em; flex: none;
}
.cs-section-label .label--accent::after {
  content: ""; display: inline-block; vertical-align: middle;
  width: 28px; height: 1px; background: var(--line); margin-left: var(--space-sm);
}
/* Two spacing tiers, proportional to type size: section heading→text 12px,
   small title→text 8px (cards, hiw). Same ink/gray pairing at every tier. */
.cs-heading { color: var(--ink); font-family: var(--font-display); font-weight: 600; font-size: var(--text-h3); line-height: 1.3; letter-spacing: var(--track-md); max-width: 40ch; margin-bottom: var(--space-sm); }
.prose { max-width: 65ch; }
.prose p { margin-bottom: var(--space-lg); }
.prose p:last-child { margin-bottom: 0; }
.prose strong { font-weight: 600; color: var(--ink); }
.prose a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.prose em { font-style: italic; }

/* Figures */
.cs-figure { margin-block: var(--space-block); }
.cs-figure .frame { overflow: hidden; border-radius: var(--radius-media); }
.cs-figure .frame img { width: 100%; height: auto; }
.cs-figure figcaption { font-size: var(--text-small); color: var(--ink-body); margin-top: var(--space-md); max-width: 65ch; }

/* ---- the figure's own switch ----
   A prototype in a frame is inert on purpose, so a control INSIDE it is a
   control nobody can reach: the visitor either works that out and gives up,
   or is asked to open the whole app just to see a second state. So the
   switch comes out of the frame and stands on the page, where it is a real
   button that happens to redraw a screen.

   Same tab the version numerals use above (.cs-vtab): a row on the --rule
   hairline, the current one in accent, on an accent border. Words rather
   than numerals, so this one takes body type - the page has one kind of tab,
   set in whichever face the label belongs to. */
/* No JS, no strip: the frame is driven across a message channel, so a row
   of buttons that cannot send one is worse than no row at all. Same contract
   .cs-vtabs keeps, and site.js marks it live from the same place it wires
   the clicks. */
.fig-tabs { display: none; }
.fig-tabs.is-live {
  display: flex; flex-wrap: wrap; gap: var(--space-md); align-items: baseline;
  border-bottom: 1px solid var(--rule);
  /* the strip is the control for the frame directly under it, so it sits
     nearer to that frame than to whatever paragraph it followed */
  margin-bottom: var(--space-sm);
}
.fig-tab {
  appearance: none; background: none; border: 0; cursor: pointer;
  /* the same 44px target the version tabs make, for the same reason */
  padding: var(--space-md) var(--space-xs) var(--space-xs); margin-bottom: -1px;
  border-bottom: 1px solid transparent;
  font: 500 var(--text-body)/1 var(--font-body); letter-spacing: -0.01em;
  color: var(--ink-3);
  transition: color var(--dur-fast) var(--ease-emerge),
              border-color var(--dur-fast) var(--ease-emerge);
}
/* the padding is hit area, not indent: the first label starts on the column */
.fig-tab:first-child { margin-left: calc(var(--space-xs) * -1); }
.fig-tab:hover { color: var(--ink-2); }
.fig-tab[aria-selected="true"] { color: var(--accent); border-bottom-color: var(--accent); }
.fig-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 2px; }
@media (prefers-reduced-motion: reduce) { .fig-tab { transition: none; } }

/* Callout / highlight (ported from highlight-block) */
.callout {
  background: var(--accent-dim); border-radius: var(--radius-panel);
  padding: var(--space-lg); max-width: 65ch; margin-block: var(--space-lg);
}
/* small-title tier: 8px to its text, same as every other label/body pair */
.callout .label { display: block; margin-bottom: var(--space-xs); }

/* Metric ledger — stacked hairline rows, spec-sheet cadence.
   The figure sits in the display serif (the site's one deliberate type voice),
   the meaning in body type beside it, aligned on a shared baseline.
   Replaces the 3-up number grid, which read as the generic SaaS metric strip. */
.metrics { margin-block: var(--space-block); border-top: 1px solid var(--line); }
.metrics > div {
  display: grid; grid-template-columns: clamp(150px, 24vw, 240px) 1fr;
  column-gap: var(--space-lg); align-items: baseline;
  padding-block: var(--space-lg);
  border-bottom: 1px solid var(--line);
}
.metric-num {
  color: var(--ink); font-family: var(--font-display); font-weight: 600;
  font-size: var(--text-h2); letter-spacing: var(--track-lg); line-height: 1.15;
  text-wrap: balance;
}
.metrics .label { display: block; font-weight: 600; max-width: 60ch; color: var(--ink-body); }
.metrics-note { font-size: var(--text-label); color: var(--ink-2); margin-top: calc(-1 * var(--space-block) + var(--space-md)); margin-bottom: var(--space-block); } /* ink-2 not ink-3: 14px text needs 4.5:1 */
.metrics-note a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }

/* Deliverables (ported) */
.deliverables { list-style: none; padding: 0; max-width: 65ch; margin-block: var(--space-lg); }
.deliverables li { position: relative; padding-left: var(--space-lg); margin-bottom: var(--space-md); color: var(--ink-body); }
.deliverables li::before { content: "–"; position: absolute; left: 0; color: var(--accent); }

/* Decisions (ported: category + ✦ list) */
.decisions-cat { margin-block: var(--space-xl); }
/* category names are claims, not metadata: full ink, statement weight */
/* category over a list: 12px, one step up from the 8px label/text pair */
.decisions-cat > .label { display: block; margin-bottom: var(--space-sm); color: var(--ink); font-weight: 600; }
.decisions { list-style: none; padding: 0; max-width: 68ch; }
.decisions li { position: relative; padding-left: var(--space-lg); margin-bottom: var(--space-md); color: var(--ink-body); }
.decisions li::before { content: "✦"; position: absolute; left: 0; top: 2px; color: var(--accent); font-size: var(--text-label); }
.decisions li strong { color: var(--ink); font-weight: 600; } /* emphasis weight is 600 everywhere (.prose strong, .project-impact strong) */
/* A decision point carries its own mark instead of the shared sparkle: the
   glyph sits in the same outline-button frame as the How-I-work icons and
   runs the accent-to-signal ramp. The ✦ is suppressed so the row keeps one
   marker, and the text hangs off the frame rather than wrapping under it. */
.decisions li.dp { padding-left: 52px; min-height: 36px; }
.decisions li.dp::before { display: none; }
.dp-icon {
  position: absolute; left: 0; top: 0;
  display: grid; place-items: center; width: 36px; height: 36px;
  border: 1px solid var(--line); border-radius: 4px;
}
.dp-icon svg { width: 19px; height: 19px; display: block; }

/* ---- the picture belongs to the list above it ----
   Every figure in a decisions section is evidence for the category it
   follows: the claim is in the sentences, the proof is in the screen under
   them. It sat 64px under that list and the next category began 44px under
   the figure, so the picture was physically CLOSER to the text it was not
   about, and it read as a slab between two lists rather than as the answer
   to one of them.

   So the pair binds by proximity instead. The figure comes in close under
   the sentences it is answering, and the next category starts a clear step
   further down, which is the only cue that tells a reader which text a
   picture is for. Both sides are stated here rather than left to the
   section's shared 16px sibling margin, because the point is the RATIO
   between them, and a ratio set in two places drifts. */
.cs-main .cs-section > .decisions-cat:has(+ figure) { margin-bottom: var(--space-md); }
.cs-main .cs-section > .decisions-cat + figure { margin-top: 0; }
/* the list's own trailing 16px counts too: it is the last thing between the
   sentence and the picture, and left in place it doubles the gap the rule
   above is setting */
.cs-main .cs-section > .decisions-cat:has(+ figure) .decisions li:last-child { margin-bottom: 0; }
.cs-main .cs-section > figure:has(+ .decisions-cat) { margin-bottom: var(--space-sm); }
.cs-main .cs-section > figure + .decisions-cat { margin-top: var(--space-2xl); }

/* Module list (Vera) + inline code */
.module-list { list-style: none; padding: 0; counter-reset: mod; max-width: 68ch; margin-block: var(--space-lg); }
.module-list li { counter-increment: mod; position: relative; padding: var(--space-lg) 0 var(--space-lg) var(--space-3xl); border-top: 1px solid var(--line); color: var(--ink-body); }
.module-list li::before {
  content: counter(mod, decimal-leading-zero); position: absolute; left: 0; top: var(--space-lg);
  font-family: var(--font-body); font-size: var(--text-h3); font-weight: 600; color: var(--ink-3); line-height: 1; /* numerals match .project-num at 600 */
}
.module-role { display: block; font-weight: 600; color: var(--ink); margin-bottom: var(--space-2xs); }
.code {
  font-family: ui-monospace, "SF Mono", Consolas, monospace;
  font-size: 0.875em; background: var(--accent-dim); border-radius: var(--radius-panel); padding: 1px var(--space-2xs);
}

/* Next case + related */
.cs-next { border-top: 1px solid var(--line); padding-block: var(--space-2xl); }
.cs-next .label { display: block; margin-bottom: var(--space-2xs); }
.cs-next-link { margin-top: 0 !important; }
.related-row { margin-top: var(--space-lg); display: flex; flex-wrap: wrap; gap: var(--space-lg); }
/* related links are links, not chips */
.related-row .pill {
  border: 0; background: none; padding: 0; border-radius: 0;
  color: var(--ink-2); font-size: var(--text-small);
  text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 4px;
}
.related-row .pill:hover { color: var(--ink); text-decoration-color: var(--ink-2); }
.cs-next-link {
  display: inline-flex; align-items: baseline; gap: var(--space-md);
  font-family: var(--font-body); font-weight: 600; font-size: var(--text-h3);
  letter-spacing: -0.01em; transition: color var(--dur-med) var(--ease-out-expo); margin-top: var(--space-md);
}
.cs-next-link:hover, .cs-next-link:focus-visible { color: var(--accent); }
/* next-case cards: the studio card recipe pointed at the other case studies,
   auto-fit so four cards read as a row on wide screens and wrap cleanly.
   Stripe anatomy: the cover fills the card's top edge-to-edge, a hairline
   separates it, and the text sits below. */
.cs-next .card-grid { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); margin-top: var(--space-lg); }
.cs-next .mini-card { min-height: 0; overflow: hidden; }
/* Footer wayfinding cards, not canvases: the title sits one step above the
   16px desc. 24px lede still read big against the text, so body size it is. */
.cs-next .mini-card-title { font-size: var(--text-body); }
.cs-next .mini-banner {
  aspect-ratio: 16/10; border-radius: 0;
  margin: calc(-1 * var(--space-lg)) calc(-1 * var(--space-lg)) var(--space-2xs);
  border-bottom: 1px solid var(--line);
}

@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }

/* ============ SIDE CONTENTS RAIL (design-system pages) ============
   Named sections in a sticky left rail instead of a pill row: the labels
   carry the meaning, the active one inks in as you scroll, and long lists
   scroll inside the rail rather than pushing the page. */
.ds-contents {
  position: sticky; top: 96px; align-self: start;
  display: flex; flex-direction: column; gap: var(--space-2xs);
  max-height: calc(100vh - 140px); overflow-y: auto;
  padding-right: var(--space-xs);
}
.ds-contents .label { margin-bottom: var(--space-xs); color: var(--ink-3); }
.ds-contents a {
  position: relative; padding: var(--space-2xs) 0 var(--space-2xs) var(--space-md);
  font-size: var(--text-small); color: var(--ink-2);
  transition: color var(--dur-med) var(--ease-out-expo);
}
.ds-contents a::before {
  content: ""; position: absolute; left: 0; top: 50%; translate: 0 -50%;
  width: 2px; height: 0; background: var(--accent); border-radius: 2px;
  transition: height var(--dur-fast) var(--ease-out-expo);
}
.ds-contents a:hover, .ds-contents a:focus-visible { color: var(--ink); }
.ds-contents a.is-active { color: var(--accent); }
.ds-contents a.is-active::before { height: 60%; }
/* The shell is a plain wrapper now: site.js replaces each page's own
   .ds-contents nav with the shared .cs-toc rail, so the old 180px rail
   track would swallow the content into a 180px column if it stayed. */
.ds-shell { display: block; }
@media (max-width: 900px) {
  .ds-contents {
    position: static; flex-direction: row; flex-wrap: wrap; align-items: center;
    max-height: none; overflow: visible;
  }
  .ds-contents a { padding-left: 0; }
  .ds-contents a::before { display: none; }
}

/* Live interface frame: a real running screen scaled into the cover slot,
   not a screenshot. Inert (no pointer events, not focusable) so it reads as
   an artifact on the page rather than an app the visitor should operate. */
.live-frame {
  position: relative; overflow: hidden;
  border-radius: var(--radius-media); border: 1px solid var(--line);
  background: var(--bg-raised);
  /* The slot is sized HERE, and only here. The iframe is a 1440px document
     that script scales down, so a slot with no height of its own is 820px
     tall until the script runs and shows the app at 1:1 in the meantime: a
     giant, half-cropped screen that then snaps to cover size. The page
     author states the prototype's ratio in --fw/--fh (the same numbers as
     the iframe's data-w/data-h, which stay the source of truth for the
     scale) and the box is right from the first paint.

     content-box so the ratio measures the SCREEN and not the screen plus its
     hairline: the scaled iframe is exactly contentWidth / ratio tall, and a
     border counted into the ratio leaves a pixel of background under it.
     max-height is the clamp the script applies to the scale — a slot wider
     than the app shows it at 1:1 rather than blown up — kept in the same
     place as the ratio it belongs to. */
  box-sizing: content-box;
  aspect-ratio: var(--fw, 1440) / var(--fh, 820);
  max-height: calc(var(--fh, 820) * 1px);
}
.live-frame iframe {
  width: 1440px; height: 820px; border: 0;
  transform-origin: top left; pointer-events: none;
  display: block;
  /* and the screen itself waits until it has been scaled — see .is-fit, set
     by site.js on the first successful measure. No transition on this one:
     the whole figure already arrives on the reveal fade, and a property the
     content depends on should resolve instantly, not over 200ms. Pages carry
     a noscript override so a scriptless visitor still sees the screen. */
  opacity: 0;
}
.live-frame.is-fit iframe { opacity: 1; }

/* Desktop prototypes authored narrower than 1440. The shared rule renders
   every embedded document at 1440 and lets script scale it down, which is
   right for a screen drawn at 1440 and wrong for one drawn at 1180: the
   document lays itself out for a viewport it does not have, the slot shows
   the leftmost 1180 of it, and the right-hand column is cut off. Same fix
   the phone variant already makes - the iframe takes the authored viewport
   the page states in --fw/--fh, and the fit scale then lands on the whole
   screen instead of a crop of it. */
.live-frame--fit iframe {
  width: calc(var(--fw, 1440) * 1px);
  height: calc(var(--fh, 820) * 1px);
}

/* ---- the frame invites the click ----
   A still of a running app reads as a screenshot, and a screenshot is not
   something anyone tries to operate. So the frame breathes: a slow accent
   glow around its edge, the one piece of idle motion on the page, which is
   exactly why it draws the eye to the only box that is actually a machine.
   Held under a quarter of the accent and on a four-second cycle so it
   registers in peripheral vision without competing with the copy, and off
   entirely where motion is unwelcome. */
@media (prefers-reduced-motion: no-preference) {
  .live-frame { animation: live-invite 4s ease-in-out infinite; }
  /* ...except where the frame is not a door. A glow that says "operate
     me" over a screen whose control is the tab strip above it sends the
     pointer to the one place that has nothing to answer. */
  .live-frame[data-no-try] { animation: none; }
}
@keyframes live-invite {
  0%, 100% { box-shadow: 0 0 0 0 transparent; }
  50%      { box-shadow: 0 0 26px 2px color-mix(in srgb, var(--accent) 24%, transparent); }
}

/* Phone variant: a mobile prototype standing in the body of a case rather
   than filling a cover. It holds its authored 390px width instead of
   stretching to the column, so the app runs at 1:1 and the try-it gate
   opens it at the same scale it is already showing. */
.live-frame--phone {
  /* content-box so the 390 is the APP's width, not the app plus the frame's
     hairline: a 388px slot would scale the screen to 0.995 and soften every
     glyph in it for the sake of two pixels. */
  box-sizing: content-box;
  /* the cap has to leave room for the hairline the content box does not
     count, or on a phone the frame stands 2px wider than its column and the
     page picks up a sideways scroll it can never use */
  width: 390px; max-width: calc(100% - 2px);
  margin: var(--space-xl) auto 0;
}
/* The screen inside is a PHONE, so it has to be rendered at phone size.
   The shared rule sizes every embedded document 1440x820 and lets script
   scale it down, which is right for a desktop prototype and wrong here: a
   mobile app handed a 1440px viewport lays itself out for a viewport it
   will never have, and the slot then shows a 390px-wide crop of that. The
   ratio the page already states in --fw/--fh is the app's real viewport,
   so the iframe takes it directly and the fit scale resolves to 1. */
.live-frame--phone iframe {
  width: calc(var(--fw, 390) * 1px);
  height: calc(var(--fh, 844) * 1px);
}
/* As a cover it IS the hero, so it opens the page rather than following
   something: the leading gap belongs to the hero above it, not here. */
.cs-cover .live-frame--phone { margin-top: 0; }

/* ============ COMPONENT ROW ============
   Components lifted out of the same prototype the cover is showing,
   standing under it. The cover answers "what is this product"; the row
   answers "what did you actually design", which is the question a hiring
   reader is holding and the one a whole-screen shot at 60% never quite
   lets them read.

   Each frame runs the real component at 1:1 rather than scaling a
   screenshot into a thumbnail: at this width a scaled screen would put the
   product's 14px type on the page at nine, and a component nobody can read
   is a texture. The frames are inert, like the cover, and they are not
   doors: the cover is the way into the app, and more of them in a row
   would be more invitations to leave the case study. */
/* It punctuates a decision list rather than opening a section, so it takes
   the same block rhythm as the diagrams it stands among (see .csd--sm). */
.cs-parts { margin-block: var(--space-md) var(--space-xl); }
.cs-parts-grid {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-md); align-items: start;
}

/* ---- stepped: one screen, cut down ----
   Full column width and stacked, not side by side. Three versions of a
   screen at 285px each would be three squeezed thumbnails whose differences
   are exactly the thing the reader cannot see, and the differences ARE the
   argument. At full width each one runs at the size it was designed at, and
   the reduction is legible as the frames getting physically shorter.

   Heights deliberately NOT equalised here: v1 is short because v1 is small,
   and padding it out to match v3 would erase the point. */
.cs-parts--steps { display: grid; gap: var(--space-2xl); }
.cs-parts--steps .cs-part { display: grid; gap: var(--space-xs); }
/* Whole screens, as stills. Each carries its own intrinsic width/height so
   the slot is the right shape before the file arrives and the row never
   reflows under the reader. */
/* ---- annotated screenshot ----
   A dot with a short leader on the thing itself, and the sentence in a
   numbered legend under the picture. The margin-note version of this ran off
   the frame on the right-hand anchors at this column width, and the only way
   to fit it was to lay the note over the interface it was pointing at. The
   number is small enough to sit on a screen without covering it. */
.cs-shot .shot-frame {
  position: relative;
  border: 1px solid var(--line); border-radius: var(--radius-media);
  background: var(--bg-raised); overflow: hidden;
}
.cs-shot .shot-img { display: block; width: 100%; height: auto; }
.shot-mark {
  position: absolute; top: var(--y); left: var(--x);
  display: flex; align-items: center; gap: 0; transform: translateY(-50%);
}
.shot-line { width: 26px; height: 1px; background: var(--accent); flex: none; }
.shot-num {
  display: grid; place-items: center; flex: none;
  width: 19px; height: 19px; border-radius: 50%;
  background: var(--accent); color: var(--bg);
  font-size: 11px; font-weight: 700; line-height: 1;
  box-shadow: 0 0 0 2px var(--bg);
}
/* the leader points away from the screen, so the number lands clear of it */
.shot-mark--right { flex-direction: row; }
.shot-mark--left  { flex-direction: row-reverse; transform: translate(-100%, -50%); }
/* Only the dot takes the pointer, never the leader or the marker's box: the
   box is 45px of invisible flex sitting over the interface, and a cursor that
   turns into a hand halfway across a screenshot reads as a bug. Wired by
   script, so the hand only appears where a click does something. */
.cs-shot.is-windowed .shot-mark { pointer-events: none; }
.cs-shot.is-windowed .shot-num {
  pointer-events: auto; cursor: pointer;
  transition: box-shadow var(--dur-fast) var(--ease-emerge);
}
.cs-shot.is-windowed .shot-mark:not(.is-on) .shot-num:hover {
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px color-mix(in srgb, var(--accent) 45%, transparent);
}

.shot-legend {
  list-style: none; padding: 0;
  margin: 0 0 var(--space-sm);
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-sm) var(--space-lg);
}
.shot-legend li { display: grid; grid-template-columns: auto 1fr; gap: var(--space-xs); align-items: start; }
.shot-n {
  display: grid; place-items: center; flex: none;
  width: 19px; height: 19px; border-radius: 50%; margin-top: 1px;
  background: var(--accent); color: var(--bg);
  font-size: 11px; font-weight: 700; line-height: 1;
}
.shot-legend b { display: block; color: var(--ink); font-weight: 600; font-size: var(--text-label); line-height: 1.4; }
.shot-legend span:not(.shot-n) { display: block; font-size: var(--text-label); line-height: 1.5; color: var(--ink-2); margin-top: 2px; }
@media (max-width: 720px) { .shot-legend { grid-template-columns: 1fr; } }

/* ---- the shot as a window ----
   The capture is the assessment screen with its scrolling panel unrolled,
   so the file is 1280x2090: at column width that is a metre and a half of
   page, and under it five sentences the reader reaches by scrolling past
   the picture they describe. Both halves of the problem are the same one -
   everything is shown at once because nothing is selectable.

   So the frame becomes the app's own window, 1280x900, and the numbers come
   out of the picture and stand above it as the control, with the sentence
   they select directly under them. Picking one pans the shot to that marker;
   the marker on the picture is a second way to pick the same thing. The
   section goes from about 1700px to about 750, and the sentence is read on
   the way into the picture rather than after it.

   Script-gated, like the version tabs: with no JS the whole shot and the
   whole legend stay exactly as they were, which is a longer page but never
   a broken one. */
.shot-steps { display: none; }
.cs-shot.is-windowed .shot-steps {
  display: flex; flex-wrap: wrap; gap: var(--space-xs);
  margin-bottom: var(--space-sm);
}
/* A numbered dot, the same one the marker on the picture and the number in
   the legend already are, at a size a thumb can find. */
.shot-step {
  appearance: none; cursor: pointer; padding: 0;
  width: 36px; height: 36px; border-radius: 50%;
  display: grid; place-items: center;
  background: none; border: 1px solid var(--line); color: var(--ink-2);
  font: 700 13px/1 var(--font-body);
  transition: background-color var(--dur-fast) var(--ease-emerge),
              border-color var(--dur-fast) var(--ease-emerge),
              color var(--dur-fast) var(--ease-emerge);
}
.shot-step:hover { border-color: var(--ink-3); color: var(--ink); }
.shot-step[aria-selected="true"] {
  background: var(--accent); border-color: var(--accent); color: var(--bg);
}
.shot-step:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

/* the app's window, not the length of the capture */
.cs-shot.is-windowed .shot-frame { aspect-ratio: 1280 / 900; }
.cs-shot.is-windowed .shot-pan {
  position: absolute; top: 0; left: 0; right: 0;
  transition: transform var(--dur-med) var(--ease-out-expo);
}
/* The other four markers stay drawn - the point of the picture is that all
   five are on one screen - but they stand back so the selected one reads. */
.cs-shot.is-windowed .shot-mark {
  opacity: 0.3;
  transition: opacity var(--dur-fast) var(--ease-emerge);
}
.cs-shot.is-windowed .shot-mark.is-on { opacity: 1; }
/* One sentence, standing between the number that chose it and the picture it
   is about. All five are kept in one grid cell rather than switched with
   display: the block then holds the height of the longest of them, and the
   picture under it does not jump a line up and down as the reader clicks
   along the row. */
.cs-shot.is-windowed .shot-legend { grid-template-columns: 1fr; }
.cs-shot.is-windowed .shot-legend li {
  grid-area: 1 / 1;
  opacity: 0; visibility: hidden;
  transition: opacity var(--dur-fast) var(--ease-emerge);
}
.cs-shot.is-windowed .shot-legend li.is-on { opacity: 1; visibility: visible; }
/* reading size, not caption size: standing above the picture this is the line
   the reader arrives on, the same call the version captions make */
.cs-shot.is-windowed .shot-legend b,
.cs-shot.is-windowed .shot-legend span:not(.shot-n) { font-size: var(--text-body); }
.cs-shot.is-windowed .shot-legend span:not(.shot-n) { line-height: 1.55; }
.cs-shot.is-windowed .shot-n { width: 21px; height: 21px; font-size: 12px; margin-top: 2px; }
.cs-shot.is-windowed .shot-legend li:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 2px; }
@media (prefers-reduced-motion: reduce) {
  .cs-shot.is-windowed .shot-pan, .cs-shot.is-windowed .shot-mark,
  .cs-shot.is-windowed .shot-legend li, .shot-step { transition: none; }
}

/* The versions are whole screens, and a whole screen is taller than the
   reader's patience: at full height four of these was 7000px of scrolling
   before the argument was made. So the still is cropped to the top at the
   cover's own aspect, which is the shape the reader has already learned to
   read on this page. Nothing is scaled down to fit; the crop takes the top
   band at full size and the caption carries what is below it. */
.cs-part-crop {
  display: block;
  aspect-ratio: 1440 / 900;
  overflow: hidden;
  margin-block: var(--space-2xs) var(--space-xs);
  border: 1px solid var(--line); border-radius: var(--radius-media);
  background: var(--bg-raised);
}
.cs-part-shot {
  display: block; width: 100%; height: auto;
}
/* ---- the version tabs ----
   Four whole screens stacked made the reader scroll to compare, which is the
   one comparison a stack cannot serve: no two frames were ever on screen at
   once. The strip puts the versions side by side as labels and holds the crop
   still underneath, so what moves between V1 and V4 is only the capability
   that was added.

   It is the same .fig-tabs strip the business-unit figure higher up the page
   uses, unchanged. Two tab strips on one page that look different are two
   controls the reader has to learn.

   No JS, no tabs: the strip is display:none until the script marks it live,
   the same contract the business-unit strip keeps, and all four panels stay
   open exactly as they did before. That is also why each panel keeps its V
   numeral, hidden only once the strip above is carrying it. */
.cs-versions.is-tabbed .cs-part { gap: 0; }
.cs-versions.is-tabbed .cs-part[hidden] { display: none; }
.cs-versions.is-tabbed .cs-part-crop { margin-top: var(--space-md); }
.cs-versions.is-tabbed .cs-part-head { margin-top: 0; }
.cs-versions.is-tabbed .cs-part-v { display: none; }
.cs-versions .cs-part:focus-visible { outline: 2px solid var(--accent); outline-offset: 6px; border-radius: var(--radius-panel); }

/* ---- a control strip belongs to the picture, not to the prose above it ----
   Two places on a case page put a row of buttons between a block of text and
   the thing those buttons drive: the numbered dots over the annotated shot,
   and the version tabs over the assessment crop. Both sat the same 16px under
   the text above them as they sat from their own content below, so the strip
   read as the last line of that text. Five numbered circles directly under a
   list of decisions is a pager, and a reader who takes it for one never finds
   out that it moves the picture.

   One rhythm, stated once, for both strips:

     32px  above the strip        the only gap here bigger than the rest, so
                                  the strip parts company with the paragraph
     16px  strip to its sentence  the sentence the strip selects
     16px  sentence to picture

   The strip, its sentence and its picture are one object; the text above is
   another. Both are gated on the strip actually being live - with no script
   there is no strip, the block starts with its text, and the tighter binding
   that rule sets is the right one.

   The section is a grid, so sibling margins ADD rather than collapse: each
   gap is set on one side and zeroed on the other, or the two rules quietly
   sum into a third number neither of them says. */
.cs-main .cs-section > .decisions-cat:has(+ figure.cs-shot.is-windowed) { margin-bottom: 0; }
.cs-main .cs-section > .decisions-cat + figure.cs-shot.is-windowed { margin-top: var(--space-xl); }
/* and the step down to the next category has to stay the LARGER gap, or the
   figure sits equidistant between two lists and binds to neither. See the
   proximity pair further up: this is that ratio, re-set for the taller top,
   56 under against 32 over. */
.cs-main .cs-section > figure.cs-shot.is-windowed:has(+ .decisions-cat) { margin-bottom: 0; }
.cs-main .cs-section > figure.cs-shot.is-windowed + .decisions-cat { margin-top: var(--space-3xl); }

/* The versions section is a grid, so its 40px row gap is what stands between
   the lede and the tabs and between the tabs and the crop. Tabbed, only one
   panel is in the flow, so the gap can drop to the 16px the block wants and
   the lede's own trailing 16px makes the 32 above the strip. Stacked (no
   script) the four whole screens still need their 40. */
.cs-versions.is-tabbed { row-gap: var(--space-md); }
.cs-versions.is-tabbed .fig-tabs.is-live { margin-bottom: 0; }

/* The caption is the argument, the screenshot is only its evidence. Under a
   screen-height still the sentence that says what this version added landed
   a scroll away from the tab that was just clicked, so the reader clicked,
   looked, and never got the point. Name and caption come first, directly
   below the strip; the frame follows. DOM order stays picture-then-caption
   so the figcaption still reads as a caption of the image it belongs to. */
.cs-versions .cs-part-head { order: 1; }
.cs-versions .cs-part-cap  { order: 2; }
.cs-versions .cs-part-crop { order: 3; margin-block: var(--space-md) 0; }
/* 13px was below the page's functional floor for a paragraph this long. It
   is a paragraph, so it takes paragraph type: the lede above it, matched. */
.cs-versions .cs-part-cap {
  margin-top: var(--space-2xs);
  font-size: var(--text-body); line-height: 1.6; color: var(--ink-body);
  max-width: 72ch;
}

.cs-parts-lede {
  font-size: var(--text-body); line-height: 1.6; color: var(--ink-body);
  max-width: 62ch; margin-bottom: var(--space-sm);
}
.cs-part-head { display: flex; align-items: baseline; gap: var(--space-sm); }
/* The version number is the label the reader navigates by, so it is the one
   thing here allowed to be a numeral in the display face. */
.cs-part-v {
  font-family: var(--font-display); font-weight: 600; font-style: italic;
  font-size: var(--text-h4); line-height: 1; color: var(--accent);
  letter-spacing: var(--track-md); flex: none;
}
.cs-part-name { font-size: var(--text-body); font-weight: 500; color: var(--ink); }
.cs-parts--steps .cs-part-cap { max-width: 72ch; }
.cs-parts--steps .cs-part-cap b { color: var(--ink); font-weight: 600; }
.cs-part { margin: 0; }
/* One height for all three, so the row reads as one exhibit rather than
   three unrelated boxes. The frames report their own natural height and the
   page takes the tallest (see the sync in the case page); the value here is
   what a scriptless reader gets, sized for the tallest of the three. */
.cs-part-media {
  /* content-box, like .live-frame: the reported height is the COMPONENT's,
     and a hairline counted into it clips two pixels off the tallest crop. */
  box-sizing: content-box;
  height: var(--part-h, 420px);
  overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--radius-media);
  background: var(--bg-raised);
}
.cs-part-media iframe {
  width: 100%; height: 100%; border: 0; display: block;
  pointer-events: none;   /* an artifact on the page, not an app to operate */
}
.cs-part-cap {
  margin-top: var(--space-xs);
  font-size: var(--text-label); line-height: 1.5; color: var(--ink-2);
}
@media (max-width: 720px) { .cs-parts-grid { grid-template-columns: 1fr; } }

/* ============ LAPTOP COVER ============ */
/* The case cover as a machine: a CSS-drawn laptop whose screen is the
   live prototype. No image assets; the whole object is tokens, gradients
   and two rotateX planes sharing a hinge. The lid opens as the page's
   signature moment; the prototype stays inert until the visitor clicks
   "Try it" (the cursor becomes the chip over the glass), so the page
   never loses its scroll to an embedded app nobody asked to operate.
   Esc, clicking outside, or closing the lid hands the page back. */

/* Stage: owns the camera. Product-shot framing: a LONG lens (large
   perspective value) with the screen nearly face-on, so nothing leans
   and the deck compresses to a sliver, the way a press photo of a
   machine is shot. The stage is a size container so the 3D math can
   use real pixel lengths (cqw) instead of percentages of rotated boxes. */
.laptop-stage {
  container-type: inline-size;
  perspective: 3200px;
  perspective-origin: 50% 40%;
  position: relative;
  /* room for the deck sliver + front edge + shadow under the hinge */
  padding-bottom: min(11%, 120px);
}
.laptop {
  /* real lengths, used by every plane below */
  --w: min(100cqw, 1080px);
  --depth: calc(var(--w) * 0.52);  /* deck depth before rotation */
  /* deck angle 86deg: cos = 0.0698 (visible sliver), sin = 0.9976 */
  --deck-cos: 0.0698; --deck-sin: 0.9976;
  position: relative;
  width: var(--w);
  margin-inline: auto;
  transform-style: preserve-3d;
  /* device shell tones, mixed from the page's own inks so both themes
     get a believable aluminum without a second palette */
  --shell: color-mix(in srgb, var(--ink) 7%, var(--bg-raised));
  --shell-deep: color-mix(in srgb, var(--ink) 16%, var(--bg-raised));
  --shell-edge: color-mix(in srgb, var(--ink) 30%, var(--bg-raised));
  --key: color-mix(in srgb, var(--ink) 82%, var(--bg-raised));
}

/* Lid: hinged at its bottom edge. Open faces the viewer dead-on. Closed
   is a slim wedge, the machine ajar rather than shut flat: the lid folds
   to just short of the 86deg deck plane, so from the front you read a
   thin base, a raised lid edge, and a dark sleeping screen. The camera
   never moves; the reveal is purely the lid rising. The negative
   translateZ keeps the folded lid a machine's thickness ABOVE the deck
   (local +z points at the floor once folded past horizontal). */
.laptop-lid {
  position: relative;
  transform-style: preserve-3d;
  transform-origin: 50% 100%;
  transform: rotateX(0deg) translateZ(1px);
  transition: transform var(--dur-slow) var(--ease-out-expo);
}
.laptop.is-closed .laptop-lid { transform: rotateX(-78deg) translateZ(-8px); }

/* Screen face: fixed dark bezel in both themes, like the object it is */
.laptop-screen {
  position: relative;
  backface-visibility: hidden;
  background: linear-gradient(175deg, #17171C, #0B0B0F);
  border: 1px solid var(--shell-edge);
  border-radius: clamp(10px, 1.6vw, 18px) clamp(10px, 1.6vw, 18px) clamp(4px, 0.6vw, 6px) clamp(4px, 0.6vw, 6px);
  padding: clamp(8px, 1.4vw, 16px) clamp(8px, 1.4vw, 16px) clamp(10px, 1.8vw, 20px);
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.06);
}
.laptop-screen::before { /* camera */
  content: ""; position: absolute; top: clamp(3px, 0.55vw, 7px); left: 50%;
  width: 4px; height: 4px; border-radius: 50%;
  transform: translateX(-50%);
  background: #2A2A31; box-shadow: 0 0 0 1px rgba(255,255,255,0.05);
}
.laptop-viewport {
  position: relative; overflow: hidden;
  aspect-ratio: 1440 / 900; /* site.js rewrites this from data-w/data-h */
  border-radius: clamp(4px, 0.7vw, 8px);
  background: #101014;
}
.laptop-viewport iframe {
  width: 1440px; height: 900px; border: 0; display: block;
  transform-origin: top left; pointer-events: none;
  /* the screen wakes as the lid rises: content fades in on the tail
     of the open, and cuts out instantly when the machine folds shut */
  transition: opacity 500ms var(--ease-out-expo) 420ms;
}
/* State lives on the STAGE, not on .laptop: in the WebGL edition the
   CSS3D renderer lifts .laptop-viewport out of the lid into its own layer,
   so .laptop is no longer its ancestor and any .laptop-scoped rule would
   silently stop applying (taking the click gate with it). The stage is an
   ancestor in both editions. site.js mirrors the classes onto it. */
.laptop-stage.is-closed .laptop-viewport iframe { opacity: 0; transition: opacity 200ms var(--ease-out-expo); }
/* The screen in the lid is a DISPLAY, never an app: at lid scale the
   prototype is about half its authored size, too small to operate. Clicking
   it opens try mode instead (see TRY MODE), so this iframe stays inert. */
/* one restrained sheet of glare so the glass reads as glass */
.laptop-viewport::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(115deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.03) 28%, transparent 45%);
  transition: opacity var(--dur-fast) var(--ease-out-expo);
}

/* Lid back: only its raised edge shows while the machine sits ajar */
.laptop-back {
  position: absolute; inset: 0;
  transform: rotateY(180deg);
  backface-visibility: hidden;
  border-radius: clamp(10px, 1.6vw, 18px) clamp(10px, 1.6vw, 18px) clamp(4px, 0.6vw, 6px) clamp(4px, 0.6vw, 6px);
  background: linear-gradient(200deg, var(--shell) 0%, var(--shell-deep) 100%);
  border: 1px solid var(--shell-edge);
}

/* Base: a 3D group pinned at the hinge. Inside it, the deck plane lies
   back at 86deg (visible only as a sliver plus a flash of keyboard while
   the machine opens), and the front face is a REAL vertical plane placed
   at the deck's near edge: y = depth*cos(86), z = depth*sin(86). */
.laptop-base {
  position: absolute; top: 100%; left: 50%;
  width: calc(100% + clamp(8px, 1.2vw, 16px)); /* the base lip */
  height: 0;
  transform: translateX(-50%);
  transform-style: preserve-3d;
}
.laptop-deck {
  position: absolute; top: 0; left: 0; right: 0;
  height: var(--depth);
  transform-origin: 50% 0%;
  transform: rotateX(86deg);
  border-radius: 4px 4px clamp(12px, 2vw, 22px) clamp(12px, 2vw, 22px);
  background: linear-gradient(180deg, var(--shell-deep) 0%, var(--shell) 10%, var(--shell) 100%);
  border: 1px solid var(--shell-edge);
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.35);
}
[data-theme="dark"] .laptop-deck { box-shadow: inset 0 1px 0 rgba(255,255,255,0.08); }
.laptop-keys {
  position: absolute; left: 11%; right: 11%; top: 7%; height: 48%;
  border-radius: 8px;
  background-color: var(--key);
  /* the deck's own colour carves the key gaps out of one dark slab */
  background-image:
    repeating-linear-gradient(0deg, var(--shell) 0 3px, transparent 3px calc((100% - 3px) / 6 + 3px)),
    repeating-linear-gradient(90deg, var(--shell) 0 3px, transparent 3px calc((100% - 3px) / 14 + 3px));
  box-shadow: inset 0 0 0 3px var(--shell);
}
.laptop-trackpad {
  position: absolute; left: 50%; top: 63%; width: 30%; height: 26%;
  transform: translateX(-50%);
  border-radius: 10px;
  border: 1px solid color-mix(in srgb, var(--ink) 20%, transparent);
  background: linear-gradient(180deg, color-mix(in srgb, var(--ink) 3%, var(--bg-raised)), transparent);
}
/* The machine's thickness, facing the camera */
.laptop-front {
  position: absolute; top: 0; left: 0; right: 0;
  height: clamp(9px, 1.2cqw, 14px);
  transform: translate3d(0, calc(var(--depth) * var(--deck-cos) - 1px), calc(var(--depth) * var(--deck-sin)));
  border-radius: 2px 2px clamp(8px, 1.1cqw, 12px) clamp(8px, 1.1cqw, 12px);
  background: linear-gradient(180deg, var(--shell) 0%, var(--shell-deep) 70%, var(--shell-edge) 100%);
  border: 1px solid var(--shell-edge); border-top: 0;
}
.laptop-front::before { /* thumb scoop */
  content: ""; position: absolute; left: 50%; top: 0;
  width: 11%; height: 55%;
  transform: translateX(-50%);
  border-radius: 0 0 999px 999px;
  background: color-mix(in srgb, var(--ink) 30%, transparent);
}

/* Ground shadow: 2D, pooled under the front edge. Softens while the
   machine sits ajar. */
.laptop-shadow {
  position: absolute; left: 50%; bottom: clamp(-18px, -1.8vw, -8px);
  width: 96%; height: clamp(14px, 2.4vw, 26px);
  transform: translateX(-50%);
  border-radius: 50%;
  background: radial-gradient(50% 50% at 50% 50%, rgba(19,18,23,0.30), transparent 70%);
  filter: blur(9px);
  transition: opacity var(--dur-slow) var(--ease-out-expo), transform var(--dur-slow) var(--ease-out-expo);
}
.laptop.is-closed .laptop-shadow { opacity: 0.55; transform: translateX(-50%) scaleX(0.85); }

/* The Try-it gate: an honest button over the glass while the prototype
   is inert. Fine pointers get the cursor chip instead of a visible
   control; coarse pointers and reduced motion get the pill itself. */
.laptop-hit {
  position: absolute; inset: 0; z-index: 2;
  display: grid; place-items: center;
  background: none; border: 0; padding: 0; margin: 0;
  border-radius: inherit; cursor: none;
  -webkit-appearance: none; appearance: none;
}
.laptop-hit .laptop-try {
  font: 500 var(--text-body)/1 var(--font-body);
  color: var(--bg); background: var(--ink);
  padding: 10px 18px; border-radius: var(--radius-pill);
  box-shadow: var(--shadow-chip);
  opacity: 0; transition: opacity var(--dur-fast) var(--ease-out-expo);
  display: inline-flex; align-items: center; gap: var(--space-xs);
}
/* the click affordance: a cursor glyph in the house stroke, carried as a
   mask so it takes the label colour in both themes */
.laptop-try::before {
  content: ""; width: var(--icon-size); height: var(--icon-size); flex: none;
  background: currentColor;
  -webkit-mask: var(--cursor-click-svg) center / contain no-repeat;
  mask: var(--cursor-click-svg) center / contain no-repeat;
}
/* show the pill where a trailing chip cannot exist */
@media (hover: none) { .laptop-hit { cursor: pointer; } .laptop-hit .laptop-try { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .laptop-hit { cursor: pointer; } .laptop-hit .laptop-try { opacity: 1; } }
.laptop-hit:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.laptop-hit:focus-visible .laptop-try { opacity: 1; }

/* The cursor chip covers the inert screen; see CURSOR CHIP above. */

/* ---- the case cover opens the prototype ----
   The screen is the control, and now it says so standing still. site.js
   lays this button over the running prototype: the whole frame is the hit
   area, and a real "Try prototype" button sits at the centre of the glass.

   Two things had to change before it read as a control. It used to be
   announced by the cursor chip alone, which meant the deepest interaction
   on the site was declared only to people who had already found it. Then
   it stood at the bottom edge in the app's own ink, which is exactly where
   a phone draws its tab bar: a button parked in an interface's chrome
   position gets read as that interface's chrome and skipped.

   So it is centred, where the eye already is, and it stands on a veil.
   The veil is the whole trick — it puts the button on a layer ABOVE the
   screenshot instead of inside it, which is what the play button on every
   video the visitor has ever seen does. It lifts on hover, so the moment
   the pointer arrives the app is unobstructed again. */
.cover-hit {
  position: absolute; inset: 0; z-index: 2;
  display: grid; place-items: center;
  background: color-mix(in srgb, var(--invert-bg) 14%, transparent);
  border: 0; padding: 0; margin: 0;
  border-radius: inherit; cursor: pointer;
  -webkit-appearance: none; appearance: none;
  transition: background-color var(--dur-fast) var(--ease-out-expo);
}
/* the pointer is here, so the announcement has done its job: hand the
   screen back and let the visitor see what they are about to open */
.cover-hit:hover, .cover-hit:focus-visible { background: transparent; }
.cover-hit .cover-try {
  display: inline-flex; align-items: center; gap: var(--space-xs);
  font: 600 var(--text-body)/1 var(--font-body); letter-spacing: -0.01em;
  color: var(--bg); background: var(--ink);
  padding: 15px 24px; border-radius: var(--radius-pill);
  /* --shadow-rest-sm, not --shadow-chip: this one is not riding inside a
     bar, it is standing off a photograph and needs the volume to say so */
  box-shadow: var(--shadow-rest-sm);
  /* colour rides with the fill: on a theme flip the ink and the label swap
     grounds at once, and a label that snapped while its fill lerped spent
     300ms invisible on top of a screenshot */
  transition: background-color var(--dur-fast) var(--ease-out-expo),
              color var(--dur-fast) var(--ease-out-expo),
              box-shadow var(--dur-fast) var(--ease-out-expo),
              transform var(--dur-fast) var(--ease-out-expo);
}
/* the same click glyph the chip carried, masked so it takes the label colour */
.cover-try::before {
  content: ""; width: var(--icon-size); height: var(--icon-size); flex: none;
  background: currentColor;
  -webkit-mask: var(--cursor-click-svg) center / contain no-repeat;
  mask: var(--cursor-click-svg) center / contain no-repeat;
}
/* the frame is the button, so the pointer anywhere on the glass moves it */
.cover-hit:hover .cover-try, .cover-hit:focus-visible .cover-try {
  background: color-mix(in srgb, var(--ink) 86%, var(--bg));
  box-shadow: var(--shadow-rest);
  transform: translateY(-2px);
}
.cover-hit:active .cover-try {
  transform: translateY(0); box-shadow: var(--shadow-chip-press);
}
@media (prefers-reduced-motion: reduce) {
  .cover-hit, .cover-hit .cover-try { transition: none; }
}
.cover-hit:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }

/* ============ TRY MODE ============ */
/* The laptop is the cover; this is the workshop. The prototypes are authored
   at 1440px and a lid renders them at roughly half that, which is fine to
   LOOK at and impossible to OPERATE. So "Try it" lifts the app out of the
   machine and runs it as large as the viewport allows, over a dimmed page.
   Esc, the close control, or a click on the scrim puts it back. */
.try-overlay {
  position: fixed; inset: 0; z-index: 10000;
  display: grid; grid-template-rows: auto 1fr; justify-items: center;
  padding: var(--space-md);
  background: color-mix(in srgb, var(--invert-bg) 86%, transparent);
  opacity: 0; pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease-out-expo);
}
@supports (backdrop-filter: blur(1px)) {
  .try-overlay { backdrop-filter: blur(8px) saturate(120%); }
}
.try-overlay.is-open { opacity: 1; pointer-events: auto; }
/* ---- the exit bar ----
   Names what you are inside and how to get out. It used to be a flat
   white pill with a solid black button on it: correct, and belonging to
   nothing. It now takes the same recipe the app bar and the dock share,
   so the three floating controls on this site read as one family: a
   translucent surface over a blur, an 8% hairline, and --shadow-float.

   Three things carry the craft, and none of them is decoration:
   a live dot, because the thing behind the scrim is RUNNING and that is
   the one fact the bar is not otherwise stating; a hairline divider, so
   the name and the instruction stop being two phrases in a row; and a
   glyph on the close button, which is the only element here that acts. */
.try-bar {
  display: flex; align-items: center; gap: var(--space-sm);
  padding: 6px 6px 6px var(--space-md);
  margin-bottom: var(--space-md);
  border-radius: var(--radius-pill);
  background: color-mix(in srgb, var(--bg-raised) 82%, transparent);
  border: 1px solid color-mix(in srgb, var(--ink) 8%, transparent);
  box-shadow: var(--shadow-float);
}
@supports (backdrop-filter: blur(1px)) {
  .try-bar { backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); }
}
[data-theme="dark"] .try-bar { border-color: rgba(255,255,255,0.10); }

/* 600, not 500: it is the subject of the bar and everything else in here
   is either an instruction or a control. */
.try-bar-name {
  display: inline-flex; align-items: center; gap: var(--space-xs);
  font-weight: 600; color: var(--ink); white-space: nowrap;
}
/* The availability dot, reused verbatim including its pulse: the hero
   uses it for "this is live" and so does this. */
.try-bar-name::before {
  content: ""; flex: none;
  width: 7px; height: 7px; border-radius: 50%;
  background: var(--accent);
  animation: pulse 2.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) { .try-bar-name::before { animation: none; } }

/* --rule, the hairline every divider on this site shares, not --line: a
   separator inside a control should be the lightest tier, or it reads as
   two joined objects rather than one bar with a beat in it. */
/* Caption tier, not body. At --text-small this ran at the same size and
   weight as the product name next to it, so the bar read as two titles
   with a button after them instead of a name, an instruction and a
   control. --text-label is the site's caption size and is legal here
   because the key beside it carries the meaning. */
.try-bar-hint {
  display: inline-flex; align-items: center; gap: var(--space-xs);
  font-size: var(--text-label); color: var(--ink-3); white-space: nowrap;
  /* the element carries .label, which is 600. Only the key should be
     weighted; the caption beside it is the quietest thing in the bar. */
  font-weight: 400;
}
/* An actual key. Small caps-height chip on the raised surface with a
   1px bottom edge doing the job the moulding does on a real one: it is
   the difference between naming a key and drawing one. */
.try-key {
  display: inline-grid; place-items: center;
  min-width: 22px; height: 20px; padding: 0 6px;
  font-family: var(--font-body); font-size: var(--text-label);
  font-weight: 600; line-height: 1; color: var(--ink-2);
  background: var(--bg-raised);
  border: 1px solid var(--line);
  border-bottom-width: 2px;
  border-radius: 5px;
}
[data-theme="dark"] .try-key { border-color: rgba(255,255,255,0.16); }
.try-bar-hint::before {
  content: ""; flex: none; width: 1px; height: 18px; background: var(--rule);
}

/* Same 40px height as the dock icons and the pager buttons, so the one
   control in the bar matches every other small control on the site. */
.try-close {
  display: inline-flex; align-items: center; gap: var(--space-xs);
  font: 500 var(--text-small)/1 var(--font-body);
  color: var(--bg); background: var(--ink);
  border: 0; height: 40px; padding: 0 var(--space-sm) 0 var(--space-md);
  border-radius: var(--radius-pill);
  cursor: pointer;
  transition: background-color var(--dur-fast) var(--ease-out-expo);
}
/* Drawn, not typed: site.js sets this button's textContent, and a child
   element would be wiped on the next open. */
.try-close::after {
  content: ""; flex: none; width: 14px; height: 14px;
  background: currentColor;
  -webkit-mask: var(--try-close-svg) center / contain no-repeat;
  mask: var(--try-close-svg) center / contain no-repeat;
}
:root {
  --try-close-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M6 6l12 12M18 6 6 18'/%3E%3C/svg%3E");
}
.try-close:hover { background: color-mix(in srgb, var(--ink) 86%, var(--bg)); }
.try-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .try-close { transition: none; } }

/* On a phone the bar is the width of the screen and the name is the first
   thing to give: the instruction and the button matter more than knowing
   the product's name while you are looking at it. */
@media (max-width: 520px) {
  .try-bar { gap: var(--space-xs); padding-left: var(--space-sm); }
  .try-bar-name { display: none; }
  .try-bar-hint::before { display: none; }
}

/* The app itself: sized in JS to the largest whole multiple the viewport
   allows, never past 1:1 (upscaling a UI only blurs it). */
.try-stage {
  position: relative; overflow: hidden; align-self: start;
  border-radius: var(--radius-panel);
  background: var(--bg-raised); box-shadow: var(--shadow-rest);
}
.try-stage iframe { border: 0; display: block; transform-origin: top left; }
/* ---- the cursor dot and the prototype ----
   The prototype runs in an iframe, which is a separate document. Once the
   pointer crosses into it, pointermove stops reaching this page, so the
   dot freezes wherever it last was and sits there as a dead mark on the
   screen for as long as the overlay is up - which is exactly what you see
   after clicking a cover.
   It cannot follow the pointer into another document, so it stands down
   while the overlay is open and the real cursor does the job unaided. */
body:has(.try-overlay.is-open) .cursor-dot { display: none; }
@media (prefers-reduced-motion: reduce) { .try-overlay { transition: none; } }


/* Lid toggle rides under the machine; the two labels swap by state so
   the language toggle can translate both spans in place */
.laptop-controls {
  display: flex; align-items: center; justify-content: center;
  gap: var(--space-md); margin-top: var(--space-lg);
}
.laptop-toggle .laptop-toggle-open { display: none; }
.laptop-toggle.is-closed .laptop-toggle-open { display: inline; }
.laptop-toggle.is-closed .laptop-toggle-close { display: none; }

@media (prefers-reduced-motion: reduce) {
  .laptop-lid, .laptop-shadow, .laptop-viewport iframe { transition: none; }
}

/* WebGL edition (laptop3d.js). The CSS planes stay in the layout so the
   stage keeps its height, but go invisible; the canvas + CSS3D layers
   paint the machine instead. The ground shadow is shared by both. */
.laptop-stage.is-3d .laptop-lid,
.laptop-stage.is-3d .laptop-base,
/* the CSS ground shadow would hang off a now-collapsed .laptop; the WebGL
   machine casts its own */
.laptop-stage.is-3d .laptop-shadow { visibility: hidden; transition: none; }
.laptop-stage.is-3d .laptop-lid { transform: none !important; }
.laptop-stage.is-3d .laptop-viewport { visibility: visible; }

/* ============ REVEALS ============ */
/* One observer, several entrances. .reveal is the trigger: site.js adds
   .is-visible once at 15% visibility and the element plays its transition.
   What that transition IS depends on what the element is. The hero set the
   grammar and the scroll reveals speak it:

     text     pulls into focus where it stands — blur resolving, no travel
     objects  are set down onto the page — a rise with a scale settling to 1
     groups   hand the entrance to their members, one at a time

   Every entrance animates the independent properties (opacity / translate /
   scale / filter), never `transform`, so hover transforms compose with a
   half-finished entrance instead of being outranked by it.

   --lag staggers siblings that arrive together: a unitless index per element,
   spent as time here (x90ms) and as scroll distance in the scroll-linked
   block (x70px). One number drives both clocks. */
.reveal { opacity: 0; translate: 0 24px;
  transition: opacity var(--dur-med) var(--ease-out-expo), translate var(--dur-med) var(--ease-out-expo); }
.reveal.is-visible { opacity: 1; translate: none; }

/* ---- running text: fades in place ----
   No blur here. The defocus belongs to the hero's on-load moment only: on
   scroll it lingers mid-resolve while the visitor is already reading, which
   reads as a rendering bug, not an entrance. Text doesn't travel either — it
   simply arrives where it stands. */
.reveal.section-title, .reveal.section-intro,
.footer-line.reveal, .footer-sub.reveal, .footer-email.reveal {
  translate: none;
  /* --ease-out-expo, not --ease-emerge: expo is past the translucent middle
     almost immediately, so text is legible the moment it starts arriving.
     The lingering curve belongs to the hero's blur, nowhere else. */
  transition: opacity var(--dur-med) var(--ease-out-expo);
}

/* ---- section pills: a chip set down ----
   Slightly undersized and rising into place, growing from its bottom-left
   corner so it stays anchored to the heading it labels. */
/* Rise and fade only — no scale anywhere in the scroll entrances: scaled
   text rasterizes soft while it settles, which reads as the page being out
   of focus rather than as motion. Elements arrive at 100% size, always. */
.section-pill.reveal {
  translate: 0 10px;
  transition: opacity var(--dur-med) var(--ease-out-expo),
              translate var(--dur-med) var(--ease-out-expo);
}
.section-pill.reveal.is-visible { translate: none; }

/* ---- case rows: media settles, copy focuses ----
   The row is a container; animating it as one slab is exactly the blob this
   system replaces. The cover panel is the object, so it settles like one, and
   the copy is text, so it sharpens in place a beat later. The media-note is
   part of the copy voice and travels with it. */
.project.reveal { opacity: 1; translate: none; transition: none; }
.project.reveal .project-media {
  opacity: 0; translate: 0 28px;
  transition: opacity var(--dur-fast) var(--ease-out-expo),
              translate var(--dur-med) var(--ease-out-expo);
}
/* The copy snaps legible in 300ms with no delay: any longer and the
   translucent mid-fade reads as the text being out of focus. The motion
   (the media's rise) can take its time; legibility cannot. */
.project.reveal :is(.project-body, .media-note) {
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease-out-expo);
}
.project.reveal.is-visible .project-media { opacity: 1; translate: none; }
.project.reveal.is-visible :is(.project-body, .media-note) { opacity: 1; }

/* ---- cards: objects set down one at a time ----
   The transition union restates .mini-card's own border-color/transform pair:
   a transition list replaces, never merges, so dropping them here would kill
   the hover zoom. The delay list is per-property — the stagger belongs to the
   entrance properties only, and a lagged border-color would make the glow
   ring feel broken. */
.mini-card.reveal {
  translate: 0 26px;
  transition: border-color var(--dur-fast), transform 700ms var(--ease-out-expo),
              opacity var(--dur-med) var(--ease-out-expo),
              translate var(--dur-med) var(--ease-out-expo);
  transition-delay: 0s, 0s, calc(var(--lag, 0) * 90ms), calc(var(--lag, 0) * 90ms);
}
.mini-card.reveal.is-visible { translate: none; }
/* Reading order per grid ROW, not per grid: the index restarts at each row's
   left edge, because a stagger that keeps counting across rows turns into a
   long queue the visitor is asked to wait out. Generic three-column grids
   (next-case rows) count 0/1/2; the studio's 4+2 / 2+2+2 / 6 layout gets its
   own count where the generic one lands wrong. */
.card-grid > :nth-child(3n+2) { --lag: 1; }
.card-grid > :nth-child(3n)   { --lag: 2; }
#studio .card-grid > :nth-child(3) { --lag: 0; }
#studio .card-grid > :nth-child(4) { --lag: 1; }
#studio .card-grid > :nth-child(5) { --lag: 2; }
#studio .card-grid > :nth-child(6) { --lag: 0; }

/* ---- systems row: cards walk in left to right ---- */
.ds-row.reveal { opacity: 1; translate: none; transition: none; }
.ds-row.reveal .ds-card {
  opacity: 0; translate: 0 22px;
  transition: opacity var(--dur-slow) var(--ease-out-expo) calc(var(--lag, 0) * 90ms),
              translate var(--dur-slow) var(--ease-out-expo) calc(var(--lag, 0) * 90ms);
}
.ds-row.reveal.is-visible .ds-card { opacity: 1; translate: none; }
.ds-row .ds-card:nth-child(2) { --lag: 1; }
.ds-row .ds-card:nth-child(3) { --lag: 2; }
.ds-row .ds-card:nth-child(4) { --lag: 3; }

/* ---- quotes: the rail triggers, the cards walk in left to right ----
   The rail, not each card: a card scrolled past the right edge of the
   scrollport never intersects the viewport, so per-card observers would
   leave cards four and five permanently invisible. */
.quote-row.reveal { opacity: 1; translate: none; transition: none; }
.quote-row.reveal .quote-card {
  opacity: 0; translate: 0 14px;
  transition: opacity var(--dur-med) var(--ease-out-expo) calc(var(--lag, 0) * 90ms),
              translate var(--dur-med) var(--ease-out-expo) calc(var(--lag, 0) * 90ms);
}
.quote-row.reveal.is-visible .quote-card { opacity: 1; translate: none; }
.quote-row .quote-card:nth-child(2) { --lag: 1; }
.quote-row .quote-card:nth-child(3) { --lag: 2; }
.quote-row .quote-card:nth-child(4) { --lag: 3; }
.quote-row .quote-card:nth-child(5) { --lag: 4; }

/* ---- row lists: the wrapper triggers, the rows arrive in order ----
   Marked in the HTML with .reveal--rows (the experience list), because a bare
   wrapper div gives the selector nothing to infer from. */
.reveal--rows { opacity: 1; translate: none; transition: none; }
.reveal--rows .xp-row {
  opacity: 0; translate: 0 14px;
  transition: opacity var(--dur-med) var(--ease-out-expo) calc(var(--lag, 0) * 90ms),
              translate var(--dur-med) var(--ease-out-expo) calc(var(--lag, 0) * 90ms);
}
.reveal--rows.is-visible .xp-row { opacity: 1; translate: none; }
.reveal--rows .xp-row:nth-child(2) { --lag: 1; }
.reveal--rows .xp-row:nth-child(3) { --lag: 2; }

/* ---- hero entrance choreography ----------------------------------------
   The hero is above the fold, so it plays on load rather than on scroll, and
   everything in it comes out of the same defocus. Only the timing separates
   the parts: the headline pulls into focus first and carries a slight scale,
   the copy follows at half the blur and no scale so it settles under the
   headline instead of competing with it.

   The order is the reading order with one deliberate exception. The status
   line sits above the headline in the markup, but arrives after it, because
   the headline is what the visitor came for and it should land in an empty
   field rather than into a page that is already filling up.

   Every delay here is stated rather than derived, so the whole sequence can be
   read as a timeline in one place:
     0 / 120ms   headline lines
     340ms       status line
     440-620ms   the three points, 90ms apart
     740ms+      the stack, in index.html, which continues this count. */
.lab-copy .hero-sub { animation: none; }   /* the list is a container; its items animate */
.lab-copy .hero-status,
.lab-copy .hero-points li {
  animation: emerge-soft var(--dur-med) var(--ease-emerge) backwards;
}
.lab-copy .hero-status                 { animation-delay: 340ms; }
.lab-copy .hero-points li:nth-child(1) { animation-delay: 440ms; }
.lab-copy .hero-points li:nth-child(2) { animation-delay: 530ms; }
.lab-copy .hero-points li:nth-child(3) { animation-delay: 620ms; }

@media (prefers-reduced-motion: reduce) {
  /* Killing the animation outright is what keeps a `backwards` fill from
     holding the blurred first frame on screen. */
  .line-inner, .hero-status, .hero-sub, .hero-ctas,
  .lab-copy .hero-points li { animation: none; }
}

/* ---- scroll-linked reveals ---------------------------------------------
   The observer above is a trigger: it fires once at 15% visibility and the
   element then plays on its own clock, so the entrance is unrelated to how
   you are actually scrolling. Where the browser supports view timelines the
   same two states become a function of scroll position instead, which is
   what reads as "smooth" on the sites this was borrowed from. It composes
   with the damped scroll in site.js: both run off the real scroll offset.

   Progressive enhancement, not a rewrite. The observer keeps running and
   keeps adding .is-visible; an animation outranks those declarations in the
   cascade, so it simply stops being the thing you see. Nothing to remove if
   this block is deleted.

   Ranges are fixed pixel distances, NOT percentages. Percentages of a named
   range scale with the element, and `cover` is element height plus viewport
   height, so a full-height section like .project would still be half faded
   once it owned the screen. A length means every element, a 40px heading or
   a 900px case block, travels over the same amount of scrolling.

   Both stops matter. `cover 0` is the instant the element's top edge touches
   the BOTTOM of the viewport, so a range starting there finishes while the
   element is still a sliver at the bottom of the screen and the entrance is
   over before you look at it. The 180px offset holds the start until the
   element is properly on screen; the motion then lands around the middle,
   where it can actually be read. Move both stops together to reposition it.

   No animation-duration: on a progress-based timeline it must stay `auto`,
   and the shorthand would reset it to 0s. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    /* base state is the finished one: if the animation never attaches, the
       page is readable rather than invisible */
    .reveal {
      opacity: 1; transform: none; transition: none;
      animation-name: reveal-rise;
      animation-fill-mode: both;
      animation-timing-function: linear;   /* the scroll is the easing */
      animation-timeline: view();
      /* Ends at 300px: the entrance is DONE while the element is still in
         the bottom third of the screen, before the eye reaches it. The old
         480px end meant content was still translucent at mid-viewport,
         exactly where people read, which registered as a focus problem. */
      animation-range: cover 60px cover 300px;
    }
    /* `translate`, not `transform`: the independent property composes with
       .card-grid .mini-card:hover's scale, which an animation on `transform`
       would outrank and freeze. */
    @keyframes reveal-rise {
      from { opacity: 0; translate: 0 24px; }
      to   { opacity: 1; translate: none; }
    }

    /* Component dialects on the scroll clock — but only where a view()
       timeline is actually trustworthy. Two hard lessons live here:

       1. Ranges must finish inside a SHORT viewport. An entrance that ends at
          620px of travel never completes on a 700px window before the element
          is being read, and the visitor sees a page stuck mid-reveal. Nothing
          may end past ~480px.
       2. view() resolves against the nearest SCROLL CONTAINER, not the page.
          Inside the ds row (overflow-x) or any sticky box, progress freezes
          at some arbitrary value forever. Those entrances are driven by the
          observer instead — time-based transitions cannot freeze — so their
          containers stand down here and their child rules above stay in
          charge on every browser. */

    /* running text: fades in place, no travel */
    .reveal.section-title, .reveal.section-intro,
    .footer-line.reveal, .footer-sub.reveal, .footer-email.reveal {
      animation-name: text-fade-linked;
    }
    @keyframes text-fade-linked {
      from { opacity: 0; }
      to   { opacity: 1; }
    }

    /* pills: set down */
    .section-pill.reveal { animation-name: pill-set-linked; }
    @keyframes pill-set-linked {
      from { opacity: 0; translate: 0 10px; }
      to   { opacity: 1; translate: none; }
    }

    /* case rows / systems row / experience rows: containers stand down and
       the observer path's child transitions (media settle, copy fade, card
       walk, row order) run on every browser. */
    .project.reveal, .ds-row.reveal, .reveal--rows { animation-name: none; opacity: 1; }

    /* studio cards: set down one at a time — a plain grid, so the page is the
       scroller and the ranges are safe. The --lag index spends as 50px of
       scroll per step; every range still ends well inside a short viewport. */
    .mini-card.reveal {
      animation-name: card-set-linked;
      animation-range: cover calc(60px + var(--lag, 0) * 50px)
                       cover calc(300px + var(--lag, 0) * 50px);
    }
    /* No scale: a card that arrives at 97% size renders its text soft until
       the settle finishes, and a one-second soft card reads as a focus
       problem, not an entrance. Rise and fade only, full size always. */
    @keyframes card-set-linked {
      from { opacity: 0; translate: 0 26px; }
      to   { opacity: 1; translate: none; }
    }

    /* sticky guard: anything revealed inside a sticky box would freeze
       mid-entrance the moment it pins, so it doesn't scroll-link at all */
    .hiw__aside .reveal { animation-name: none; opacity: 1; translate: none; scale: none; }

    /* Case-study pages are reading documents, not a stage: their blocks keep
       the one-shot observer fade (fires once, done in 700ms) and never track
       the scrollbar. The base states are restated because the block's own
       .reveal rule above hands everything to the scroll animation (opacity 1,
       transition none), which would otherwise leave these with no entrance at
       all. The is-visible pair must live here too — this rule is later in the
       cascade than the global one and would hold opacity at 0 without it. */
    .cs-main .reveal {
      animation-name: none;
      opacity: 0; translate: 0 24px;
      transition: opacity var(--dur-med) var(--ease-out-expo),
                  translate var(--dur-med) var(--ease-out-expo);
    }
    .cs-main .reveal.is-visible { opacity: 1; translate: none; }

    /* The footer is the last thing in the document, so a `cover` range can
       run out of scroll before it completes and strand the sign-off at half
       opacity. `entry` ends when the element's own bottom edge reaches the
       viewport bottom, which is always reachable at max scroll. Last in the
       block on purpose: it shares specificity with the footer text rule
       above, and order is what lets the range win. */
    .footer .reveal { animation-range: entry 0% entry 100%; }
  }
}

/* ============ RESPONSIVE ============ */
@media (max-width: 960px) {
  .card-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 860px) {
  .project { grid-template-columns: 1fr; gap: var(--space-lg); }
  .project-media { order: -1; margin-bottom: var(--space-md); }
  .tldr { grid-template-columns: repeat(2, 1fr); }
  /* two in the rail instead of three, and the peek comes off: below this
     width a 56px sliver is a bigger fraction of the card than it is a hint */
  .quote-row { --q-peek: 0px; }
  .quote-card { flex-basis: calc((100% - var(--space-md)) / 2); }
}
@media (max-width: 720px) {
  .hiw-row { grid-template-columns: 1fr; }
  /* No room for company, role and dates on one line: the head becomes a
     two-row grid instead - company and caret up top, role and dates stacked
     beneath on the company name's left edge - so the longest German role
     strings get the whole width rather than wrapping ragged beside a date. */
  /* button.xp-head, not .xp-head: the base rule is element+class, so a bare
     class here would lose the display swap to it */
  button.xp-head {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas: "co caret" "meta meta";
    row-gap: var(--space-2xs);
  }
  .xp-co { grid-area: co; }
  .xp-caret { grid-area: caret; align-self: center; }
  .xp-meta {
    grid-area: meta; margin-left: 0;
    flex-direction: column; align-items: flex-start; gap: 2px;
    padding-left: calc(22px + var(--space-xs));
  }
  /* one card in view, so equal heights buy nothing and cost a dead half
     screen under the shortest quote */
  .quote-row { align-items: flex-start; }
  .quote-card { flex-basis: 100%; }
  .nav-links { --nav-gap: var(--space-lg); }
  .card-grid { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
  .nav-hide-sm { display: none; }   /* secondary nav links yield on small screens */
  /* Phone budget. At 375px the bar carries the name plus three controls, and
     the desktop rhythm (16px gaps, 24px side margins) asked for 355px of a
     327px pill. The gaps tighten, the pill takes the page's own margin rather
     than a wider one, and the burger loses the padding it only had to match a
     text link's box. The three controls are each their own shape, so 8px is
     enough to keep them apart. */
  .nav-links { --nav-gap: var(--space-xs); }
  .nav { gap: var(--space-sm); max-width: calc(100vw - var(--space-md) * 2); }
  .nav .nav-burger { width: 36px; }
  .nav .lang-toggle { padding: 0 var(--space-xs) 0 6px; }
  .metrics > div { grid-template-columns: 1fr; row-gap: var(--space-2xs); }
}
/* Touch targets ≥44px on touch/small screens (nav + footer links, theme toggle,
   related-project pills). Padding only — no visual size change to text. */
@media (pointer: coarse), (max-width: 640px) {
  .nav-link { padding-block: var(--space-sm); } /* 12px: 25.6px text + 24px = 49.6px ≥44 target, on the 4pt grid */
  .theme-toggle, .lang-toggle {
    min-width: 44px; min-height: 44px;
    display: inline-flex; align-items: center; justify-content: center;
  }
  .related-row .pill { padding-block: var(--space-sm); } /* 12px: 24px text + 24px + 2px border = 50px ≥44 target, on the 4pt grid */
  .footer-email, .cs-next-link { padding-block: 4px; }
  .footer-email::after { bottom: 6px; } /* keep hover underline hugging the text above the added padding */
}
@media (prefers-reduced-motion: reduce) {
  *,*::before,*::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  html { scroll-behavior: auto; }
}

/* ============ PHONE-TIER CTAS ============
   Phones only: primary CTAs run the full column width and hero CTAs stack.
   Tablets (>620px) keep the inline desktop posture. */
@media (max-width: 620px) {
  /* Full width, but the auto-layout stands: label pinned left, disc pinned
     right. Centring grouped the two in the middle of a stretched button and
     stranded the rest of the width either side of them. */
  .btn { width: 100%; }
  .hero-ctas { flex-direction: column; }
}

/* ============ TERM MODALS (deep dives behind key phrases) ============
   Any phrase can open a shared panel: mark it up as
   <button class="term" data-modal="ai-workflow">phrase</button>
   and put its content in <template data-modal-content="ai-workflow">.
   site.js builds the dialog shell once per page. */
.term {
  font: inherit; color: inherit; background: none; border: 0; padding: 0;
  cursor: pointer; text-align: left;
  /* quiet affordance: a 2px grey rule under the phrase that inks to accent on
     hover. Solid, not dotted: this opens a panel, it is not a link. */
  text-decoration: underline; text-decoration-thickness: 2px;
  text-decoration-color: var(--line-strong);
  text-underline-offset: 4px;
  transition: text-decoration-color var(--dur-med) var(--ease-out-expo);
}
/* only the rule inks on hover; the phrase stays in its surrounding voice */
.term:hover, .term:focus-visible { text-decoration-color: var(--accent); }

.modal {
  /* margin:auto restores the browser's dialog centering, which the global
     `* { margin: 0 }` reset removes. Without it the panel hugs the left edge. */
  margin: auto;
  /* Desktop and wide: a centred panel with generous air on both sides.
     Tablet tightens the gutter; phones go full-bleed (see breakpoints below). */
  width: min(1040px, calc(100vw - var(--space-3xl) * 2));
  max-height: min(84vh, 900px);
  padding: 0; border: 0; border-radius: var(--radius-panel);
  background: var(--bg-raised); color: var(--ink-body);
  box-shadow: 0 32px 80px -24px rgba(19,18,23,0.30);
  opacity: 0; transform: translateY(8px) scale(0.99);
  transition: opacity var(--dur-fast) var(--ease-out-expo), transform var(--dur-fast) var(--ease-out-expo);
}
.modal.is-open { opacity: 1; transform: none; }
.modal::backdrop { background: rgba(19,18,23,0.45); opacity: 0; transition: opacity var(--dur-fast) var(--ease-out-expo); }
.modal.is-open::backdrop { opacity: 1; }
[data-theme="dark"] .modal { border: 1px solid var(--line); }
[data-theme="dark"] .modal::backdrop { background: rgba(0,0,0,0.6); }
.modal-panel {
  position: relative; overflow-y: auto; max-height: inherit;
  padding: var(--space-2xl);
  /* a blank panel still reads as a panel while content is being written */
  min-height: 320px;
}
.modal-close { position: absolute; top: var(--space-md); right: var(--space-md); }
/* tablet: the same panel with a tighter side gutter */
@media (max-width: 1000px) {
  .modal { width: calc(100vw - var(--space-xl) * 2); }
}
/* phones: full-bleed takeover, no side gutter and no corners to break */
@media (max-width: 620px) {
  .modal {
    /* stretch to the inset box rather than sizing by viewport units, which
       leave hairline slivers once the UA's own dialog metrics are involved */
    inset: 0; margin: 0;
    width: auto; height: auto; max-width: none; max-height: none;
    border-radius: 0;
  }
  .modal-panel { padding: var(--space-xl) var(--space-lg); min-height: 100%; }
}
@media (prefers-reduced-motion: reduce) {
  .modal, .modal::backdrop { transition: opacity 0.01ms; transform: none; }
}

/* ---- flow: a sequence told in type, no diagram ----
   Steps hang off a hairline with an accent node each, so the chain reads as
   a direction of travel while staying pure text. */
.flow { list-style: none; padding: 0; margin: 0; }
.flow li { position: relative; padding-left: var(--space-lg); padding-bottom: var(--space-lg); }
.flow li:last-child { padding-bottom: 0; }
.flow li::before {
  content: ""; position: absolute; left: 4px; top: 14px; bottom: 0; width: 1px;
  background: var(--line);
}
.flow li:last-child::before { display: none; }
.flow li::after {
  content: ""; position: absolute; left: 0; top: 7px;
  width: 9px; height: 9px; border-radius: 50%; background: var(--accent);
}
.flow-role { display: block; margin-bottom: var(--space-2xs); }
.flow p { color: var(--ink-body); margin: 0; }
.flow strong { color: var(--ink); font-weight: 600; }
/* Panel rhythm: kicker sits tight to the heading it introduces, the lede
   tight to the heading, and the three blocks below breathe apart. */
.modal-body > * + * { margin-top: var(--space-lg); }
.modal-body .label--accent + .cs-heading { margin-top: var(--space-2xs); }
.modal-body .cs-heading { margin-bottom: 0; max-width: 32ch; font-size: var(--text-h2); }
.modal-body .cs-heading + .prose { margin-top: var(--space-sm); }
.modal-body .prose p { color: var(--ink-body); max-width: 60ch; }
/* Panel head: the claim and its value on the left, what each role gets back
   parked top right so the payoff is readable before the chain is. */
.modal-head {
  display: grid;
  grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
  gap: var(--space-xl) var(--space-2xl);
  align-items: start;
}
.modal-head-main .cs-heading { max-width: 20ch; }
/* only the "AI-Integrated" words carry the ramp; "Workflow" stays ink.
   Two hues, one weight: indigo into blue-violet, both held at roughly the
   same lightness and chroma. A same-hue ramp reads as a fade and a tail in
   blue-grey reads as washed out, so the ramp travels sideways in hue rather
   than downward in saturation, and stays below the raw accent's chroma so
   the phrase is emphasis and not a light source. */
.cs-heading .grad {
  --grad-from: #3742C8;
  --grad-to: #7248BE;
  background-image: linear-gradient(100deg, var(--grad-from) 10%, var(--grad-to) 90%);
  -webkit-background-clip: text; background-clip: text; color: transparent;
}
[data-theme="dark"] .cs-heading .grad {
  --grad-from: #838BE8;
  --grad-to: #AE8AEA;
}
.modal-head-main .prose p { max-width: 54ch; }
.modal-head-aside > .label {
  display: block; margin-bottom: var(--space-sm);
  color: var(--ink); font-weight: 600;
}
.modal-head-aside .decisions { max-width: none; }
.modal-head-aside .decisions li:last-child { margin-bottom: 0; }
/* one column below the panel's two-column comfort width */
@media (max-width: 860px) {
  .modal-head { grid-template-columns: 1fr; gap: var(--space-lg); }
  .modal-head-main .cs-heading, .modal-head-main .prose p { max-width: 60ch; }
}
/* ---- case-study diagrams ----
   Inline SVG on a viewBox: scales with the column, needs no JS, and stays
   crisp at any width. Dark canvas like the workflow panel's, so every
   diagram on the site reads as one family regardless of page theme. */
.csd { margin-block: var(--space-lg); }
/* punctuates a list rather than opening a section: less air around it */
.csd--sm { margin-block: var(--space-md) var(--space-xl); }
.csd svg { display: block; width: 100%; height: auto; border-radius: var(--radius-media); }
.csd svg text { font-family: var(--font-body); }
.csd-note { margin-top: var(--space-xs); font-size: var(--text-label); color: var(--ink-2); }
/* Redacted cells. The bars are drawn instead of the words, not drawn over
   them: the architecture diagram is inline SVG, so a blur filter would leave
   every service name sitting in the DOM for anyone who opens devtools. What
   stays is the frame the client does not own: six stages, three lanes, and
   which two services are model-backed, which is the only thing the picture
   was ever arguing. The soft edge is decoration on content-free bars. */
.csd-bar { fill: var(--ink-3); opacity: 0.28; }
.csd-bar--head { fill: var(--ink); opacity: 0.22; }
.csd-bar--model { fill: var(--accent); opacity: 0.34; }
.csd-lock { filter: blur(1.4px); }
[data-theme="dark"] .csd-bar { opacity: 0.34; }
[data-theme="dark"] .csd-bar--head { opacity: 0.3; }
@media (max-width: 720px) { .csd-lock { filter: blur(1px); } }

/* Themed variant. The fixed dark plate is the family default, but a drawing
   that carries the section's main argument reads better on the page's own
   ground than as a slab dropped into it, so this one swaps with the theme.
   Every colour it uses is a variable below; the markup names no hex. Light
   values first: the accent pair has to darken against white or the violet
   and cyan disappear at 12px. */
.csd--themed svg {
  --dg-panel: #FFFFFF;
  --dg-edge: rgba(19,18,23,0.10);
  --dg-dot: #131217;
  --dg-dot-opacity: 0.05;
  --dg-glow: 0.10;
  --dg-ink-2: #6F6E76;
  --dg-inert-fill: rgba(19,18,23,0.02);
  --dg-inert-line: rgba(19,18,23,0.15);
  --dg-inert-tab: rgba(19,18,23,0.14);
  --dg-inert-stub: rgba(19,18,23,0.09);
  --dg-violet: #4750E8;
  --dg-violet-line: rgba(71,80,232,0.26);
  --dg-violet-wash: rgba(71,80,232,0.05);
  --dg-violet-mute: rgba(71,80,232,0.28);
  --dg-module: #FFFFFF;
  --dg-connector: rgba(19,18,23,0.30);
  --dg-cyan: #0B7E93;
  --dg-cyan-text: #0B7E93;
  /* a tinted chip needs more edge on white than it does on the dark plate */
  --dg-chip-fill: 0.07;
  --dg-chip-line: 0.45;
}
[data-theme="dark"] .csd--themed svg {
  --dg-panel: #141319;
  --dg-edge: transparent;
  --dg-dot: #FFFFFF;
  --dg-dot-opacity: 0.07;
  --dg-glow: 0.16;
  --dg-ink-2: #8A8790;
  --dg-inert-fill: rgba(255,255,255,0.04);
  --dg-inert-line: rgba(255,255,255,0.13);
  --dg-inert-tab: rgba(255,255,255,0.10);
  --dg-inert-stub: rgba(255,255,255,0.07);
  --dg-violet: #9AA3FF;
  --dg-violet-line: rgba(154,163,255,0.26);
  --dg-violet-wash: rgba(154,163,255,0.09);
  --dg-violet-mute: rgba(154,163,255,0.30);
  --dg-module: rgba(255,255,255,0.05);
  --dg-connector: rgba(255,255,255,0.28);
  --dg-cyan: #22D3EE;
  --dg-cyan-text: #7FD6E4;
  --dg-chip-fill: 0.08;
  --dg-chip-line: 0.28;
}

/* ---- Personas ---------------------------------------------------------
   Two cards, because the permission split is the design problem: the
   assessor decides and signs, the lead releases work they did not read.

   Every row inside a card repeats the page's own label-left / text-right
   split, so the card is read across rather than down: the labels stack in
   one narrow rail and each sentence gets the full remaining measure. The
   earlier three-across layout put 14 characters in a column and turned a
   short paragraph into ten lines. The block sits outside .prose so it takes
   the whole section column, with the same panel treatment as .csd. */
.personas { margin-block: var(--space-lg); display: grid; gap: var(--space-sm); }
/* the avatar hangs in a column of its own so it costs the card no height:
   everything else, identity and fields alike, starts on one indent past it */
.persona {
  display: grid; grid-template-columns: 52px minmax(0, 1fr);
  gap: var(--space-sm); align-items: start;
  padding: var(--space-lg);
  background: var(--bg-raised);
  border: 1px solid var(--rule);
  border-radius: var(--radius-panel);
}
/* the value track is capped at a readable measure rather than 1fr: with the
   section rail gone the card is 888px wide, and a full-width value ran to
   80 characters */
.persona-id,
.persona-grid > div {
  display: grid; grid-template-columns: 180px minmax(0, 62ch);
  gap: var(--space-lg); align-items: start;
}
/* one hairline in the card, under the identity, and it is the same colour
   as the column rules; the field rows are held apart by space alone */
.persona-id { padding-bottom: var(--space-md); border-bottom: 1px solid var(--rule); }
/* Line-art bust in the same accent-tinted well as .quote-avatar, so the two
   places on the site that show a person share one treatment. Drawn, not
   photographed: these are composite roles from the workshops, and a stock
   face would claim a specific person. One figure for the assessor with the
   medical mark, two for the lead, because the lead's job is other people's
   work. Decorative, so aria-hidden in the markup. */
.persona-avatar {
  --well: color-mix(in srgb, var(--accent) 12%, var(--bg-raised));
  width: 52px; height: 52px; border-radius: 50%;
  display: grid; place-items: center;
  background: var(--well); color: var(--accent);
}
[data-theme="dark"] .persona-avatar { --well: color-mix(in srgb, var(--accent) 22%, var(--bg-raised)); }
.persona-avatar svg { width: 30px; height: 30px; display: block; }
/* one step above body rather than the h3 clamp: at 24px the name broke
   across three lines inside the 180px rail and stopped reading as a name */
.persona-name {
  display: block; font-size: 1.125rem; font-weight: 600; line-height: 1.25;
  color: var(--ink); letter-spacing: var(--track-md);
}
.persona-role { display: block; margin-top: var(--space-2xs); font-size: var(--text-label); color: var(--ink-2); }
.persona-line { font-size: var(--text-body); line-height: 1.55; color: var(--ink-body); }
.persona-grid { margin-top: var(--space-md); display: grid; gap: var(--space-md); }
.persona-grid dt { font-size: var(--text-label); color: var(--ink-2); padding-top: 3px; }
.persona-grid dd { font-size: var(--text-body); line-height: 1.5; color: var(--ink-body); }
.persona-grid dd strong { color: var(--ink); font-weight: 500; }
/* below roughly 550px of card the rail costs more than it buys: the label
   goes back above its sentence and the measure stays intact */
@media (max-width: 640px) {
  .persona { grid-template-columns: 1fr; padding: var(--space-md); }
  .persona-id,
  .persona-grid > div { grid-template-columns: 1fr; gap: var(--space-2xs); }
  .persona-id { gap: var(--space-xs); }
  .persona-grid dt { padding-top: 0; }
}
/* The .persona rules above no longer render on any page: project-one ships a
   blurred capture of the cards instead, because the copy is under NDA. They
   stay because screenshots/proto/_persona-locked.mjs injects the cards back
   into the live page to re-shoot that capture, and it needs them to be styled.

   ── Withheld figure ─────────────────────────────────────────────────────────
   A picture whose blur is already in the file, a wash of page ground over it,
   and a lock that opens into its label on hover. Nothing here is a CSS filter
   on real text: a filter is one devtools toggle away from giving the copy
   back, which would defeat the point of hiding it. Two captures, because the
   cards are drawn in page tokens and a light-mode picture sitting in dark mode
   reads as a hole punched in the page. */
.locked { margin: var(--space-lg) 0 0; }
.locked-media {
  position: relative;
  border: 1px solid var(--rule); border-radius: var(--radius-media);
  overflow: hidden; background: var(--bg-raised);
}
.locked-shot { display: block; width: 100%; height: auto; }
.locked-shot--dark { display: none; }
[data-theme="dark"] .locked-shot { display: none; }
[data-theme="dark"] .locked-shot--dark { display: block; }
/* the wash is what turns a soft screenshot into something deliberately behind
   glass: page ground at low alpha, densest under the plate so the label has
   contrast without a box of its own */
.locked-veil {
  position: absolute; inset: 0; pointer-events: none;
  background:
    radial-gradient(120% 84% at 50% 50%, color-mix(in srgb, var(--bg) 66%, transparent), transparent 74%),
    color-mix(in srgb, var(--bg) 42%, transparent);
}
/* collapsed to the lock alone, and opened by the 0fr → 1fr track rather than a
   width, so the pill grows to whatever the label needs in either language */
.locked-plate {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  /* max-content, not shrink-to-fit: an absolute box offset to 50% is otherwise
     capped at half the picture and the label opens into a clip */
  width: max-content; max-width: calc(100% - var(--space-lg) * 2);
  display: grid; grid-template-columns: auto 0fr; align-items: center;
  padding: var(--space-2xs); border-radius: 999px;
  border: 1px solid var(--rule);
  background: color-mix(in srgb, var(--bg-raised) 86%, transparent);
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
  box-shadow: 0 12px 32px rgba(19, 18, 23, 0.10);
  transition: grid-template-columns 0.42s cubic-bezier(0.22, 0.61, 0.36, 1),
              box-shadow 0.42s cubic-bezier(0.22, 0.61, 0.36, 1);
}
[data-theme="dark"] .locked-plate { box-shadow: 0 12px 32px rgba(0, 0, 0, 0.38); }
.locked-lock {
  width: 44px; height: 44px; border-radius: 50%;
  display: grid; place-items: center;
  background: color-mix(in srgb, var(--accent) 12%, var(--bg-raised));
  color: var(--accent);
}
[data-theme="dark"] .locked-lock { background: color-mix(in srgb, var(--accent) 22%, var(--bg-raised)); }
.locked-lock svg { width: 21px; height: 21px; display: block; }
.locked-copy { overflow: hidden; min-width: 0; opacity: 0; transition: opacity 0.24s ease; }
.locked-copy-in {
  display: block; width: max-content;
  padding: 0 var(--space-md) 0 var(--space-sm);
  font-size: var(--text-body); font-weight: 600; line-height: 1;
  color: var(--ink); letter-spacing: var(--track-md);
}
.locked:hover .locked-plate { grid-template-columns: auto 1fr; }
.locked:hover .locked-copy { opacity: 1; transition-delay: 0.14s; }
/* no pointer means no hover, so the label is simply there */
@media (hover: none) {
  .locked-plate { grid-template-columns: auto 1fr; }
  .locked-copy { opacity: 1; }
}
/* the picture narrows with the column, so the pill gives back its margin */
@media (max-width: 560px) {
  .locked-plate { max-width: calc(100% - var(--space-md) * 2); }
}
@media (prefers-reduced-motion: reduce) {
  .locked-plate, .locked-copy { transition: none; }
}

/* Said under every figure in the tradeoffs section rather than once at the top
   of it. Those pictures are prototypes standing in for work under NDA, and a
   reader who arrives mid-page at a screenshot should not have to scroll back
   to learn that. Full functional size, one ink step down from the caption it
   follows, and no box: it is a footnote, not a warning. */
.nda-note {
  display: grid; grid-template-columns: auto minmax(0, 1fr);
  gap: var(--space-xs); align-items: start;
  margin: var(--space-sm) 0 0; max-width: 65ch;
  font-size: var(--text-small); line-height: 1.5; color: var(--ink-2);
}
/* Standing above a control strip it is a caveat on what follows, so it takes
   its space from the paragraph before and gives it to nothing: the strip
   underneath should still read as attached to its own picture. */
.nda-note:first-child,
.cs-parts-lede + .nda-note { margin-top: var(--space-md); margin-bottom: var(--space-md); }
.nda-note-i { display: block; color: var(--ink-3); margin-top: 3px; }
.nda-note-i svg { width: 16px; height: 16px; display: block; }
/* inside .cs-parts the note follows the lede rather than a caption, so it
   carries the lede's own indent instead of a figure's */



/* Gains row: what the workflow buys, read before the diagram explains how.
   Encased on the endorsement surface (see .quote-card) rather than fenced by
   two hairlines: the three figures are the panel's payoff, and a card lifts
   them off the page where a rule only fences them in. The surface itself is
   inherited from the shared rule up in ENDORSEMENTS, so this block only
   describes the layout. */
.wf-gains {
  list-style: none;
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--space-lg);
  margin-block: var(--space-lg) 0;
}
.wf-gain { display: flex; flex-direction: column; gap: var(--space-2xs); }
.wf-gain-fig {
  font-family: var(--font-display); font-weight: 600;
  font-size: var(--text-h3); line-height: 1.15; letter-spacing: var(--track-md);
  color: var(--ink);
}
.wf-gain-label { font-size: var(--text-label); line-height: 1.4; color: var(--ink-body); }
@media (max-width: 720px) {
  .wf-gains { grid-template-columns: 1fr; gap: var(--space-md); }
}
/* the flow is the centrepiece: give it air and a rule to sit against */
.modal-body .flow {
  margin-top: var(--space-xl);
  padding-top: var(--space-lg);
  border-top: 1px solid var(--line);
}
/* explicit top/bottom: these components carry their own page margins, which
   otherwise outrank the panel's shared rhythm and collapse the gap to zero */
.modal-body .decisions-cat { margin-block: var(--space-xl) 0; }
.modal-body .decisions li:last-child { margin-bottom: 0; }
.modal-body .callout { margin-block: var(--space-xl) 0; max-width: none; }

/* ---- workflow diagram (AI-workflow panel) ----
   A fixed 1440x830 canvas, scaled to the panel width by site.js. Like the
   animated covers it is an artifact with its own dark art direction, always
   dark in both themes; colours stay in the site family (accent blues plus
   the relay artifact's cyan signal). Below a floor scale the wrap pans
   sideways instead of shrinking the type into noise. */
.wfd { overflow-x: auto; }
.wfd-scale { position: relative; overflow: hidden; border-radius: var(--radius-media); }
.wfd-canvas {
  position: absolute; left: 0; top: 0; transform-origin: 0 0;
  width: 1440px; height: 980px;
  background: radial-gradient(120% 80% at 62% -10%, #1d1c26 0%, #141319 46%, #111016 100%);
  border: 1px solid rgba(255,255,255,0.07); border-radius: inherit; overflow: hidden;
}
@keyframes wfDraw {
  0%   { stroke-dashoffset: 100; opacity: 0; }
  8%   { stroke-dashoffset: 92;  opacity: 1; }
  58%  { stroke-dashoffset: 0;   opacity: 1; }
  88%  { stroke-dashoffset: 0;   opacity: 1; }
  100% { stroke-dashoffset: 0;   opacity: 0; }
}
@keyframes wfTip {
  0%, 54%   { opacity: 0.3; }
  64%, 88%  { opacity: 0.9; }
  100%      { opacity: 0.3; }
}
@media (prefers-reduced-motion: reduce) {
  .wfd-canvas [style*="animation"] { animation: none !important; }
}
.wfd-col { position: absolute; width: 236px; z-index: 1; display: flex; flex-direction: column; gap: 4px; }
.wfd-num { font-size: 15px; font-weight: 600; color: #9aa3ff; }
.wfd-name { font-size: 21px; font-weight: 400; color: #f2f1ed; line-height: 1.2; }
.wfd-col--me .wfd-name { font-weight: 600; }
.wfd-sub { font-size: 14px; font-weight: 300; color: #a2a1a9; line-height: 1.3; }
.wfd-col--me .wfd-sub { color: #aab0e8; }
.wfd-chip {
  position: absolute; width: 236px; min-height: 52px; z-index: 1;
  display: flex; align-items: center; gap: 10px; padding: 8px 15px;
  background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.10);
  border-radius: 10px; font-size: 15px; color: #eceaf5; line-height: 1.25;
}
.wfd-chip--accent { background: rgba(154,163,255,0.10); border-color: rgba(154,163,255,0.28); color: #f2f2ff; }
.wfd-chip--signal { background: rgba(34,211,238,0.07); border-color: rgba(34,211,238,0.30); color: #eafcff; }
.wfd-tag { margin-left: auto; font-size: 13px; color: #9aa3ff; white-space: nowrap; }
.wfd-tag--mute { color: #a2a1a9; }
.wfd-tag--signal { color: #22d3ee; }
.wfd-ai {
  position: absolute; width: 236px; height: 116px; z-index: 1;
  display: flex; flex-direction: column; justify-content: center; gap: 7px; padding: 0 20px;
  background: linear-gradient(145deg, #b9beff 0%, #757DFF 55%, #22d3ee 100%);
  border-radius: 20px;
  box-shadow: 0 0 0 1px rgba(255,255,255,0.18), 0 18px 50px -14px rgba(117,125,255,0.6);
}
.wfd-ai-label { font-size: 13px; font-weight: 600; color: rgba(19,18,23,0.6); }
.wfd-ai-title { font-size: 21px; font-weight: 600; color: #131217; line-height: 1.2; }
.wfd-ai-sub { font-size: 14px; color: rgba(19,18,23,0.66); line-height: 1.3; }
.wfd-pill {
  position: absolute; transform: translate(-50%,-50%); z-index: 2;
  height: 30px; display: flex; align-items: center; padding: 0 13px;
  border-radius: 15px; font-size: 13.5px; white-space: nowrap;
}
.wfd-pill--info { background: #141319; border: 1px solid rgba(255,255,255,0.13); color: #b9b7cb; }
.wfd-pill--loop { background: #14131d; border: 1px solid rgba(154,163,255,0.32); color: #9aa3ff; }
.wfd-pill--yes { background: #0a1015; border: 1px solid rgba(34,211,238,0.28); color: #22d3ee; }
.wfd-gate {
  position: absolute; width: 640px; height: 112px; z-index: 1;
  display: flex; flex-direction: column; justify-content: center; gap: 6px; padding: 0 22px;
  background: rgba(255,255,255,0.04); border: 1px solid rgba(154,163,255,0.22); border-radius: 14px;
}
.wfd-eval {
  position: absolute; width: 1356px; height: 96px; z-index: 1;
  display: flex; align-items: center; gap: 28px; padding: 0 24px;
  background: rgba(34,211,238,0.05); border: 1px solid rgba(34,211,238,0.22); border-radius: 16px;
}
.wfd-eval-head { display: flex; flex-direction: column; gap: 4px; width: 296px; flex: none; }
.wfd-eval-label { font-size: 13.5px; font-weight: 600; color: #22d3ee; }
.wfd-eval-sub { font-size: 13.5px; font-weight: 300; color: #9fb4bb; line-height: 1.35; }
.wfd-eval-checks { display: flex; gap: 12px; flex: 1; }
.wfd-eval-check {
  flex: 1; height: 52px; display: flex; align-items: center; padding: 0 14px;
  background: rgba(255,255,255,0.04); border: 1px solid rgba(34,211,238,0.16);
  border-radius: 10px; font-size: 14.5px; color: #eceaf5;
}
.wfd-gate-label { font-size: 13.5px; font-weight: 600; color: #9aa3ff; }
.wfd-gate-q { font-size: 19.5px; font-weight: 400; color: #f2f1f8; line-height: 1.25; }
.wfd-gate-sub { font-size: 15px; font-weight: 300; color: #a2a1a9; line-height: 1.3; }
.wfd-node {
  position: absolute; width: 440px; height: 60px; z-index: 1;
  display: flex; flex-direction: column; justify-content: center; gap: 3px; padding: 0 18px;
  background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.10); border-radius: 12px;
}
.wfd-node-title { font-size: 17px; color: #f2f1f8; line-height: 1.2; }
.wfd-node-sub { font-size: 13.5px; font-weight: 300; color: #a2a1a9; line-height: 1.2; }
.wfd-ship {
  position: absolute; width: 180px; height: 56px; z-index: 1;
  display: flex; align-items: center; justify-content: center;
  background: rgba(34,211,238,0.10); border: 1px solid rgba(34,211,238,0.32); border-radius: 12px;
  box-shadow: 0 0 40px -16px rgba(34,211,238,0.55);
  font-size: 18px; font-weight: 600; color: #22d3ee;
}


/* ============ DOCK ============
   The bar's twin at the other end of the page: same glass, same pill, same
   soft floor beneath it, so the two bracket the content as one family. Two lines of copy
   and a pair of round controls, the LinkedIn one solid. Fades out once the
   footer is on screen, which already carries both actions. */
.dock {
  position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
  z-index: 10; display: flex; align-items: center; gap: var(--space-sm);
  padding: 7px 7px 7px 20px; border-radius: 999px;
  border: 1px solid color-mix(in srgb, var(--ink) 7%, transparent);
  background: color-mix(in srgb, var(--bg-raised) 55%, transparent);
  backdrop-filter: blur(20px) saturate(180%);
  -webkit-backdrop-filter: blur(20px) saturate(180%);
  box-shadow: var(--shadow-float);
  transition: opacity var(--dur-med) var(--ease-out-expo), transform var(--dur-med) var(--ease-out-expo);
}
[data-theme="dark"] .dock {
  border-color: rgba(255,255,255,0.09);
  background: color-mix(in srgb, var(--bg-raised) 42%, transparent);
}
.dock.is-hidden { opacity: 0; transform: translateX(-50%) translateY(14px); pointer-events: none; }
.dock-copy { display: flex; flex-direction: column; line-height: 1.25; margin-right: var(--space-2xs); }
.dock-copy-lead { font-size: var(--text-label); font-weight: 600; color: var(--ink); white-space: nowrap; }
.dock-copy-sub { font-size: var(--text-label); color: var(--ink-2); white-space: nowrap; }
.dock-icon {
  display: inline-grid; place-items: center; flex: none;
  width: 40px; height: 40px; border-radius: 50%;
  color: var(--ink); background: var(--bg-raised);
  border: 1px solid color-mix(in srgb, var(--ink) 8%, transparent);
  box-shadow: var(--shadow-chip);
  transition: background-color var(--dur-med) var(--ease-out-expo), color var(--dur-med) var(--ease-out-expo);
}
[data-theme="dark"] .dock-icon { border-color: rgba(255,255,255,0.10); }
.dock-icon:hover, .dock-icon:focus-visible { background: color-mix(in srgb, var(--ink) 8%, var(--bg-raised)); }
.dock-icon--solid { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.dock-icon--solid:hover, .dock-icon--solid:focus-visible { background: color-mix(in srgb, var(--ink) 88%, var(--accent)); color: var(--bg); }
/* phones: the controls alone, no room for the sentence */
@media (max-width: 620px) {
  .dock { bottom: 12px; padding: 7px; gap: 6px; }
  .dock-copy { display: none; }
}
@media (prefers-reduced-motion: reduce) { .dock { transition: none; } }


/* ============ PAGE BLUR ============
   A fixed layer over the whole site, not something living in the footer:
   the bottom edge of the viewport stays softly out of focus while the page
   scrolls under it, so content dissolves out rather than being cut off.

   Five stacked strips, each blurring harder and each masked to begin lower
   than the last. One strip at a single blur radius produces a visible band
   where it starts; overlapping masked strips ramp smoothly instead.

   z-index sits under the app bar and the dock (10) so both stay crisp, and
   above the page (main is 1). Never interactive. */
.page-blur {
  /* Stops below the dock: the blur is there to soften the page edge, not to
     fog the controls sitting on it. 96px clears the dock plus its offset. */
  position: fixed; left: 0; right: 0; bottom: 0; height: 96px;
  z-index: 8; pointer-events: none;
}
.page-blur i { position: absolute; left: 0; right: 0; bottom: 0; display: block; }
.page-blur i:nth-child(1) {
  top: 0; backdrop-filter: blur(1px); -webkit-backdrop-filter: blur(1px);
  mask-image: linear-gradient(to bottom, transparent, #000 38%);
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 38%);
}
.page-blur i:nth-child(2) {
  top: 22%; backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
  mask-image: linear-gradient(to bottom, transparent, #000 42%);
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 42%);
}
.page-blur i:nth-child(3) {
  top: 44%; backdrop-filter: blur(7px); -webkit-backdrop-filter: blur(7px);
  mask-image: linear-gradient(to bottom, transparent, #000 45%);
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 45%);
}
.page-blur i:nth-child(4) {
  top: 64%; backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
  mask-image: linear-gradient(to bottom, transparent, #000 45%);
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 45%);
}
.page-blur i:nth-child(5) {
  top: 82%; backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
  mask-image: linear-gradient(to bottom, transparent, #000 40%);
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 40%);
}
@media (max-width: 620px) { .page-blur { height: 76px; } }
/* without backdrop-filter there is nothing to ramp, so show nothing rather
   than a grey slab across the foot of the page */
@supports not (backdrop-filter: blur(1px)) { .page-blur { display: none; } }
