/* Joana RGB — shared design system.
   Linked by every page. Page-specific rules stay inline in that page.
   index.html is the reference implementation; this file is the source. */
:root {
    /* Accent channels — stay RGB in both themes */
    --red: #ff5a63;
    --green: #33d97a;
    --blue: #4f9dff;

    /* Orb channels — additive (light) in dark mode */
    --orb-blend: screen;

    --bg: #0c0d12;
    --surface: #15161e;
    --surface-2: #1c1e29;
    --text: #f3f2ee;
    --text-dim: #a3a3ad;
    --border: rgba(243,242,238,0.1);
    /* One colour means interactive, everywhere. Picked from the blue channel
       so it belongs to the mark rather than arriving from outside it. Nothing
       else on the page is allowed to use it. */
    --focus: #4f9dff;
    --sel: #4f9dff;
    --glass-ink: #f3f2ee;
    /* All three, in circle order. Used for rules and underlines that belong
       to the site rather than to one project. A single hue would have to pick
       a favourite; this one does not. */
    --spectrum: linear-gradient(90deg, #33d97a, #ff5a63, #4f9dff);

    /* LEADING. Three values, and nothing on the site is allowed to leave
       line-height unset. Unset means `normal`, which is whatever metric the
       font ships with, so two 16px paragraphs in different components ended up
       with visibly different leading and the page read as unconsidered.

         --lh-display  headings and company names. Tight, because display type
                       at 19px and up already has enough air between lines.
         --lh-body     anything meant to be read in a run of more than a line.
         --lh-ui       labels, dates, chips, buttons. Between the two, because
                       these are single lines that occasionally wrap. */
    --lh-display: 1.16;
    --lh-body: 1.62;
    --lh-ui: 1.45;

    /* Statement keywords. Each word is locked to ONE circle and keeps that
       circle's colour in both modes. The pairing is the whole idea, so the
       colour never swaps on interaction: hovering emphasises, it does not
       recolour. Index in brackets is the circle in SHAPE.POS.

       The text itself never takes these colours: a yellow word on cream is
       unreadable. The colour is a highlight, and the text sits on it. */
    --kw1: #ff5a63; --kw1-on: #16171c;   /* people  · circle [1] */
    --kw2: #4f9dff; --kw2-on: #16171c;   /* scale   · circle [2] */
    --kw3: #33d97a; --kw3-on: #16171c;   /* details · circle [0] */

    /* Text-safe versions of the SAME three circles, for the one job kw1-3
       can't do themselves: small text and thin accents on a page-coloured
       surface. In dark mode they just are kw1-3 — the raw circles already
       read fine on near-black. Light mode overrides these three lines only;
       everything that reads --kwN-txt still moves red->yellow, blue->magenta,
       green->cyan across the toggle, it just does it at a lightness that
       stays legible instead of at the circle's own, cream-on-cream lightness. */
    --kw1-txt: var(--kw1);
    --kw2-txt: var(--kw2);
    --kw3-txt: var(--kw3);
    --border-strong: rgba(243,242,238,0.18);
    --radius: 14px;

    /* ONE measure for the case study column. The intro, the facts row, the body
       copy and every figure all resolve to this, so nothing on the page has a
       width of its own to drift out of step. 820px puts body copy at about 91
       characters a line: past the 45-75 that reads easiest, but the facts row
       needs four columns and the figures need room, and 91 is the widest the
       text stays comfortable at. Narrow this and everything follows. */
    --measure: 820px;

    /* .wrap's own gutter, and how far the top bar and footer are pulled in from
       it on wide screens. The footer derives its inset from the same number, so
       the bar across the top and the bar across the bottom always line up. */
    --wrap-pad: 28px;
    --bar-inset: 105px;

    /* Futura is installed on macOS and iOS but not on Windows or Android.
       Jost is a Futura revival on Google Fonts and carries the same geometric
       skeleton, so it covers everyone else without the look collapsing to a
       generic grotesque. Century Gothic and Avenir Next are the next best
       local geometrics if both fail. */
    --display: Futura, 'Futura PT', Jost, 'Century Gothic', 'Avenir Next', Avenir, sans-serif;
    --body: 'Work Sans', system-ui, sans-serif;

    --ease: cubic-bezier(.65,0,.35,1);
  }

  /* BASE LEADING. Declared once, early, so component rules that need something
     else still win on order. Without this, anything that never had an explicit
     line-height fell back to `normal`, which is the font's own metric: two
     16px paragraphs in different components came out visibly different and the
     page read as unconsidered rather than as designed. */
  h1, h2, h3, h4, h5 { line-height: var(--lh-display); }
  p, li, dd, dt, figcaption, blockquote { line-height: var(--lh-body); }
  .card h3, .card p, .card .meta,
  .deck-card .face .who, .deck-card .face .what,
  .cs-eyebrow, .section-label, .todo, .work-more, .btn, .links a,
  .nav-links a, .toggle-label,
  .skip, .nav-back, .wordmark .name, .rgb,
  .cs-media, .zoom-media, .screen, .shot { line-height: var(--lh-ui); }
  html[data-theme="light"] {
    /* Darkened from #e0384a / #1f9d5c so 11px tag text clears WCAG AA
       (4.5:1) against the cream page background, which is the strictest
       surface since compact cards have no fill. Hue and saturation held
       constant; only lightness moved. */
    --red: #db2236;
    --green: #1a814c;
    --blue: #2f65e0;

    /* Light-mode inks. SOLVED values, not picked by eye. Two sets of hue
       targets had to be satisfied at once:
         - the intersections must match the dark-mode circles
           (red 25.3°, green 150.5°, blue 277.2°)
         - the inks must match the dark-mode overlaps they correspond to
           (yellow 89.4°, magenta 325.8°, cyan 209.7°)
       Worst residual across all six is 6.9°; most are under 3.5°.
       Nudging one ink pulls its two intersections off-hue.

       Lightness cannot match across modes, only hue. A dark-mode overlap
       is two lights screened together and always lightens; a light-mode
       circle is ink on cream. */
    --orb-blend: multiply;

    --bg: #faf8f4;
    --surface: #ffffff;
    --surface-2: #f1efe9;
    --text: #16171c;
    --text-dim: #6b6b72;
    --border: rgba(22,23,28,0.1);
    --border-strong: rgba(22,23,28,0.16);
    --focus: #2f65e0;
    --sel: #ffd174;
    --glass-ink: #16171c;
    --spectrum: linear-gradient(90deg, #00d2e0, #ffd174, #ff87ff);

    /* Same three circles, the ink side. people[1] yellow, scale[2] magenta,
       details[0] cyan. Text stays near-black on all three. */
    --kw1: #ffd174; --kw1-on: #16171c;
    --kw2: #ff87ff; --kw2-on: #16171c;
    --kw3: #00d2e0; --kw3-on: #16171c;

    /* kw2/kw3 are the same hue, darkened until small text clears roughly the
       same bar --red/--green/--blue were solved for above (4.5:1). kw1 is
       NOT darkened at all: any push toward contrast reads as brown, and she
       has said twice now that the brown is worse than the low contrast.
       This is the same yellow the sun icon uses, full stop. */
    --kw1-txt: var(--kw1);
    --kw2-txt: #cc00cc;
    --kw3-txt: #00868f;
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body {
    background: var(--bg);
    color: var(--text);
    font-family: var(--body);
    -webkit-font-smoothing: antialiased;
    /* Deliberately short, and tied to RUN in rgb-shape.js. The swap has to
       land inside the mark's flip window (0.12 x RUN, so ~60ms at RUN=500),
       otherwise the dot finishes inverting to black while the page is still
       dark and there is a beat with no contrast. */
    transition: background 0.1s ease, color 0.1s ease;
  }
  a { color: inherit; text-decoration: none; }
  .wrap { max-width: 1180px; margin: 0 auto; padding: 0 var(--wrap-pad); }

  /* One focus treatment for everything. Visible in both themes. */
  :focus-visible {
    outline: 2px solid var(--focus);
    outline-offset: 3px;
    border-radius: 4px;
  }
  /* Hidden by clipping rather than by left: -9999px. A negative offset
     extends the layout box, which is why body previously needed
     overflow-x: hidden, and that rule in turn breaks position: sticky in
     Safari. Clipping takes the element out of view without widening
     anything. */
  .sr-only, .skip {
    position: absolute; width: 1px; height: 1px; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap; margin: 0; padding: 0;
  }
  .skip:focus {
    position: fixed; left: 0; top: 0; z-index: 100;
    width: auto; height: auto; clip-path: none;
    background: var(--surface); color: var(--text);
    padding: 12px 18px; border-radius: 0 0 8px 0;
    font-size: 14px; font-weight: 500;
  }

  /* ---------- The mark ----------
     One component, two scales: 22px in the wordmark, ~300px in the hero.
     Every shape is a circle at every moment, so no intermediate silhouette
     can appear. The theme toggle runs a three-beat move:

       1. the current set rotates inward and converges to a single dot
       2. that dot is already the extreme of the mode you are leaving
          (three lights screened = white, three inks multiplied = black),
          so it inverts as the page background swaps under it
       3. the dot splits open and the new set expands out of it

     Geometry and motion are driven from script (see the RGB_SHAPE block)
     because the phases need to be sequenced against the background swap.
     CSS here only covers layout, the resting float, and reduced motion. */
  .rgb-shape { overflow: visible; }
  .rgb-shape circle { mix-blend-mode: var(--orb-blend); }
  .rgb-shape .dot { mix-blend-mode: normal; }
  /* The ambient drift is driven from script, not CSS. A CSS animation cannot
     be faded out: killing it snaps the circles back to zero offset, which put
     a visible jump at both ends of the theme transition. Script lets the
     amplitude ease to zero first, so the circles settle into position before
     the move starts and drift back in afterwards. */

  /* ---------- Nav ----------
     Hides on scroll down, returns on scroll up, and is always present at the
     top of the page.

     Two things this depends on, both easy to undo by accident:
       1. the bar is a sibling of .wrap, not a child of it
       2. NO ancestor may set overflow-x: hidden. Safari in particular stops
          honouring position: sticky when one does, which is why body no
          longer carries that rule and why the skip link is clipped rather
          than pushed to left: -9999px. If a horizontal scrollbar ever
          appears, fix the element causing it rather than clipping the body. */
  .topbar {
    position: sticky; top: 0; z-index: 50;
    background: var(--bg);
    /* Arriving is fast, because by the time it arrives you have asked for it.
       Leaving is slow, so it does not feel snatched away. */
    transition: transform 0.24s var(--ease), background 0.22s ease, border-color 0.22s ease;
    border-bottom: 1px solid transparent;
  }
  .topbar.-stuck { border-bottom-color: var(--border); }
  .topbar.-hidden {
    transform: translateY(-101%);
    transition: transform 0.5s cubic-bezier(0.4, 0, 0.6, 1), background 0.22s ease, border-color 0.22s ease;
  }
  nav {
    display: flex; align-items: center; justify-content: space-between;
    padding: 20px 0;
  }

  /* Back stays reachable even while the bar is away, so a case study never
     traps you. It only appears once the bar has gone. */
  .backfloat {
    position: fixed; top: 14px; left: 20px; z-index: 49;
    width: 38px; height: 38px; border-radius: 50%;
    display: flex; align-items: center; justify-content: center;
    background: var(--surface); border: 1px solid var(--border-strong);
    box-shadow: 0 2px 10px rgba(0,0,0,0.12);
    color: var(--text); font-size: 16px; line-height: 1;
    opacity: 0; pointer-events: none;
    transition: opacity 0.25s ease, background 0.22s ease;
  }
  body.nav-hidden .backfloat { opacity: 1; pointer-events: auto; }
  .backfloat:hover { background: var(--surface-2); }
  .nav-back {
    display: inline-flex; align-items: center; justify-content: center;
    width: 34px; height: 34px; margin-right: 4px; border-radius: 50%;
    color: var(--text-dim); font-size: 16px; flex-shrink: 0;
    transition: background 0.2s ease, color 0.2s ease;
  }
  .nav-back:hover { background: var(--surface-2); color: var(--text); }
  .nav-left { display: flex; align-items: center; gap: 6px; }
  .wordmark {
    display: flex; align-items: center; gap: 12px;
    /* Geometric caps need a touch more tracking than a serif did. */
    font-family: var(--display); font-weight: 500; font-size: 17.5px; letter-spacing: 0.03em;
  }
  .mark { width: 22px; height: 20px; overflow: visible; flex-shrink: 0; }
  .wordmark .name .rgb { font-style: italic; font-weight: 400; opacity: 0.7; }

  .nav-right { display: flex; align-items: center; gap: 22px; }
  .nav-links { display: flex; align-items: center; gap: 26px; font-family: var(--body); font-weight: 500; font-size: 14px; color: var(--text-dim); }
  .menu-btn {
    display: none; width: 40px; height: 40px; border-radius: 10px;
    border: 1px solid var(--border); background: var(--surface);
    align-items: center; justify-content: center; flex-direction: column; gap: 5px;
    cursor: pointer; padding: 0;
  }
  .menu-btn span {
    display: block; width: 17px; height: 1.5px; background: var(--text); border-radius: 2px;
    transition: transform 0.26s var(--ease), opacity 0.2s ease;
  }
  .menu-btn[aria-expanded="true"] span:first-child { transform: translateY(3.25px) rotate(45deg); }
  .menu-btn[aria-expanded="true"] span:last-child { transform: translateY(-3.25px) rotate(-45deg); }
  .nav-links a { transition: color 0.2s ease; }
  .nav-links a:hover { color: var(--text); }

  .toggle {
    display: flex; align-items: center; gap: 8px;
    border: 1px solid var(--border); border-radius: 100px;
    padding: 4px 12px 4px 12px; cursor: pointer; background: var(--surface);
    font: inherit; color: inherit;
  }
  html[data-theme="dark"] .toggle { border-color: var(--border-strong); }
  .toggle-label { font-family: var(--body); font-size: 12px; font-weight: 500; color: var(--text-dim); }

  /* One surface, not three. Earlier this was a track with a thumb sliding
     inside it, each with its own border and shadow: a container in a
     container in a container. Now there is a single disc, and the moon-to-
     sun change happens INSIDE it rather than by moving a shape between two
     slots.

     The trick: disc-bg is a plain circle, always full size. disc-bite is a
     second circle painted in the button's own background colour, sitting
     on top and offset, so it visually "erases" a bite out of disc-bg,
     which is what reads as a crescent moon. In light mode disc-bite slides
     out past the edge and shrinks, so disc-bg reads as a full, uncut
     circle, and disc-rays fade and scale in around it, the same disc
     becoming the sun rather than a different icon replacing it. Colour is
     fixed brand hue, not the theme-dependent tokens: always blue while it
     reads as a moon, always yellow while it reads as a sun. */
  .toggle-disc { display: flex; width: 24px; height: 24px; flex: none; }
  .toggle-disc svg { width: 100%; height: 100%; overflow: visible; display: block; }
  .disc-bg {
    fill: #4f9dff; r: 13px;
    transition: fill 0.4s var(--ease), r 0.4s var(--ease);
  }
  /* cx/cy/r as CSS, not a transform: the SVG geometry properties themselves
     are animatable, so the bite genuinely moves and shrinks in the circle's
     own coordinate space, no transform-origin guessing involved. */
  .disc-bite {
    fill: var(--surface); cx: 21px; cy: 10px; r: 11px; opacity: 1;
    transition: cx 0.4s var(--ease), cy 0.4s var(--ease), r 0.4s var(--ease),
      fill 0.2s ease, opacity 0.25s ease 0.15s;
  }
  html[data-theme="light"] .disc-bg { fill: #ffd174; r: 9px; }
  /* Opacity, not just position: sliding it off past the edge still let a
     stray dot show through once the SVG's overflow area extended past the
     button's own edge. Fading it out as it leaves means nothing is ever
     visibly floating outside the toggle in light mode. */
  html[data-theme="light"] .disc-bite { cx: 40px; cy: -6px; r: 4px; opacity: 0; }
  .disc-rays {
    stroke: #4f9dff; stroke-width: 1.6; stroke-linecap: round;
    opacity: 0; transform: scale(0.5);
    transform-box: fill-box; transform-origin: center;
    transition: opacity 0.3s ease, transform 0.4s var(--ease), stroke 0.4s var(--ease);
  }
  html[data-theme="light"] .disc-rays { opacity: 1; transform: scale(1); stroke: #ffd174; }
  @media (prefers-reduced-motion: reduce) {
    .disc-bg, .disc-bite, .disc-rays { transition: none; }
  }

  /* Hero */
  .hero { padding: 66px 0 54px; position: relative; }
  /* flex-start, so the circles sit level with the headline rather than
     drifting to the middle of a column whose height varies with the copy. */
  .hero-top { display: flex; align-items: flex-start; gap: 40px; position: relative; }
  .hero-visual { flex-shrink: 0; padding-top: 10px; }
  .hero-text { flex: 1; }
  /* Geometry now lives in the viewBox, so the triangle holds at every
     size for free. This replaces the old percentage-positioned divs. */
  .hero-stage { width: clamp(200px, 24vw, 280px); height: auto; }

  /* Font-size max trimmed from 62 to 58, and the gap/circle sizes just
     above from 56/300 to 40/280, together freeing the ~55px "people and
     scale," needed to hold as one line instead of splitting mid-phrase.
     Small enough on each lever that nothing reads as smaller on its own. */
  h1 {
    font-family: var(--display); font-weight: 600;
    font-size: clamp(38px, 5.2vw, 58px); line-height: 1.08; letter-spacing: -0.018em;
    max-width: 780px;
  }
  /* Looser than the rest of the display type. A highlight that fills the line
     box needs room above and below it or the three of them touch, and at 1.08
     the second line's highlight sat on the first line's descenders. */
  .statement { line-height: 1.26; }
  h1 em { font-style: normal; color: var(--blue); }

  /* The three keywords. Each is a link that re-aims the work below it.

     The highlight is ON at rest. Three solid colour blocks in the first line
     of the page is the point: it is the only place a visitor sees the colour
     system applied to something other than the shape, and it is what makes
     the words read as controls rather than emphasis.

     Interaction never changes the colour. Each word IS its circle, and a
     colour swap on hover breaks that the instant it fires. So hover works by
     attention instead: the hovered word holds, the other two drop back, and
     the matching circle in the shape steps forward. */
  .kw {
    position: relative; display: inline-block; z-index: 0;
    color: var(--text); text-decoration: none;
    padding: 0 0.06em;
    transition: color 0.16s var(--ease) 0.05s;
  }
  /* One box, scaled. Rest is a rule under the word; hover fills it to a
     highlight. Scaling from the bottom edge means it is the same object
     growing rather than two states crossfading, which is why it reads as
     the underline becoming the colour rather than a second thing arriving. */
  /* TWO LAYERS, AND THE DOMINANT COLOUR IS ALWAYS THE HOVER COLOUR.

     The first attempt sliced one continuous band across all three words. It
     failed for a reason that is obvious once seen: the words sit on different
     lines and at different horizontal positions, so there is no line for a
     band to run along. "people" showed the start of a gradient while sitting
     to the RIGHT of "smallest detail" on the line below. Continuity you have
     to be told about is not continuity.

     What replaced it: each word's rule is its OWN colour for most of its
     width, then leans toward the next word's colour in the last quarter. So
     the sentence still passes something along, but every rule is unmistakably
     one primary, and that primary is exactly what fills on hover. The rest
     state and the hover state can no longer disagree.

     The lean is mixed in oklab, not srgb. An srgb mix of green and red passes
     through brown; oklab does not. */
  .kw::before, .kw::after {
    content: ""; position: absolute; z-index: -1;
    left: 0; right: 0; top: 0.07em; bottom: 0.03em;
    border-radius: 7px;
    transform-origin: 50% 100%;
    transform: scaleY(0.13);
    transition: transform 0.3s var(--ease), opacity 0.3s var(--ease);
  }
  .kw::before {
    background: linear-gradient(90deg,
      var(--rest) 0%,
      var(--rest) 76%,
      color-mix(in oklab, var(--next) 52%, var(--rest)) 100%);
  }
  .kw::after { background: var(--rest); opacity: 0; }

  .kw:hover::before, .kw:focus-visible::before,
  .kw:hover::after,  .kw:focus-visible::after { transform: scaleY(1); }
  .kw:hover::after, .kw:focus-visible::after { opacity: 1; }
  .kw:hover, .kw:focus-visible { color: var(--on); }
  .kw:focus-visible { outline: none; }
  .kw:focus-visible::before { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--focus); }

  /* Attention, not colour. The colour is the pairing with the circle, so it
     never changes. :has() lets the siblings react with no JS; where :has()
     is missing they stay at full strength and nothing breaks. */
  .statement:has(.kw:hover) .kw:not(:hover)::before,
  .statement:has(.kw:focus-visible) .kw:not(:focus-visible)::before {
    opacity: 0.32;
  }

  /* An inline-block can be broken away from the punctuation that follows it,
     which put a lone comma at the head of a line at phone width. */
  .nb { white-space: nowrap; }
  /* --next is the colour of the word that FOLLOWS this one in the sentence.
     The last word has no successor, so it hands off to itself and stays
     solid. Reorder the sentence and these three lines move with it. */
  .kw-1 { --rest: var(--kw1); --on: var(--kw1-on); --next: var(--kw2); }
  .kw-2 { --rest: var(--kw2); --on: var(--kw2-on); --next: var(--kw3); }
  .kw-3 { --rest: var(--kw3); --on: var(--kw3-on); --next: var(--kw3); }

  /* Reduced motion still needs the affordance, so the fill is instant
     rather than absent. */
  @media (prefers-reduced-motion: reduce) {
    .kw, .kw::before { transition: none; }
  }
  .hero-sub {
    transition: opacity 0.18s ease;
    margin-top: 20px; font-size: 17.5px; line-height: 1.62; color: var(--text-dim);
    max-width: 520px;
  }

  /* Work grid */
  .section-label {
    font-family: var(--body); font-size: 14px; font-weight: 500;
    color: var(--text-dim); margin-bottom: 22px;
    display: flex; align-items: center; gap: 14px;
  }
  /* A MOMENT OF LIGHT.
     The rule beside a section heading is normally a dead hairline. This one
     arrives: it draws left to right in all three colours the first time it
     scrolls into view, then settles to the hairline and stays there. It costs
     nothing, it happens once, and it is the kind of thing that is only
     noticed by someone looking closely, which is the audience. */
  .section-label::after {
    content: ""; flex: 1; height: 1px; position: relative;
    background: var(--border);
  }
  .section-label::before {
    content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
    background: var(--spectrum); border-radius: 2px;
    transform: scaleX(0); transform-origin: 0 50%;
    opacity: 0; pointer-events: none;
  }
  .section-label { position: relative; }
  .section-label.-lit::before { animation: rule-draw 1.5s var(--ease) forwards; }
  @keyframes rule-draw {
    0%   { transform: scaleX(0); opacity: 1; }
    45%  { transform: scaleX(1); opacity: 1; }
    100% { transform: scaleX(1); opacity: 0; }
  }
  @media (prefers-reduced-motion: reduce) {
    .section-label.-lit::before { animation: none; }
  }

  .work-grid {
    display: grid; grid-template-columns: repeat(12, 1fr); gap: 18px;
    margin-bottom: 34px; align-items: start;
  }
  /* IMAGE-LED CARDS.

     One rule governs this whole block: text never sits on top of the image.
     Overlaid text needs a scrim, the scrim fights whatever the screenshot
     happens to be, and the layout has to be re-solved for narrow screens
     where there is no room beside the picture. Putting the caption under the
     frame costs a little vertical space and removes all three problems, and
     it means the desktop and mobile card are the same object.

     The image does the persuading. Everything else is deliberately quiet. */
  .card {
    position: relative;
    border-radius: var(--radius);
    display: flex; flex-direction: column; gap: 16px;
    text-decoration: none; color: inherit;
    transition: transform 0.3s var(--ease);
  }
  /* Some cards are buttons, not links: the ones with nothing to open yet
     reveal a note in place instead of navigating anywhere. Reset the native
     button chrome so it reads exactly like the anchor version beside it. */
  button.card {
    appearance: none; -webkit-appearance: none;
    border: none; background: none; padding: 0; margin: 0; width: 100%;
    font: inherit; text-align: left; cursor: pointer;
  }
  .card-img {
    position: relative; overflow: hidden;
    border-radius: var(--radius);
    background: var(--surface);
    border: 1px solid var(--border);
    aspect-ratio: 16 / 10;
    display: flex; align-items: center; justify-content: center;
    /* Scaled from its own centre, so the caption underneath does not move.
       This is the difference between a pop and the lift that was cut in v26:
       a lift shifts everything below it, a scale shifts nothing. */
    transform-origin: 50% 50%;
    transition: transform 0.42s var(--ease), box-shadow 0.42s var(--ease),
                border-color 0.42s var(--ease);
  }
  /* Anchored left, because these are screenshots of an interface: the
     left edge carries the navigation and the headline, and a centred crop
     cuts words in half at narrow widths. */
  .card-img img, .card-img video {
    width: 100%; height: 100%; object-fit: cover; object-position: left center;
    display: block;
    transition: transform 0.5s var(--ease);
  }
  .card.-wide .card-img { aspect-ratio: 21 / 8; }
  /* The phone crop fades to white over its last tenth, in both themes.
     The old version was a mask, which fades to transparent and therefore to
     whatever the card sits on: invisible in light, and in dark it dissolved a
     third of the interface into black. This one paints white instead, which is
     the screenshot's OWN background rather than the page's, so the interface
     reads as continuing into its own empty space no matter which theme is up. */
  @media (max-width: 620px) {
    .card-img { position: relative; }
    /* Only on a card that carries a real screenshot. The placeholders have a
       hatched fill and a pill rather than a picture, and painting white over
       their right edge would be a smudge with nothing behind it. */
    .card:not(.-soon) .card-img::after {
      content: ""; position: absolute; inset: 0; pointer-events: none;
      background: linear-gradient(to right, transparent 90%, #ffffff 100%);
    }
  }
  .card.-tall .card-img { aspect-ratio: 4 / 5; }

  /* Placeholder only. Delete the span, drop in an <img>. */
  .shot {
    font-family: var(--body); font-size: 13px; color: var(--text-dim); opacity: 0.75;
  }

  /* A card with nowhere to send someone yet reveals a note in its own
     picture frame instead: tap it, and "Coming soon" steps back while a
     short line about why unfinished work is still worth publishing steps
     forward in the same spot. Nothing here claims to be a real screen or
     sends anyone to a dead end. */
  .tbd { position: relative; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
  /* The cross-fade positioning lives on this wrapper, NOT on .shot/.reveal
     themselves: .shot already carries the pill's own background and
     border-radius from .card.-soon .shot below, and stretching that pill to
     `inset: 0` inflated it into a giant rounded box filling the whole card.
     The wrapper stretches; the pill and the reveal text keep their own,
     content-sized shape and just sit centred inside it. */
  .tbd-state {
    position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
    text-align: center; padding: 0 22px;
    transition: opacity 0.3s var(--ease), transform 0.3s var(--ease);
  }
  .tbd-state.-reveal { opacity: 0; transform: translateY(6px); }
  .tbd .reveal {
    font-family: var(--body); font-size: 13px; line-height: 1.5; color: var(--chan);
  }
  .card.-tbd.-revealed .tbd-state.-shot { opacity: 0; transform: translateY(-6px); }
  .card.-tbd.-revealed .tbd-state.-reveal { opacity: 1; transform: translateY(0); }
  @media (prefers-reduced-motion: reduce) {
    .tbd-state { transition: none; }
  }

  .card-body { display: flex; flex-direction: column; padding: 0 2px; }
  /* Identical mechanic to .kw, one size down. Two layers: a gradient rule
     that leans toward the next colour, and the flat fill that rises over it.
     Each card sits on a different channel, so the grid shows different parts
     of the same spectrum without any card needing a bespoke rule. */
  .card h3 {
    font-family: var(--display); font-weight: 600; letter-spacing: -0.008em;
    font-size: 22px; margin-bottom: 8px; align-self: flex-start;
    position: relative; z-index: 0; padding: 0 0.07em;
    transition: color 0.16s var(--ease) 0.05s;
  }
  .card h3::before, .card h3::after {
    content: ""; position: absolute; z-index: -1;
    left: 0; right: 0; top: 0.02em; bottom: -0.06em;
    border-radius: 6px;
    transform: scaleY(0.14); transform-origin: 50% 100%;
    transition: transform 0.3s var(--ease), opacity 0.3s var(--ease);
  }
  .card h3::before {
    background: linear-gradient(90deg,
      var(--card-hl) 0%,
      var(--card-hl) 76%,
      color-mix(in oklab, var(--card-next) 52%, var(--card-hl)) 100%);
  }
  .card h3::after { background: var(--card-hl); opacity: 0; }
  .card p { color: var(--text-dim); font-size: 14.5px; line-height: 1.58; max-width: 62ch; }
  .card.-wide h3 { font-size: clamp(26px, 2.6vw, 32px); }
  .card.-wide p { font-size: 16px; line-height: 1.6; }

  /* ONE hover signal, and it is the one the hero already taught.

     The card used to lift, zoom its image, trace a line across the frame and
     trace another under the title: four things at once to say "clickable",
     on a page that already says it a different way at the top. Now the title
     behaves exactly like a statement keyword. Underline at rest, highlight on
     hover, same colour throughout, nothing moves. */
  /* The image and the title respond together. Separately they read as two
     unrelated things happening near each other; together they read as one
     object being pointed at. */
  .card:not(.-soon):hover .card-img,
  .card:not(.-soon):focus-visible .card-img {
    transform: scale(1.014);
    border-color: color-mix(in srgb, var(--card-hl) 55%, transparent);
    box-shadow: 0 14px 34px rgba(0,0,0,0.16);
  }
  .card:hover h3, .card:focus-visible h3 { color: var(--card-on); }
  .card:hover h3::after, .card:focus-visible h3::after { transform: scaleY(1); opacity: 1; }
  .card:hover h3::before, .card:focus-visible h3::before { transform: scaleY(1); }
  .card:focus-visible { outline: none; }
  .card:focus-visible .card-img { outline: 2px solid var(--focus); outline-offset: 3px; }
  .card { transition: none; }

  /* COMING SOON, honestly.

     A hover-revealed status label is a desktop-only pattern: there is no
     hover on a phone, and tap-to-reveal spends the one tap the visitor was
     going to use on opening the thing. So the state is drawn instead of
     revealed. An unfinished card LOOKS unfinished: no photograph, the channel
     colour flat behind a hatch, and the words in the frame.

     These do carry an href now, either to a real draft of the project or to
     the general in-progress page, so they behave and hover like any other
     card: the tag is honest about the image, not about whether the link
     goes anywhere. */
  .card.-soon .card-img {
    background:
      repeating-linear-gradient(-45deg,
        color-mix(in srgb, var(--chan) 13%, transparent) 0 2px,
        transparent 2px 11px),
      color-mix(in srgb, var(--chan) 7%, var(--surface));
    border-color: color-mix(in srgb, var(--chan) 26%, transparent);
  }
  /* Highlighted pill, not coloured text on the page background: --card-hl
     (kw1-3 at full strength) behind near-black text, same pairing as the
     hero keywords and the placeholder labels in a case study. --chan itself
     went back to true, undarkened yellow for kw1 at her request, which
     is too low-contrast to carry this tag's own text. */
  .card.-soon .shot {
    font-family: var(--body); font-size: 12px; font-weight: 500; opacity: 1;
    color: #16171c;
    background: var(--card-hl); border-radius: 100px; padding: 5px 14px;
  }

  /* --chan is the text-safe accent used elsewhere on the card (the hatch,
     the border): kw1-3's own hue, just legible on the page background
     instead of only on the hero's white circle. --card-hl is the bright
     circle colour itself, used only as a surface with near-black text,
     exactly like the statement keywords, so it stays the vivid value that
     also lights up the hero and the deck. Both are the SAME identity in
     both jobs; only --chan's own lightness moves. --card-next is what its
     rule leans toward. */
  .card.-r { --chan: var(--kw1-txt); --card-hl: var(--kw1); --card-next: var(--kw2); --card-on: var(--kw1-on); }
  .card.-b { --chan: var(--kw2-txt); --card-hl: var(--kw2); --card-next: var(--kw3); --card-on: var(--kw2-on); }
  .card.-g { --chan: var(--kw3-txt); --card-hl: var(--kw3); --card-next: var(--kw1); --card-on: var(--kw3-on); }

  .span-12 { grid-column: span 12; }
  .span-7 { grid-column: span 7; }
  .span-6 { grid-column: span 6; }
  .span-5 { grid-column: span 5; }


  .work-more {
    font-family: var(--body); font-size: 13.5px; font-weight: 500; color: var(--text-dim);
    display: inline-flex; align-items: center; gap: 8px; margin-bottom: 72px;
    border-bottom: 1px solid var(--border); padding-bottom: 4px; transition: color 0.2s ease;
  }
  .work-more:hover { color: var(--text); }

  /* About / contact */
  footer { padding: 60px 0 70px; border-top: 1px solid var(--border); }
  .footer-grid { display: grid; grid-template-columns: 1.3fr 1fr; gap: 40px; }
  footer h2 { font-family: var(--display); font-size: 30px; font-weight: 600;
    line-height: var(--lh-display); letter-spacing: -0.012em; margin-bottom: 16px; }
  footer p { color: var(--text-dim); font-size: 17px; line-height: var(--lh-body); max-width: 440px; }
  .links { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; font-family: var(--body); font-weight: 500; font-size: 13px; line-height: var(--lh-ui); }
  /* THE INTERACTION COLOUR PROBLEM, AND WHY LINKS LOOK LIKE THIS.

     On cream, any colour used as TEXT has to clear 4.5:1, which forces it
     dark. That is why the light-mode action blue read as heavy navy: it was
     not a bad pick, it was the only pick, because the brief was impossible.

     So the brand colour stops being a text colour. It goes in a bar under
     the word, where it is a background with dark text on it and can be as
     vivid as the circles are. Same mechanic as the statement keywords, one
     size down, which is the point: the site has ONE way of showing that
     something responds. */
  .links a {
    position: relative; color: var(--text-dim);
    transition: color 0.2s ease; padding-bottom: 2px;
  }
  /* One channel colour per link, in the order the circles read left to right,
     cycling when it runs out. The gradient that used to live here was the only
     place on the site where the underline was a spectrum rather than a single
     colour, which broke the rule the statement keywords establish: a word owns
     one colour and keeps it. */
  .links a::after {
    content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
    border-radius: 2px; background: var(--link-chan, var(--kw3));
    transform: scaleX(0); transform-origin: 0 50%;
    transition: transform 0.3s var(--ease);
  }
  .links a:nth-child(3n + 1) { --link-chan: var(--kw3); }
  .links a:nth-child(3n + 2) { --link-chan: var(--kw2); }
  .links a:nth-child(3n + 3) { --link-chan: var(--kw1); }
  /* Icon plus label. The underline belongs to the word, so it starts after
     the icon rather than running under both. */
  .links a { display: inline-flex; align-items: center; gap: 9px; }
  .links a .ico { flex: none; width: 18px; height: 18px; }
  .links a::after { left: 27px; }
  .links a:hover { color: var(--text); }
  .links a:hover::after { transform: scaleX(1); }

  /* A link with no destination is worse than no link. It sits in a list of
     working links, hovers like one, and then does nothing, which reads as a
     broken site rather than an unfinished one. Until a real URL exists these
     carry no href at all: not clickable, not focusable, and visibly waiting.
     An <a> without href is valid HTML and is not exposed as a link, so screen
     readers stop announcing four destinations that do not exist. */
  .links a:not([href]),
  .work-more:not([href]) {
    color: var(--text-dim); opacity: 0.5; cursor: default; border-bottom-color: transparent;
  }
  .links a:not([href])::after { content: none; }
  .links a:not([href])::before,
  .work-more:not([href])::before {
    content: "soon · "; font-size: 0.88em; letter-spacing: 0.02em; opacity: 0.8;
  }
  .links a:not([href]):hover,
  .work-more:not([href]):hover { color: var(--text-dim); }

  @media (max-width: 900px) {
    .span-7, .span-5, .span-6 { grid-column: span 12; }
  }
  @media (max-width: 760px) {
    .footer-grid { grid-template-columns: 1fr; }
    nav { padding: 16px 0; }
    /* Every tab moves into a panel behind the hamburger. The toggle stays out
       in the open, because it is a signature interaction rather than a tab. */
    .menu-btn { display: flex; }
    /* Once the hamburger shows up next to it, the toggle needs to read as
       its equal, not a smaller, different-shaped afterthought. Same height
       as .menu-btn's fixed 40px; the shapes staying different (pill vs.
       square) is fine and expected, it's the size mismatch that read as
       careless. */
    .toggle { height: 40px; }
    .nav-right { gap: 12px; }
    .nav-links {
      position: absolute; top: calc(100% + 10px); right: 0;
      flex-direction: column; align-items: stretch; gap: 2px;
      min-width: 190px; padding: 8px;
      background: var(--surface); border: 1px solid var(--border-strong);
      border-radius: 12px; box-shadow: 0 2px 6px rgba(0,0,0,.10), 0 20px 48px rgba(0,0,0,.26);
      font-size: 15px; z-index: 60;
    }
    .nav-links[hidden] { display: none; }
    .nav-links a { padding: 11px 14px; border-radius: 8px; color: var(--text); }
    .nav-links a:hover { background: var(--surface-2); }
    nav { position: relative; }
    .hero-top { flex-direction: column; align-items: flex-start; gap: 28px; }
    .hero-stage { width: 180px; }
    /* One column below 760. The wide card stops being cinematic at phone
       width, so it takes the same frame as everything else. */
    .work-grid { grid-template-columns: 1fr; gap: 34px; }
    .span-12, .span-7, .span-6, .span-5 { grid-column: span 1; }
    .card.-wide .card-img, .card.-tall .card-img { aspect-ratio: 16 / 10; }
    .card.-wide h3 { font-size: 24px; }
    .card.-wide p { font-size: 15px; }
    /* The button sits partway across the column, so a menu anchored to it
       runs off the right edge on a phone. Anchor it to the column instead
       and let it span the full width. */
  }

  @media (max-width: 430px) {
    .nav-links { gap: 14px; font-size: 13px; }
    .toggle-label { display: none; }
    .toggle { padding: 4px 8px; }
    .toggle-disc { width: 20px; height: 20px; }
  }
  @media (max-width: 430px) {
    /* Case study pages carry a back arrow as well, which is the same
       destination as this link. Two of them will not fit at phone width, so
       the text one goes and the arrow stays. Contact is untouched. */
    .dupe-of-back { display: none; }
  }
  @media (max-width: 380px) {
    .wordmark { font-size: 16px; }
  }

  @media (prefers-reduced-motion: reduce) {
    .hero-stage .s-g, .hero-stage .s-r, .hero-stage .s-b { animation: none; }
    .rgb-shape path, .rgb-shape { transition: fill 0.2s ease; }
    .card, .card:hover { transition: none; transform: none; }
    .card:hover .card-img img, .card h3::after { transition: none; }
  }

/* Horizontal rail. Same cards as the home grid, smaller, scrollable.
   The container takes focus so it can be scrolled from the keyboard, which
   an overflow region does not get for free. */
.rail {
  display: flex; gap: 16px; overflow-x: auto; padding: 4px 4px 18px;
  scroll-snap-type: x proximity; scrollbar-width: thin;
  margin: 0 -4px;
}
.rail .card {
  flex: 0 0 300px; scroll-snap-align: start; gap: 12px;
}
.rail .card .card-img { aspect-ratio: 16 / 10; }
.rail .card h3 { font-size: 18px; margin-bottom: 6px; }
.rail .card p { font-size: 13.5px; line-height: 1.5; }
.rail:focus-visible { outline: 2px solid var(--focus); outline-offset: 4px; border-radius: 8px; }
/* The row of other projects is the one thing on a case study that is not
   reading matter, so it is allowed to use the full page width the header and
   footer use rather than the narrow text column. No fade at the edges: a card
   cut by the edge of the screen already says the row continues, and a gradient
   over it read as a smudge. */
.rail-wrap {
  position: relative;
  --rail-out: calc((min(1180px, 100vw) - 2 * var(--wrap-pad) - var(--measure)) / 2);
  margin-inline: calc(-1 * max(0px, var(--rail-out)));
}
@media (min-width: 1100px) {
  .rail-wrap { --rail-out: calc((min(1180px, 100vw) - 2 * var(--bar-inset) - var(--measure)) / 2); }
}
@media (max-width: 620px) { .rail .card { flex-basis: 250px; } }

/* ============================================================
   CASE STUDY PAGES
   No all-caps micro-labels here either. Uppercase mono is a
   habit that reads as machine-set rather than typeset, and it
   was doing the same job as sentence case at worse legibility.
   Shared by every case study. The rhythm is deliberate: a wide
   opening image, then alternating text and evidence, narrowing
   to a single column for anything that is argument rather than
   proof. Placeholders are visibly bracketed so nothing ships by
   accident.
   ============================================================ */

.cs-back {
  display: inline-flex; align-items: center; gap: 8px;
  font-family: var(--body); font-size: 13px; font-weight: 500;
  color: var(--text-dim); margin: 8px 0 44px; transition: color 0.2s ease;
}
.cs-back:hover { color: var(--text); }

/* ---------- COLOUR FRAMING FOR A PROJECT PAGE ----------

   A project page is tuned to one channel. Two tokens do it:

     --chan     text-safe accent. Darkens in light mode so it can be read.
     --chan-hl  the highlight block. The exact colour of the matching word on
                the home page, used only as a background with near-black text.

   A solid background in the channel colour was tried and it is too much: it
   turns the case study into a poster and fights every screenshot on the page.
   So the colour arrives in five small places instead, and the page reads as
   tinted rather than painted:

     1. The word in the lede, highlighted exactly as it is on the home page.
     2. A wash behind the header that fades out before the content starts.
     3. The pull quote rule.
     4. The scroll progress line.
     5. Text selection.

   Of these, (1) is the one that matters. Joana's note was that the first
   paragraph has to guarantee the connection back to the word that was
   clicked. Making it literally the same visual object -- same colour, same
   shape, same size relationship -- is a stronger guarantee than any sentence,
   because it lands before the sentence is read. */

.chan-page { position: relative; }

/* The home page keyword, restated. Static, so no transition and no link. */
.hl {
  position: relative; display: inline-block; z-index: 0;
  color: var(--kw-on, #16171c); padding: 0 0.06em;
}
.hl::before {
  content: ""; position: absolute; z-index: -1;
  left: 0; right: 0; top: 0.09em; bottom: 0.04em;
  border-radius: 5px; background: var(--chan-hl);
}

/* Selection has to be a BRIGHT colour with near-black text, never a dark
   colour with near-black text. --blue in light mode is #2f65e0, which put
   near-black text on navy: unreadable. Selection now runs on --sel, which is
   an emitter in dark and an ink in light, and takes the page channel where
   there is one. */
::selection { background: var(--sel); color: #16171c; }
.chan-page ::selection { background: var(--chan-hl); color: #16171c; }

/* Reading progress. 2px, channel colour, only on project pages. */
.chan-progress {
  position: fixed; top: 0; left: 0; height: 2px; width: 100%;
  transform: scaleX(0); transform-origin: 0 50%;
  background: var(--chan-hl); z-index: 200; pointer-events: none;
}

/* Opt-in per page rather than every <main> on the site, so pages that have not
   been moved onto the shared measure yet keep their own layouts.
   margin-inline centres the column; without it the page hangs off the left
   edge of .wrap once it is narrower than .wrap. */
.page-col { max-width: var(--measure); margin-inline: auto; }

/* The content column is 820 and the header runs to .wrap's 1124, which left the
   wordmark a long way outside where the page actually starts. Pulling the bar in
   on wide screens closes most of that gap without lining the two up: a header
   that matches the column exactly stops reading as a bar across the top and
   starts reading as another block of content. Below this width .wrap is already
   close enough to the column that the extra padding would just cramp the nav. */
@media (min-width: 1100px) {
  .topbar .wrap { padding-inline: var(--bar-inset); }
  /* The footer is a child of .wrap rather than having a .wrap of its own, so it
     already carries --wrap-pad. Subtracting it lands the footer on the same
     line as the nav instead of 28px outside it. */
  footer { padding-inline: calc(var(--bar-inset) - var(--wrap-pad)); }
}
.cs-head { margin-bottom: 46px; }
.cs-eyebrow {
  font-family: var(--body); font-size: 14px; font-weight: 500;
  color: var(--chan, var(--blue)); margin-bottom: 18px;
}
.cs-head h1 {
  font-family: var(--display); font-weight: 600;
  font-size: clamp(34px, 4.6vw, 54px); line-height: 1.07; letter-spacing: -0.018em;
  margin-bottom: 22px;
}
.cs-lede { font-size: 19px; line-height: 1.58; color: var(--text-dim); }

/* Facts strip. Four slots; drop one and the rest re-flow. */
/* ---------- WHAT THE PLATFORM DOES ----------
   The panel that opens the case study. It is the product's own colours,
   indigo through violet, sampled from the platform's header and primary
   button, so it introduces the thing being described rather than repeating
   the site's three channels, which are already doing that job elsewhere.

   The four steps sit on ONE line with a light travelling along it, left to
   right. The movement is the point: it says these are stages of one process,
   which a row of separate columns did not. Nothing loops back visually; the
   sentence above carries that idea, and a drawn arrow returning under the row
   looked like a diagram apologising for itself.

   Stacked on a phone with the line running down the left, because four
   columns of text at 390px is four columns of two words. */
:root { --noto-1: #4338c9; --noto-2: #9333a8; }
html[data-theme="dark"] { --noto-1: #7b7ce8; --noto-2: #d07fd2; }
/* On the case study, the panel takes the page's own pair instead of its own
   purples: the same channel-into-next gradient as the eyebrow and the home
   page card underline (magenta into cyan in light, blue into green in dark),
   so the page introduces no extra colours. */
.wrap .cs-info { --noto-1: var(--chan-hl, #4338c9); --noto-2: var(--chan-next, #9333a8); }

.cs-info {
  position: relative; isolation: isolate; overflow: hidden;
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: 40px 42px 36px; margin-bottom: 44px;
  background:
    linear-gradient(135deg,
      color-mix(in srgb, var(--noto-1) 14%, transparent) 0%,
      color-mix(in srgb, var(--noto-2) 11%, transparent) 52%,
      transparent 82%),
    var(--surface);
}
html[data-theme="dark"] .cs-info {
  background:
    linear-gradient(135deg,
      color-mix(in srgb, var(--noto-1) 26%, transparent) 0%,
      color-mix(in srgb, var(--noto-2) 18%, transparent) 50%,
      transparent 80%),
    var(--surface);
}

/* Two soft lights drifting behind the type. Blurred well past being circles,
   so they read as light in the panel rather than shapes on top of it. Only
   the two product colours: the warm third one turned the light mode brown. */
.cs-info .glow { position: absolute; inset: 0; z-index: -1; pointer-events: none; }
.cs-info .glow i { position: absolute; border-radius: 50%; filter: blur(50px); opacity: .34; }
html[data-theme="dark"] .cs-info .glow i { opacity: .38; filter: blur(54px); }
.cs-info .glow i:nth-child(1) {
  width: 260px; height: 260px; background: var(--noto-1);
  right: 8%; top: -40%; animation: drift-a 22s ease-in-out infinite;
}
.cs-info .glow i:nth-child(2) {
  width: 220px; height: 220px; background: var(--noto-2);
  right: 20%; bottom: -48%; animation: drift-b 27s ease-in-out infinite;
}
.cs-info .glow i:nth-child(3) { display: none; }
@keyframes drift-a {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
  50%      { transform: translate3d(-26px, 18px, 0) scale(1.07); }
}
@keyframes drift-b {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
  50%      { transform: translate3d(22px, -20px, 0) scale(1.1); }
}

.cs-info h2 {
  font-family: var(--display); font-weight: 600;
  font-size: clamp(24px, 3.2vw, 31px); line-height: var(--lh-display);
  letter-spacing: -.015em; margin-bottom: 10px;
}
.cs-info-lede {
  font-size: 16px; line-height: var(--lh-body); color: var(--text-dim);
  max-width: 62ch; margin-bottom: 34px;
}

/* The track is drawn segment by segment, each one spanning exactly from its
   own marker to the next. That is what keeps the light travelling from the
   first marker to the last instead of running off both ends: there is no
   track before the first or after the last, because there is no step there.

   The light is a second background layer on the segment, so it is part of the
   line rather than a shape flying over it, and the markers sit above both. */
.cs-info .steps {
  --gap: 24px; --dot: 11px;
  position: relative; list-style: none; margin: 0; padding: 26px 0 0;
  display: grid; grid-template-columns: repeat(4, 1fr); gap: 0 var(--gap);
}
.cs-info .step { position: relative; }
.cs-info .step:not(:last-child)::after {
  content: ""; position: absolute; z-index: 1;
  left: var(--dot); top: calc(-26px + (var(--dot) - 2px) / 2);
  width: calc(100% + var(--gap) - var(--dot)); height: 3px; border-radius: 3px;
  background-image:
    linear-gradient(90deg, transparent, var(--spark) 45%, transparent),
    linear-gradient(90deg,
      color-mix(in srgb, var(--from) calc(var(--line-a) * 100%), transparent),
      color-mix(in srgb, var(--to) calc(var(--line-a) * 100%), transparent));
  background-size: 150px 100%, 100% 100%;
  background-repeat: no-repeat, no-repeat;
  background-position: -90px 0, 0 0;
  animation: spark 4.8s linear infinite;
  animation-delay: var(--beat);
  opacity: .95;
}
@keyframes spark {
  0%   { background-position: -150px 0, 0 0; }
  26%  { background-position: calc(100% + 150px) 0, 0 0; }
  100% { background-position: calc(100% + 150px) 0, 0 0; }
}
/* The markers. Above the light, with a ring in the panel's own colour so they
   stay legible wherever the gradient is darkest. */
.cs-info .step::before {
  content: ""; position: absolute; z-index: 2;
  left: 0; top: -26px; width: var(--dot); height: var(--dot); border-radius: 50%;
  background: var(--mark); box-shadow: 0 0 0 4px var(--surface);
}
/* The beats are the length of one segment's pass, so the light leaves one
   segment exactly as the next picks it up: one light crossing the whole
   track, not three running side by side. */
.cs-info .step:nth-child(1) { --mark: var(--noto-1); --from: var(--noto-1); --to: color-mix(in oklab, var(--noto-1) 62%, var(--noto-2)); --beat: 0s; }
.cs-info .step:nth-child(2) { --mark: color-mix(in oklab, var(--noto-1) 62%, var(--noto-2)); --from: color-mix(in oklab, var(--noto-1) 62%, var(--noto-2)); --to: color-mix(in oklab, var(--noto-1) 28%, var(--noto-2)); --beat: 1.25s; }
.cs-info .step:nth-child(3) { --mark: color-mix(in oklab, var(--noto-1) 28%, var(--noto-2)); --from: color-mix(in oklab, var(--noto-1) 28%, var(--noto-2)); --to: var(--noto-2); --beat: 2.5s; }
/* The head of the light. On cream, mixing toward white made it vanish into
   the panel, so in light mode it goes the other way: a near-black ink that
   reads as a bright pass over a coloured line. In dark mode white still is
   the brightest thing available. */
/* On cream the line itself was as strong as the light passing over it, so
   the pass had nothing to be brighter than. The line goes down to a third,
   and the head stays fully saturated. */
.cs-info .step { --spark: var(--chan, #4c22ff); --line-a: .38; }
/* The page pair is already light in light mode, so the line keeps more of
   its strength there than the old purples needed. */
.wrap .cs-info .step { --line-a: .8; }
html[data-theme="dark"] .cs-info .step { --spark: #ffffff; --line-a: 1; }
.cs-info .step:nth-child(4) { --mark: var(--noto-2); }
.cs-info .step h3 {
  font-family: var(--display); font-weight: 600; font-size: 18px;
  line-height: var(--lh-display); margin-bottom: 6px;
}
.cs-info .step p { font-size: 14px; line-height: 1.5; color: var(--text-dim); }

@media (prefers-reduced-motion: reduce) {
  .cs-info .glow i { animation: none; }
  .cs-info .step:not(:last-child)::after { animation: none; background-image: linear-gradient(90deg, var(--from), var(--to)); background-size: 100% 100%; }
}

@media (max-width: 760px) {
  .cs-info { padding: 28px 24px 26px; }
  .cs-info-lede { margin-bottom: 24px; }
  .cs-info .steps { grid-template-columns: 1fr; gap: 0; padding: 4px 0 0 30px; }
  .cs-info .step { padding-bottom: 20px; }
  .cs-info .step:last-child { padding-bottom: 0; }
  .cs-info .step::before { left: -30px; top: 5px; }
  /* Same segments, turned on their side: each runs from its own marker down
     to the next one, so the light still starts and stops on a marker. */
  .cs-info .step:not(:last-child)::after {
    left: calc(-30px + (var(--dot) - 2px) / 2); top: calc(5px + var(--dot));
    width: 2px; height: calc(100% - var(--dot) - 5px);
    background-image:
      linear-gradient(180deg, transparent, var(--spark) 45%, transparent),
      linear-gradient(180deg,
      color-mix(in srgb, var(--from) calc(var(--line-a) * 100%), transparent),
      color-mix(in srgb, var(--to) calc(var(--line-a) * 100%), transparent));
    background-size: 100% 90px, 100% 100%;
    background-position: 0 -70px, 0 0;
    animation: spark-v 4.8s linear infinite;
    animation-delay: var(--beat);
  }
  @keyframes spark-v {
    0%   { background-position: 0 -90px, 0 0; }
    26%  { background-position: 0 calc(100% + 90px), 0 0; }
    100% { background-position: 0 calc(100% + 90px), 0 0; }
  }
}

/* The lead image. It sits above the facts, so the page opens on the product
   rather than on a list about it, and it carries no caption: the sentence
   above it has already said what this is. */
.cs-fig.-lead { margin-bottom: 40px; }
.cs-fig.-lead img { border-radius: var(--radius); display: block; width: 100%; height: auto; }

/* Team reads as a list of people, one per line, rather than one long sentence. */
.cs-fact dd ul { list-style: none; margin: 0; padding: 0; }
.cs-fact dd li + li { margin-top: 3px; }

.cs-facts {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 22px 30px; padding: 0 0 26px; margin-bottom: 54px;
  /* No rule above: the panel over it is already an edge, and two lines that
     close to each other read as a table nobody asked for. */
  border-bottom: 1px solid var(--border);
}
.cs-fact dt {
  font-family: var(--body); font-size: 13px; font-weight: 500;
  color: var(--text-dim); margin-bottom: 7px;
}
.cs-fact dd { font-size: 14.5px; line-height: 1.45; }

/* Media. -wide breaks out past the text column, -pair sits two up. */
.cs-media {
  border-radius: var(--radius); border: 1px dashed var(--border);
  background: linear-gradient(135deg, color-mix(in srgb, var(--chan, var(--blue)) 11%, transparent), transparent 70%);
  display: flex; align-items: center; justify-content: center; text-align: center;
  font-family: var(--body); font-size: 13px; color: var(--text-dim); padding: 20px;
  min-height: 260px; margin-bottom: 16px;
}
.cs-media.-tall { min-height: 420px; }
.cs-media.-short { min-height: 180px; }
.cs-caption {
  font-size: 13px; line-height: 1.5; color: var(--text-dim); margin-bottom: 54px;
  max-width: 62ch;
}
.cs-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.cs-pair .cs-media { margin-bottom: 0; }
.cs-pair + .cs-caption { margin-top: 16px; }

/* Body copy fills main, the same measure as the rules bracketing the facts row
   at the top of the page. No number here on purpose: matching main means it
   keeps matching at every viewport instead of only where 680px happened to
   line up. */
.cs-section { margin-bottom: 54px; }
/* A section holding nothing but its heading is a title for whatever comes
   next, so it should sit as close to that thing as a heading sits to its own
   paragraph. Without this it contributes h2's 16px AND the section's 54px, and
   a figure under a title falls 70px away while text under the same title sits
   at 16px. That gap is what made the spacing look inconsistent. */
.cs-section:has(> h2:last-child),
.cs-section:has(> h3:last-child) { margin-bottom: 16px; }
.cs-section h2 {
  font-family: var(--display); font-weight: 600; font-size: 26px;
  letter-spacing: -0.012em; margin-bottom: 16px;
}
.cs-section h3 {
  font-family: var(--display); font-weight: 600; font-size: 18px;
  margin: 30px 0 10px;
}
.cs-section p { font-size: 16px; line-height: 1.68; color: var(--text-dim); margin-bottom: 16px; }
.cs-section p strong { color: var(--text); font-weight: 600; }
/* Lists in case study copy: indented, no bullet markers. Each item leads
   with a bold term, which already marks where it starts. */
.cs-section ul { list-style: none; margin: 0 0 16px; padding: 0; }
.cs-section li { font-size: 16px; line-height: 1.68; color: var(--text-dim); margin-bottom: 8px; }

/* A pulled-out claim. Use for the one sentence that carries the case. */
.cs-pull {
  border-left: 3px solid var(--chan-hl, var(--blue));
  padding: 4px 0 4px 22px; margin: 34px 0 40px; max-width: 720px;
}
.cs-pull p {
  font-family: var(--display); font-weight: 500; font-size: clamp(20px, 2.2vw, 26px);
  line-height: 1.34; letter-spacing: -0.008em; color: var(--text);
}

/* Fill these in and the placeholder styling disappears with them. */
.todo {
  font-family: var(--body); font-size: 0.92em;
  background: color-mix(in srgb, var(--chan, var(--blue)) 14%, transparent);
  border-radius: 4px; padding: 1px 6px;
}

.cs-next {
  display: flex; justify-content: space-between; align-items: center; gap: 24px;
  flex-wrap: wrap; padding: 32px 0 0; border-top: 1px solid var(--border); margin-top: 20px;
}
.cs-next .lbl { font-family: var(--body); font-size: 13px; font-weight: 500; color: var(--text-dim); margin-bottom: 8px; }
.cs-next h3 { font-family: var(--display); font-weight: 600; font-size: 24px; }

@media (max-width: 760px) {
  .cs-pair { grid-template-columns: 1fr; }
  .cs-media { min-height: 200px; }
  .cs-media.-tall { min-height: 280px; }
}

/* ============================================================
   ABOUT PAGE
   Deliberately plainer than the home page. This is where someone
   lands after they already like the work, so its job is to be
   clear and human rather than to impress again.
   ============================================================ */

.ab-intro { display: grid; grid-template-columns: 300px 1fr; gap: 48px; align-items: start; margin-bottom: 76px; }
.ab-photo {
  aspect-ratio: 4 / 5; border-radius: var(--radius); overflow: hidden;
  border: 1px solid var(--border);
  background: var(--surface);
  display: flex; align-items: center; justify-content: center; text-align: center;
  font-family: var(--body); font-size: 13px; color: var(--text-dim);
}
/* Fills the frame and keeps the crop. The file is already cut to 4:5, so
   object-fit is a guard rather than a fix. */
.ab-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ab-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ab-intro h1 {
  font-family: var(--display); font-weight: 600;
  font-size: clamp(30px, 3.8vw, 44px); line-height: var(--lh-display); letter-spacing: -.018em;
  margin-bottom: 20px;
}
.ab-intro p { font-size: 17px; line-height: var(--lh-body); color: var(--text-dim); margin-bottom: 14px; max-width: 56ch; }

/* The dot. Inline rather than absolutely positioned on the canvas, so it stays
   with the first word at every width instead of drifting into the middle of a
   line. It blends the way the rest of the site's colour does: subtractive on
   paper, additive on ink. */
.ab-intro h1 { position: relative; }
.ab-dot {
  position: absolute; left: -0.42em; top: 0.06em;
  width: 0.62em; height: 0.62em; border-radius: 50%;
  background: var(--kw2); mix-blend-mode: multiply; pointer-events: none;
}
html[data-theme="dark"] .ab-dot { mix-blend-mode: screen; }
@media (max-width: 720px) { .ab-dot { display: none; } }

.ab-section { margin-bottom: 72px; }
.ab-section > h2 {
  font-family: var(--display); font-weight: 600; font-size: 26px;
  line-height: var(--lh-display); letter-spacing: -.012em; margin-bottom: 8px;
}
.ab-section > .ab-lede { color: var(--text-dim); max-width: 88ch; margin-bottom: 28px;
  font-size: 17px; line-height: var(--lh-body); }

/* When a section has no lede, the heading would otherwise sit almost on top of
   the content. The gap belongs to the pair, not to the heading, so it is set
   on the adjacency rather than as a blanket margin. */
.ab-section > h2 + .ab-values,
.ab-section > h2 + .ab-skills,
.ab-section > h2 + .ab-roles { margin-top: 28px; }

/* ------------------------------------------------------------------
   TEXT ADJUSTMENT

   Two rules, applied once, site-wide.

   `balance` evens the line lengths of a short block, which is what stops a
   heading ending on a single orphaned word. The browser only balances up to a
   handful of lines, which is exactly right: it is for headings, not paragraphs.

   `pretty` leaves long text alone except at the end, where it pulls a word
   down rather than leaving one on its own line.

   Neither is supported everywhere yet. Both degrade to normal wrapping, so
   the worst case is the behaviour we already had.
   ------------------------------------------------------------------ */
h1, h2, h3, h4,
.cs-lede, .ab-lede, .cs-standfirst, figcaption, .deck-card .face .who {
  text-wrap: balance;
}
p, li, dd, .cs-fact dd { text-wrap: pretty; }

/* Values. One channel colour each, so they read as three positions
   rather than a bulleted list. */
/* Four across, two, then one. auto-fit was deciding this on its own and would
   drop to three at some widths, which leaves an orphan and reads as a mistake.
   An explicit ladder never produces an odd row. */
/* Two rows plus `subgrid`: every heading sits in one shared row, so all four
   bodies start on the same line however many lines a heading takes. Matching
   the titles by hand only aligns them at one width. */
.ab-values {
  display: grid; grid-template-columns: repeat(4, 1fr);
  grid-template-rows: auto auto; gap: 20px 22px;
}
@media (max-width: 1040px) { .ab-values { grid-template-columns: repeat(2, 1fr); gap: 28px 22px; } }
@media (max-width: 620px)  { .ab-values { grid-template-columns: 1fr; gap: 26px; } }
.ab-value {
  display: grid; grid-template-rows: subgrid; grid-row: span 2; row-gap: 8px;
  border-top: 3px solid var(--chan); padding-top: 21px;
}
/* A fifth value that wants to read as its own statement rather than one more
   tile in the 2x2 rhythm: full width, own row (the grid's implicit row-auto
   handles that on its own since only 4 rows are named above), text given
   more breathing room since it isn't fighting three neighbours for it. */
.ab-value.-full { grid-column: 1 / -1; max-width: 760px; }
/* Where I stand on AI: a note at the end of the page, not a fifth belief.
   A hairline rather than a channel rule, so it reads as an aside. */
.ab-ai { max-width: 760px; border-top: 1px solid var(--border); padding-top: 26px; }
.ab-ai h3 { font-family: var(--display); font-weight: 600; font-size: 19px; line-height: var(--lh-display); margin: 0 0 8px; }
.ab-ai p { font-size: 17px; line-height: var(--lh-body); color: var(--text-dim); }
@media (max-width: 620px) { .ab-value.-full { max-width: none; } }
@supports not (grid-template-rows: subgrid) { .ab-value { display: block; } }
/* Stacked in one column, four thick coloured rules read as section dividers
   and chop the list up. On phones the rule goes to a hairline and the colour
   stays only as a short tab, so each value keeps its channel without the
   page turning into stripes. */
@media (max-width: 620px) {
  .ab-value { border-top: 1px solid var(--border); padding-top: 26px; position: relative; }
  .ab-value::before {
    content: ""; position: absolute; top: -2px; left: 0;
    width: 28px; height: 3px; border-radius: 2px; background: var(--chan);
  }
}
.ab-value h3 { font-family: var(--display); font-weight: 600; font-size: 19px;
  line-height: var(--lh-display); margin: 0; align-self: start; }
.ab-value p { font-size: 17px; line-height: var(--lh-body); color: var(--text-dim); }

/* A plain list, not pills. A bordered, filled capsule is the shape of a
   button or a filter chip, so these read as clickable and then do nothing.
   Columns keep fourteen items scannable without dressing them as controls. */
.ab-skills {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px 22px;
}
.ab-skills li { font-size: 16px; line-height: var(--lh-ui); color: var(--text-dim); }
/* On phones the first column only takes the width of its longest item, so
   the second column sits close to it instead of floating mid-screen. */
@media (max-width: 620px) { .ab-skills { grid-template-columns: max-content 1fr; column-gap: 24px; } }

/* Experience. A list, not a timeline graphic: it is scanned, not read. */
/* Separators BETWEEN rows only. A border-top on the list plus a border-bottom
   on every row draws a line above the first company and below the last, and
   on mobile those land next to the section rule and the footer rule and read
   as a stack of stray lines. */
.ab-roles { border-top: 0; }
.ab-role {
  display: grid; grid-template-columns: 60px 190px 1fr auto; gap: 22px; align-items: center;
  padding: 14px 0;
}
.ab-role + .ab-role { border-top: 1px solid var(--border); }
.ab-role .co { font-family: var(--display); font-weight: 600; font-size: 18px;
  line-height: var(--lh-display); }
.ab-role .ti { font-size: 15px; line-height: var(--lh-ui); color: var(--text-dim); }
.ab-role .yr { font-size: 14px; line-height: var(--lh-ui); color: var(--text-dim); white-space: nowrap; }

/* The logo slot. Until the real files land this draws a monogram, which reads
   as a deliberate mark rather than as a broken image icon. Swapping in an
   <img> later needs no layout change: same box, same size. */
/* A white plate, at both modes. Company marks are fixed brand colours: Ford's
   blue and Shell's red and yellow cannot be recoloured, and inverting them for
   dark mode would be wrong twice over. Giving every mark the same white ground
   means one treatment works everywhere and nobody's logo is altered. The
   transparency keeps it from reading as a hard sticker on the dark page. */
.ab-logo {
  width: 46px; height: 46px; border-radius: 12px; display: grid; place-items: center;
  background: rgba(255, 255, 255, 0.96);
  border: 1px solid rgba(22, 23, 28, 0.10);
  font-family: var(--display); font-weight: 600; font-size: 17px; color: #4b4b52;
  overflow: hidden; flex: none;
}
html[data-theme="dark"] .ab-logo {
  background: rgba(255, 255, 255, 0.90);
  border-color: rgba(255, 255, 255, 0.16);
}
.ab-logo img { width: 66%; height: 66%; object-fit: contain; display: block; }

/* Below the point where four columns of text stop working, the row becomes a
   block: logo and company on one line, title under it, dates last. The date
   column is what breaks first, because it must never wrap. */
@media (max-width: 780px) {
  /* Flex rather than a grid span here. A spanning logo cell needs the other
     three children to land in an exact order, and one wrapped line puts them
     in the wrong track. Flex with an explicit basis cannot get that wrong. */
  .ab-role { display: flex; flex-wrap: wrap; align-items: center; gap: 5px 12px; padding: 18px 0; }
  .ab-logo { width: 40px; height: 40px; border-radius: 10px; font-size: 15px; flex: none; }
  .ab-role .co { flex: 1 1 auto; }
  .ab-role .ti { flex: 1 0 100%; }
  .ab-role .yr { flex: 1 0 100%; font-size: 13px; }
}

.btn {
  display: inline-flex; align-items: center; gap: 9px; margin-top: 26px;
  font-family: var(--body); font-size: 14.5px; font-weight: 500; line-height: var(--lh-ui);
  padding: 11px 20px; border-radius: 100px;
  border: 1px solid var(--border-strong); color: var(--text); background: transparent;
  transition: background 0.24s ease, color 0.24s ease, border-color 0.24s ease;
}
/* Fills with the ink rather than outlining in it, so the colour arrives as
   a surface with dark text on it. */
.btn:hover { background: var(--sel); border-color: var(--sel); color: #16171c; }

.ab-elsewhere { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
.ab-else {
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: 22px; background: var(--surface);
  transition: border-color 0.2s ease, transform 0.2s ease;
}
a.ab-else:hover { border-color: var(--sel); transform: translateY(-2px); }
.ab-else h3 { font-family: var(--display); font-weight: 600; font-size: 17px; margin-bottom: 6px; }
.ab-else p { font-size: 14px; line-height: 1.55; color: var(--text-dim); }

@media (max-width: 820px) {
  .ab-intro { grid-template-columns: 1fr; gap: 30px; }
  .ab-photo { max-width: 280px; }
  .ab-role { grid-template-columns: 1fr auto; gap: 6px 18px; }
  .ab-role .ti { grid-column: 1 / -1; }
}

/* ---------- ZOOM GALLERY (details.html) ----------
   Not a case study. There is no arc here, so there is no facts strip and no
   sections: just a shelf of loops, each with two sentences. The density is
   the argument. It is also the only page whose credibility does not depend
   on Joana writing anything, which is why it is the one to finish first. */
.zoom-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
  gap: 34px 26px; margin-bottom: 64px;
}
.zoom { margin: 0; display: flex; flex-direction: column; gap: 14px; }
.zoom-media {
  aspect-ratio: 4 / 3; border-radius: var(--radius); overflow: hidden;
  border: 1px solid color-mix(in srgb, var(--chan-hl) 26%, transparent);
  background:
    repeating-linear-gradient(-45deg,
      color-mix(in srgb, var(--chan-hl) 11%, transparent) 0 2px,
      transparent 2px 11px),
    color-mix(in srgb, var(--chan-hl) 6%, var(--surface));
  display: flex; align-items: center; justify-content: center;
  font-family: var(--body); font-size: 12px; font-weight: 500;
  color: var(--chan);
}
.zoom-media video, .zoom-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
.zoom figcaption h3 {
  font-family: var(--display); font-weight: 600; font-size: 17px;
  letter-spacing: -0.006em; margin-bottom: 6px;
}
.zoom figcaption p { font-size: 14px; line-height: 1.56; color: var(--text-dim); }

/* A standing constraint, not a placeholder. Kept visually quiet. */
.cs-note {
  font-size: 13px; line-height: 1.55; color: var(--text-dim);
  border-top: 1px solid var(--border); padding-top: 18px; margin-bottom: 54px;
  max-width: 62ch;
}

/* ============================================================
   THE DECK. Markup in index.html and deck-lab.html, behaviour in
   deck.js, appearance here.

   The frame of each card is its PROJECT channel, never black and
   never white. That is the answer to the problem in the Figma
   exploration: a black frame sets up an expectation that opening
   the card lands you on a black page, which collides with the
   theme toggle. A channel-coloured frame belongs to the project
   rather than to a mode, so it survives both.
   ============================================================ */

.deck-stage {
  position: relative;
  height: 520px;
  margin: 10px 0 6px;
  perspective: 1400px;
  touch-action: pan-y;
  user-select: none;
  /* The fan reaches past the column, which is the point, but past the
     VIEWPORT is a horizontal scrollbar. `clip` on one axis only, because
     `hidden` would also cut the cards that sink below the centre and would
     make this a scroll container. */
  overflow-x: clip;
  overflow-y: visible;
  /* Full-bleed, so the fan is clipped by the screen rather than by the text
     column. Clipping a card mid-way through the reading column looks like a
     mistake; clipping it at the edge of the display looks like a deck that
     carries on. */
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
}

.deck-card {
  position: absolute; top: 0; left: 50%;
  width: 320px; height: 420px;
  margin-left: -160px;
  border-radius: 22px;
  padding: 12px 12px 0;
  background: var(--chan);
  border: 0; cursor: pointer;
  display: flex; flex-direction: column;
  text-align: left;
  /* One transition covers position, rotation, scale and depth, so a card
     travelling from the far right to the centre is a single arc rather than
     four properties finishing at four different times. */
  will-change: transform, scale;
}
.deck-card .screen {
  position: relative;
  flex: 1;
  border-radius: 13px;
  background: var(--surface);
  display: flex; align-items: center; justify-content: center;
  font-family: var(--body); font-size: 12.5px; color: var(--text-dim);
  overflow: hidden;
}
/* Same reveal mechanic as the grid and rail cards: tapping a front-facing
   card with nothing to open yet swaps its "Coming soon" text for a short
   note, in place, instead of leading anywhere. */
.deck-card .screen .tbd-label, .deck-card .screen .tbd-reveal {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  text-align: center; padding: 0 26px;
  transition: opacity 0.3s var(--ease), transform 0.3s var(--ease);
}
.deck-card .screen .tbd-reveal {
  /* Not --chan: that's the vivid circle colour painted across the whole card
     as its frame, sat under near-black face text on purpose. This text sits
     on --surface instead (page-coloured, not the frame), so it needs the
     same text-safe value the grid and rail use for the same note. */
  color: var(--chan-txt); line-height: 1.5; opacity: 0; transform: translateY(6px);
}
.deck-card.-revealed .screen .tbd-label { opacity: 0; transform: translateY(-6px); }
.deck-card.-revealed .screen .tbd-reveal { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) {
  .deck-card .screen .tbd-label, .deck-card .screen .tbd-reveal { transition: none; }
}
/* The get-in-touch card, built from the design file (Noto, nodes 225:465
   light / 225:626 dark). Not a project with the screenshot missing, so no
   screen/face split: one coloured surface washed white toward the top, the
   wave, the pitch, and the email line.

   The deck's glass bezel, its light-mode tint and dark mode's frosted pane
   all restyle .deck-card, because every other card wants its colour on the
   edge and a picture in the middle. This card has no middle to protect, so
   it opts out of all three, and out of the sheen. */
@font-face {
  /* "Futura" as a family name can resolve to Futura Condensed Medium on
     macOS at weight 500. Naming the exact face pins the regular width; Jost
     (the Google Fonts Futura revival) covers everything without Futura. */
  font-family: 'RGB Futura Medium';
  src: local('Futura-Medium'), local('Futura Medium');
  font-weight: 500; font-style: normal;
}
.deck-card.-cta {
  --cta-base: #ff87ff;
  padding: 0;
  background:
    linear-gradient(180deg, rgba(255,255,255,0.8) 0%, rgba(255,255,255,0) 100%),
    var(--cta-base) !important;
  -webkit-backdrop-filter: none !important;
  backdrop-filter: none !important;
  border: 1px solid rgba(255,135,255,0.88) !important;
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.75),
    0 16px 20px rgba(24,20,14,0.14) !important;
}
/* Dark: the same frosted pane every other dark card uses (see the dark
   :not([data-deck="glass"]) .deck-card rule below), so it sits in the deck
   instead of glowing out of it. White ink, a blue edge and bezel line like
   the other blue card's, and the light bezel band (design node 225:626). */
html[data-theme="dark"] .deck-card.-cta {
  background: color-mix(in srgb, var(--chan) 6%, rgba(38, 42, 54, 0.93)) !important;
  -webkit-backdrop-filter: blur(26px) saturate(1.5) !important;
  backdrop-filter: blur(26px) saturate(1.5) !important;
  /* Same 1px channel-blue edge as the other dark cards, graded by depth
     through --edge, and the bezel hairline in that same blue. */
  border-color: color-mix(in srgb, var(--chan) var(--edge, 76%), transparent) !important;
}
html[data-theme="dark"] .deck-card.-cta .cta-bezel {
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, var(--chan) var(--edge, 76%), transparent),
    0 0 0 40px rgba(255,255,255,0.2);
}
html[data-theme="dark"] .deck-card.-cta .cta-face,
html[data-theme="dark"] .deck-card.-cta .cta-line,
html[data-theme="dark"] .deck-card.-cta .cta-sub { color: #fff; }
/* The bezel, from the design file's two stroke layers: a band washed 20%
   white between the card edge and an inset frame, and a 40% white hairline
   on that frame. It sits at the same inset as the project cards' screens,
   so the card holds the deck's rhythm. One element does both: the hairline
   is an inset shadow, and the band is a wide spread shadow that the card's
   rounded corners clip. */
.deck-card.-cta { overflow: hidden; }
.deck-card.-cta .cta-bezel {
  position: absolute; inset: 10.5px 9.88px;
  border-radius: 13.8px;
  box-shadow:
    inset 0 0 0 1px rgba(255,255,255,0.4),
    0 0 0 40px rgba(255,255,255,0.2);
  pointer-events: none;
}
.deck-card.-cta::after { display: none !important; }
.deck-card.-cta .cta-face {
  position: absolute; inset: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 14px; padding: 32px;
  color: #1a1a1a; text-align: center;
}
.deck-card.-cta .cta-wave { flex: none; overflow: visible; }
/* The wave only plays on the card that's actually in front, same rule the
   video clips follow. The hand rocks from the wrist around its resting
   pose in the design; the two motion lines pulse in and out beside it. */
.deck-card.-cta .cta-wave-hand {
  transform-box: fill-box;
  transform-origin: 40% 95%;
}
.deck-card.-cta[aria-current="true"] .cta-wave-hand {
  animation: cta-wave-hand 2.6s ease-in-out infinite;
}
.deck-card.-cta[aria-current="true"] .cta-wave-trace {
  animation: cta-wave-trace 2.6s ease-in-out infinite;
}
@keyframes cta-wave-hand {
  0%, 100% { transform: rotate(0deg); }
  25%      { transform: rotate(14deg); }
  50%      { transform: rotate(-6deg); }
  75%      { transform: rotate(10deg); }
}
@keyframes cta-wave-trace {
  0%, 100% { opacity: 1; }
  25%      { opacity: 0.25; }
  50%      { opacity: 1; }
  75%      { opacity: 0.25; }
}
@media (prefers-reduced-motion: reduce) {
  .deck-card.-cta[aria-current="true"] .cta-wave-hand,
  .deck-card.-cta[aria-current="true"] .cta-wave-trace { animation: none; }
}
.deck-card.-cta .cta-line {
  padding: 0 16px;
  font-family: 'RGB Futura Medium', Jost, 'Century Gothic', sans-serif;
  font-weight: 500; font-size: 19px; line-height: 25.65px;
  color: #1a1a1a;
}
/* Reads as a link without being a second interactive element nested in the
   card's own button; the whole card is already the link. */
.deck-card.-cta .cta-sub {
  display: flex; align-items: center; gap: 6px;
  font-family: var(--body); font-weight: 500; font-size: 14px;
  color: #16171c;
}
.deck-card.-cta .cta-sub .ico { flex: none; }
.deck-card.-cta .cta-sub span { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px; text-decoration-skip-ink: none; }
.deck-card .screen img,
.deck-card .screen video { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The card is what gets dragged, never the picture on it. Without this a
   press-and-drag on the front card picked up the image itself (the
   browser's native image drag) and pulled it out of the card. */
.deck-card .screen img,
.deck-card .screen video {
  -webkit-user-drag: none; user-select: none; -webkit-touch-callout: none;
  pointer-events: none;
}
/* The community card: the NOCD logo on a white fade at the top, over two
   upright phones that together read as one continuous feed. The right
   phone hangs down from above the card, so only its lower half shows; the
   left one rises from below, so only its upper half shows, with a gap
   between them. When the card comes to the front both slide in from
   outside the card, a beat apart; when it leaves they ease back out, so
   coming back round never jumps. Transitions rather than keyframes: the
   phones always move from wherever they are. Both phones are the same real
   screenshot from the design file, upright. */
html[data-theme] .deck-stage .deck-card .screen.-phones {
  display: block;
  background:
    linear-gradient(162deg, rgba(187,251,255,0.5) 2.4%, rgba(218,251,254,0) 60%),
    linear-gradient(180deg, var(--nocd-top, rgba(255,255,255,0.9)) 20.4%, rgba(255,255,255,0.1) 100%),
    var(--nocd-base),
    #fff;
}
/* The white fade the logo sits on, above the phones, in both themes. */
.deck-card .screen.-phones::before {
  content: ""; position: absolute; inset: 0 0 auto 0; height: 34%; z-index: 3; pointer-events: none;
  background: linear-gradient(180deg, #fff 0%, #fff 42%, rgba(255,255,255,0) 100%);
}
/* Optically centred: the mark's dots carry less weight than the wordmark,
   so a true centre reads as sitting right. Nudged a little left. */
.deck-card .screen.-phones .ph-logo {
  position: absolute; left: 50%; top: 7%; z-index: 4; width: 30%; height: auto;
  transform: translateX(-56%); object-fit: contain;
}
.deck-card .screen.-phones .ph {
  position: absolute; width: 43%; aspect-ratio: 1170 / 2532;
  border-radius: 14px; overflow: hidden; background: #fff;
  box-shadow: 0 10px 24px rgba(22,23,28,0.16), 0 0 0 1px rgba(22,23,28,0.06);
  /* Leaving: ease back out, a little quicker. */
  transition: transform 0.8s cubic-bezier(0.45,0,0.55,1);
}
.deck-card .screen.-phones .ph img { width: 100%; height: auto; object-fit: initial; display: block; }
/* Bottom phone (left): top edge at 39%, so most of its upper half shows
   (design file node 269:998). Rests fully below the card. */
.deck-card .screen.-phones .ph1 { left: 4.5%; top: 39%; z-index: 2; transform: translateY(105%); }
/* Top phone (right): bottom edge at 77%, so most of its lower half shows
   (design file node 269:998). Rests fully above the card. */
.deck-card .screen.-phones .ph2 { left: 52.5%; top: 77%; z-index: 1; translate: 0 -100%; transform: translateY(-105%); }
/* Arriving: settle in slowly, a beat apart. */
.deck-card[aria-current="true"] .screen.-phones .ph {
  transform: none;
  transition: transform 1.3s cubic-bezier(0.16,1,0.3,1);
}
.deck-card[aria-current="true"] .screen.-phones .ph1 { transition-delay: 0.12s; }
.deck-card[aria-current="true"] .screen.-phones .ph2 { transition-delay: 0.32s; }
html[data-theme="light"] .deck-card .screen.-phones::after {
  content: ""; position: absolute; inset: 0; z-index: 3; border-radius: inherit; pointer-events: none;
  box-shadow: inset 0 0 0 1px rgba(22,23,28,0.1);
}
@media (prefers-reduced-motion: reduce) {
  .deck-card .screen.-phones .ph,
  .deck-card[aria-current="true"] .screen.-phones .ph { transition: none; transform: none; }
}

/* The community teaser: three real posts on the community gradient that
   fade in one after another, overlapping a little, hold, then clear and
   start again. Only while the card is in front; at rest all three sit
   there, so a side card still shows what it is. Each post gets a soft
   teal echo behind it, like the stacked cards in the thumbnail. */
/* Extra specificity so dark mode's flat screen grey doesn't replace the
   gradient: the posts belong on the community's own ground in both themes. */
html[data-theme] .deck-stage .deck-card .screen.-teaser {
  display: block;
  background:
    linear-gradient(162deg, rgba(187,251,255,0.5) 2.4%, rgba(218,251,254,0) 60%),
    linear-gradient(180deg, var(--nocd-top, rgba(255,255,255,0.9)) 20.4%, rgba(255,255,255,0.1) 100%),
    var(--nocd-base),
    #fff;
}
.deck-card .screen.-teaser .tz { position: absolute; z-index: 1; width: 78%; }
.deck-card .screen.-teaser .tz::before {
  content: ""; position: absolute; inset: 0; z-index: -1;
  border-radius: 11px;
  background: rgba(93,198,204,0.26);
  transform: translate(7px, 7px);
}
.deck-card .screen.-teaser .tz img {
  width: 100%; height: auto; object-fit: initial;
  filter: drop-shadow(0 6px 12px rgba(12,12,13,0.1));
}
.deck-card .screen.-teaser .tz1 { left: 4%; top: 6%; }
.deck-card .screen.-teaser .tz2 { left: 18%; top: 35%; }
/* The long post is cropped to its opening and dissolves at the bottom. */
.deck-card .screen.-teaser .tz3 {
  left: 6%; top: 73%; height: 40%; overflow: hidden;
  -webkit-mask-image: linear-gradient(to bottom, #000 45%, transparent 92%);
          mask-image: linear-gradient(to bottom, #000 45%, transparent 92%);
}
.deck-card .screen.-teaser .tz3::before { display: none; }
.deck-card[aria-current="true"] .screen.-teaser .tz1 { animation: tz-1 8s ease-out infinite; }
.deck-card[aria-current="true"] .screen.-teaser .tz2 { animation: tz-2 8s ease-out infinite; }
.deck-card[aria-current="true"] .screen.-teaser .tz3 { animation: tz-3 8s ease-out infinite; }
@keyframes tz-1 {
  0%        { opacity: 0; transform: translateY(10px); }
  7%, 84%   { opacity: 1; transform: none; }
  91%, 100% { opacity: 0; transform: none; }
}
@keyframes tz-2 {
  0%, 15%   { opacity: 0; transform: translateY(10px); }
  22%, 84%  { opacity: 1; transform: none; }
  91%, 100% { opacity: 0; transform: none; }
}
@keyframes tz-3 {
  0%, 30%   { opacity: 0; transform: translateY(10px); }
  37%, 84%  { opacity: 1; transform: none; }
  91%, 100% { opacity: 0; transform: none; }
}
/* Light mode: the gradient's warm bottom ran into the yellow frame. A
   hairline gives the screen its own edge and the bottom eases to white. */
html[data-theme="light"] .deck-card .screen.-teaser::after {
  content: ""; position: absolute; inset: 0; z-index: 2; border-radius: inherit; pointer-events: none;
  background: linear-gradient(to bottom, transparent 70%, rgba(255,255,255,0.9) 100%);
  box-shadow: inset 0 0 0 1px rgba(22,23,28,0.1);
}
@media (prefers-reduced-motion: reduce) {
  .deck-card[aria-current="true"] .screen.-teaser .tz { animation: none; }
}
.deck-card .face {
  padding: 15px 6px 17px;
  display: flex; flex-direction: column; gap: 3px;
  color: #16171c;
}
.deck-card .face .who { font-family: var(--display); font-weight: 600; font-size: 19px; }
.deck-card .face .what { font-family: var(--body); font-size: 12.5px; opacity: 0.78; }
/* The org is the part someone scans for, so it carries the weight and the
   surface type trails it in the same line. */
.deck-card .face .what b { font-weight: 600; opacity: 1; }

/* Off-centre cards sit back by DESATURATING, never by going transparent.
   Transparency let the cards behind show through the ones in front, which
   broke the one thing a deck has to do: read as a stack of solid objects. */
/* Depth by desaturation. Driven by a variable rather than `filter` on the
   card itself: `filter` makes an element a backdrop root, which would stop
   the glass below from ever sampling the page behind it. */
/* ONE transition declaration for the whole component.

   This used to be split: `transform` here, and `scale, box-shadow` on the
   front card. A second `transition` on a more specific selector REPLACES the
   list, it does not extend it, so the card at the centre silently lost its
   transform transition. The card arriving at centre snapped into place while
   every other card glided, which is what read as a broken carousel. */
.deck-card {
  --sat: 1;
  transition: transform 0.62s cubic-bezier(0.22,0.61,0.20,1),
              scale 0.34s var(--ease),
              box-shadow 0.4s var(--ease),
              border-color 0.4s var(--ease);
}
/* Cards away from the front sit in shade rather than losing their color.
   A desaturate filter used to do this job, and on real screenshots it read
   as a color burn. A plain dark veil over the whole card keeps every hue
   true and only lowers the light, the way a card further from the lamp
   would look. */
.deck-card::before {
  content: ""; position: absolute; inset: 0; border-radius: inherit;
  pointer-events: none; z-index: 5;
  background: #14121c; opacity: 0;
  transition: opacity 0.45s ease;
}
.deck-card[data-d="1"]::before { opacity: 0.10; }
.deck-card[data-d="2"]::before { opacity: 0.18; }
html[data-theme="dark"] .deck-card[data-d="1"]::before { opacity: 0.28; }
html[data-theme="dark"] .deck-card[data-d="2"]::before { opacity: 0.42; }

/* THE WHOLE CARD PULSES, not the picture inside it.

   Scaling only the screen made the frame look like a window the image was
   sliding around behind. The card is one object, so the card is what responds.

   HOW IT COMPOSES WITHOUT FIGHTING deck.js. The layout code owns `transform`
   outright and rewrites it every frame, so a CSS hover rule cannot touch it.
   This uses the standalone `scale` property instead, which is applied
   independently and multiplies on top. It is safe here specifically because
   the front card's inline transform is exactly
   `translate3d(0,0,0) rotate(0deg) scale(1)` -- d is zero at the centre -- so
   there is no translation for the extra scale to amplify and the card grows
   in place rather than drifting.

   The pulse itself is deliberately shallow, 1.030 to 1.048. It is a sign of
   life on the thing under the pointer, not a bounce.

   The 0.34s animation-delay matters: an animation starts AT its first
   keyframe, so without it the card would jump straight to 1.030. The delay
   lets the transition carry it there first, and the loop picks up from
   exactly where the transition left off. */
@keyframes deck-pulse {
  0%, 100% { scale: 1.035; }
  50%      { scale: 1.072; }
}
.deck-stage.-open .deck-card[data-d="0"] { cursor: pointer; }
.deck-stage.-open .deck-card[data-d="0"]:not(.-soon):hover,
.deck-stage.-open .deck-card[data-d="0"]:not(.-soon):focus-visible {
  scale: 1.035;
  animation: deck-pulse 2.4s var(--ease) 0.34s infinite;
}
/* An in-progress card does not pulse the way an openable one does, since a
   pulse invites a visit to a whole other page. It still answers a tap
   though, cursor included, by revealing its note in place. */

@media (prefers-reduced-motion: reduce) {
  .deck-stage.-open .deck-card[data-d="0"]:not(.-soon):hover,
  .deck-stage.-open .deck-card[data-d="0"]:not(.-soon):focus-visible {
    animation: none; scale: 1.02;
  }
}
.deck-card:focus-visible { outline: 3px solid var(--focus); outline-offset: 5px; }

/* ============================================================
   GLASS. Progressive enhancement, gated on @supports.

   The fallback above is the solid channel-coloured card, unchanged. Anything
   without backdrop-filter keeps exactly what it has today. Everything below
   only exists where the browser can actually sample what is behind the card.

   TWO DECISIONS WORTH KEEPING:

   1. The SCREEN STAYS OPAQUE. Only the bezel is glass. A fully translucent
      card would put the cards behind it back inside the card in front, which
      is the thing that was wrong with the old opacity fade. A glass bezel
      around a solid display is also what the real object would be.

   2. THE RIM IS THE CHANNEL COLOUR, NOT WHITE. A conventional glass edge is a
      white specular line, which on this site would be a fourth colour with no
      meaning. Using the project's own colour turns the material into part of
      the identity: in dark mode it reads as neon, in light as a tinted edge.
   ============================================================ */
@supports ((backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px))) {
  .deck-card {
    background: color-mix(in srgb, var(--chan) 26%, transparent);
    -webkit-backdrop-filter: blur(22px) saturate(1.7);
    backdrop-filter: blur(22px) saturate(1.7);
    border: 1px solid color-mix(in srgb, var(--chan) 52%, transparent);
    box-shadow:
      /* the specular line along the top edge */
      inset 0 1px 0 color-mix(in srgb, var(--chan) 78%, transparent),
      /* a soft inner bloom so the bezel has thickness */
      inset 0 0 26px color-mix(in srgb, var(--chan) 20%, transparent),
      0 18px 44px rgba(0,0,0,0.22);
  }

  /* The sheen. `--sheen` is written per frame by deck.js from the card's own
     position in the fan, so the highlight travels across the bezel as the
     deck moves. That travel is the whole difference between glass and a
     translucent rectangle: real glass shows you where the light is. */
  .deck-card::after {
    content: ""; position: absolute; inset: 0; border-radius: inherit;
    pointer-events: none; z-index: 2;
    background: linear-gradient(100deg,
      transparent 0%,
      color-mix(in srgb, var(--chan) 42%, transparent) 46%,
      color-mix(in srgb, #ffffff 30%, transparent) 50%,
      color-mix(in srgb, var(--chan) 42%, transparent) 54%,
      transparent 100%);
    background-size: 260% 100%;
    background-position: calc(50% + var(--sheen, 0%) * 1) 0;
    opacity: 0.55;
    mix-blend-mode: screen;
  }
  /* The sheen belongs to the BEZEL, not to the picture. Lifting the screen
     and the caption above it keeps the light on the frame and off the
     product shot, which otherwise picks up a coloured streak across whatever
     the screenshot happens to be. */
  .deck-card .screen, .deck-card .face { position: relative; z-index: 3; }

  /* Near-black on a translucent dark card is unreadable, so the face ink
     follows the mode instead of being pinned. */
  .deck-card .face { color: var(--glass-ink); }

  /* DARK: the material sits close to the page, and the colour lives on the
     EDGE. The previous version tinted the whole card and bloomed a halo
     around it, which made five glowing slabs and buried the one thing the
     colour is for, which is telling projects apart. A near-background pane
     with a bright rim does that job with a fraction of the light. */
  html[data-deck="glass"][data-theme="dark"] .deck-card {
    background: color-mix(in srgb, var(--chan) 9%, rgba(17,18,25,0.66));
    border-color: color-mix(in srgb, var(--chan) 78%, transparent);
    box-shadow:
      inset 0 1px 0 color-mix(in srgb, var(--chan) 42%, transparent),
      0 18px 44px rgba(0,0,0,0.5);
  }
  /* Hover brightens the rim rather than adding a halo. The glow that is left
     is barely more than a rim light. */
  html[data-deck="glass"][data-theme="dark"] .deck-stage.-open .deck-card[data-d="0"]:not(.-soon):hover {
    border-color: var(--chan);
    box-shadow:
      inset 0 1px 0 color-mix(in srgb, var(--chan) 62%, transparent),
      0 0 14px color-mix(in srgb, var(--chan) 20%, transparent),
      0 22px 52px rgba(0,0,0,0.55);
  }
  /* The sheen loses its white core in dark. A white glint on a dark pane
     reads as glare; the channel colour reads as an edge catching light. */
  html[data-deck="glass"][data-theme="dark"] .deck-card::after {
    background: linear-gradient(100deg,
      transparent 0%,
      color-mix(in srgb, var(--chan) 30%, transparent) 47%,
      color-mix(in srgb, var(--chan) 52%, transparent) 50%,
      color-mix(in srgb, var(--chan) 30%, transparent) 53%,
      transparent 100%);
    background-size: 260% 100%;
    opacity: 0.32;
  }
  html[data-theme="light"]:not([data-deck="solid"]) .deck-card {
    background: color-mix(in srgb, var(--chan) 64%, rgba(255,255,255,0.26));
    border-color: color-mix(in srgb, var(--chan) 88%, transparent);
    box-shadow:
      inset 0 1px 0 rgba(255,255,255,0.75),
      inset 0 0 26px color-mix(in srgb, var(--chan) 30%, transparent),
      0 16px 40px rgba(24,20,14,0.14);
  }
  html[data-theme="light"] .deck-stage.-open .deck-card[data-d="0"]:hover {
    box-shadow:
      inset 0 1px 0 rgba(255,255,255,0.9),
      inset 0 0 30px color-mix(in srgb, var(--chan) 40%, transparent),
      0 22px 52px rgba(24,20,14,0.2);
  }
  .deck-card::after { transition: opacity 0.4s ease; }
  .deck-stage.-open .deck-card[data-d="0"]:hover::after { opacity: 0.8; }
}


/* The dial. Five stops; the active one stretches and takes that card's
   colour, so the indicator is also a legend for which project is up. */
/* #work now lives on this section (moved off the plain grid below it, see
   index.html) so the "Work" nav link and "Skip to work" land on the deck's
   glanceable fan of all five projects, not past it. scroll-margin-top clears
   the sticky topbar (75px measured) plus real breathing room, so the section
   doesn't start flush against the bar's bottom edge. rgb-shape.js's own
   scroll-memory script reads this same value for its hash-jump math, so a
   click and a restored position land the same place. */
.deck-section { margin-bottom: 26px; scroll-margin-top: 100px; }
/* Close enough to read as part of the deck. At 60px it looked like a separate
   component that happened to be underneath. */
.dial {
  display: flex; gap: 9px; justify-content: center; align-items: center;
  margin: -34px 0 46px;
  /* The negative margin tucks the dots up into the bottom of the deck stage,
     and the stage is positioned, so without this it paints over the dots and
     swallows every click on them. */
  position: relative; z-index: 1;
}
@media (max-width: 760px) { .dial { margin: -18px 0 34px; } }
.dial button {
  width: 9px; height: 9px; padding: 0; border: 0; border-radius: 100px;
  background: var(--border-strong); cursor: pointer;
  transition: width 0.4s var(--ease), background-color 0.4s var(--ease);
}
.dial button[aria-current="true"] { width: 34px; background: var(--dot); }
.dial button:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }

.lab-note {
  max-width: 620px; margin: 0 auto 40px; text-align: center;
  font-size: 14px; line-height: 1.6; color: var(--text-dim);
}
.lab-note kbd {
  font-family: var(--body); font-size: 12px; font-weight: 500;
  border: 1px solid var(--border-strong); border-radius: 5px; padding: 1px 6px;
}

/* An in-progress card in the deck. Same honesty rule as the grid: it looks
   unfinished rather than hiding it behind a hover. The frame keeps its full
   channel colour, so the deck still reads as five colours; only the screen
   inside says there is nothing to open yet. */
/* The hatch marks "no picture yet," independent of whether the card is
   clickable: most of these now link somewhere (a draft, or the general
   in-progress page) even without a screenshot to show. */
.deck-card .screen.-tbd {
  background:
    repeating-linear-gradient(-45deg,
      color-mix(in srgb, var(--chan) 22%, transparent) 0 2px,
      transparent 2px 11px),
    var(--surface);
}
/* An in-progress card is still SELECTABLE: clicking it brings it forward,
   and clicking the one already in front reveals its note. Nothing here is
   inert. */
.deck-card { cursor: pointer; }

@media (max-width: 760px) {
  .deck-stage { height: 430px; }
  .deck-card { width: 250px; height: 340px; margin-left: -125px; }
}
@media (prefers-reduced-motion: reduce) {
  .deck-card { transition: none; }
}

/* ============================================================
   DARK MATERIAL: OFF-BLACK PANE, COLOUR ONLY IN THE OUTLINE.

   This is the default in dark. The glass version is still here and is one
   attribute away: put data-deck="glass" on <html> and every rule above comes
   back.

   The argument for solid: once the tint dropped to 9% and the halo went, the
   glass was doing almost nothing except costing a backdrop-filter on five
   overlapping, animating layers. What was actually carrying the design was
   the coloured edge. So this keeps the edge and drops the rest.

   Two greys, not one. The card is one step above the page and the screen is
   one step below the card, so the picture reads as inset rather than as the
   same surface with a line drawn round it.
   ============================================================ */
html[data-theme="dark"]:not([data-deck="glass"]) .deck-card {
  /* FROSTED, NOT TRANSPARENT. The pane is 93% opaque over a grey that is four
     steps above the page (#0c0d12 -> #1e202b), so it separates clearly. The
     remaining 7% plus a 26px blur is enough for the colour behind to haze
     through and read as material, and nowhere near enough to make a card
     behind it legible. That was the whole failure of the earlier glass: at 9%
     tint it was still a window. */
  background: color-mix(in srgb, var(--chan) 6%, rgba(38, 42, 54, 0.93));
  -webkit-backdrop-filter: blur(26px) saturate(1.5);
  backdrop-filter: blur(26px) saturate(1.5);
  /* 1px, and graded by depth. At 1.5px and a flat 88% the outline was drawing
     itself rather than describing an edge; at a flat 56% every card went murky
     at once. The alpha now follows the same attention rule as everything else,
     so the card you are looking at has the defined edge and the ones behind
     recede. --edge is set per depth just below. */
  border: 1px solid color-mix(in srgb, var(--chan) var(--edge, 76%), transparent);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, var(--chan) 30%, transparent),
    0 18px 44px rgba(0,0,0,0.55);
}
html[data-theme="dark"]:not([data-deck="glass"]) .deck-card[data-d="0"] { --edge: 82%; }
html[data-theme="dark"]:not([data-deck="glass"]) .deck-card[data-d="1"] { --edge: 60%; }
html[data-theme="dark"]:not([data-deck="glass"]) .deck-card[data-d="2"] { --edge: 44%; }

/* One step below the card, so the picture still reads as inset. */
/* The picture area is LIGHTER than the frame, not darker. A real device has a
   dark bezel and a lit display, and once actual screenshots land they will be
   brighter than anything around them, so the placeholder should preview that
   rather than a hole. It is also the largest surface on the card, which is
   why it carries the separation from the page. */
html[data-theme="dark"]:not([data-deck="glass"]) .deck-card .screen {
  background: #333a4b;
  /* A hairline so the display reads as a separate plane from the frame while
     the placeholder is still a flat colour. It disappears under a real
     screenshot, which brings its own edge. */
  box-shadow: inset 0 0 0 1px rgba(255,255,255,0.045);
}

/* The sheen comes back, because there is a material for light to be on again.
   Very low: it is a hint of frost, not a glint. */
html[data-theme="dark"]:not([data-deck="glass"]) .deck-card::after {
  display: block;
  background: linear-gradient(100deg,
    transparent 0%,
    color-mix(in srgb, var(--chan) 22%, transparent) 47%,
    color-mix(in srgb, var(--chan) 38%, transparent) 50%,
    color-mix(in srgb, var(--chan) 22%, transparent) 53%,
    transparent 100%);
  background-size: 260% 100%;
  opacity: 0.24;
}

html[data-theme="dark"]:not([data-deck="glass"]) .deck-stage.-open .deck-card[data-d="0"]:not(.-soon):hover {
  --edge: 96%;
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, var(--chan) 46%, transparent),
    0 0 0 1px color-mix(in srgb, var(--chan) 28%, transparent),
    0 22px 52px rgba(0,0,0,0.6);
}
/* The in-progress hatch has to sit on the new screen tone. */
html[data-theme="dark"]:not([data-deck="glass"]) .deck-card .screen.-tbd {
  background:
    repeating-linear-gradient(-45deg,
      color-mix(in srgb, var(--chan) 18%, transparent) 0 2px,
      transparent 2px 11px),
    #232735;
}

/* The same solid material on the light side, off-white instead of off-black.
   Not the default: light glass is doing more work there, because a
   multiply-based palette needs the tint to carry chroma it cannot get any
   other way. Switch with data-deck="solid" on <html> to compare. */
html[data-theme="light"][data-deck="solid"] .deck-card {
  background: #ffffff;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  border: 1.5px solid color-mix(in srgb, var(--chan) 92%, transparent);
  box-shadow: 0 16px 40px rgba(24,20,14,0.13);
}
html[data-theme="light"][data-deck="solid"] .deck-card .screen { background: #f3f1ea; }
html[data-theme="light"][data-deck="solid"] .deck-card::after { display: none; }
html[data-theme="light"][data-deck="solid"] .deck-stage.-open .deck-card[data-d="0"]:not(.-soon):hover {
  border-color: var(--chan);
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--chan) 40%, transparent),
    0 22px 52px rgba(24,20,14,0.18);
}
html[data-theme="light"][data-deck="solid"] .deck-card .screen.-tbd {
  background:
    repeating-linear-gradient(-45deg,
      color-mix(in srgb, var(--chan) 24%, transparent) 0 2px,
      transparent 2px 11px),
    #f7f5ef;
}

/* ---------- CASE STUDY: pieces added for the Training Platform ---------- */

/* A line that sits between the facts strip and the first section, for the one
   sentence that frames everything after it. Bigger than body, quieter than a
   pull quote. */
.cs-standfirst {
  font-size: 18px; line-height: 1.6; color: var(--text);
  max-width: 62ch; margin-bottom: 40px;
}

/* Figure plus caption as one block. The caption is written as a BRIEF: it says
   what the picture has to communicate, so the slot is useful before the image
   exists and still reads as a caption after. */
/* Figures take the same measure as the body copy, so an image starts and ends
   where the paragraph above it does. The -wide and -mid escapes below are still
   here for anything that needs to break out or pull in. */
.cs-fig { margin: 0 0 54px; }
.cs-fig .cs-media { margin-bottom: 12px; }
/* A real image, once one exists. width/height on the tag reserves the space so
   nothing below it jumps while the file loads. */
.cs-fig img {
  display: block; width: 100%; height: auto;
  border-radius: var(--radius); margin-bottom: 12px;
}
/* <picture> is a wrapper, not a box: without this it stays inline and leaves a
   few px of baseline gap under the image. */
.cs-fig picture { display: block; }
/* A supporting diagram, sized to the reading column rather than the page. A
   figure at full width reads as a hero, and a hero makes a promise about
   importance that a context diagram cannot keep. */
.cs-fig.-mid img { max-width: 760px; }
.cs-fig.-mid figcaption { max-width: 760px; }

/* Breaks out past the reading column, up to the width of the viewport. For
   artifacts whose whole point is how much of them there is: shown small, a
   blueprint just looks like a small blueprint. */
.cs-fig.-wide {
  margin-left: calc(50% - 50vw); margin-right: calc(50% - 50vw);
  padding: 0 24px;
}
.cs-fig.-wide img { max-width: 1600px; margin-left: auto; margin-right: auto; }
.cs-fig.-wide figcaption { max-width: 92%; margin: 0 auto; }
@media (max-width: 860px) { .cs-fig.-wide { margin-left: 0; margin-right: 0; padding: 0; } }

/* A figure whose argument lives in the labels printed on it. Shrunk to a phone
   width its type lands around 3px, which is not a smaller version of the point,
   it is the absence of it. So below the breakpoint, stop scaling and let the
   reader pan it at a size where the words are words. The blueprint does the
   opposite on purpose: there, the size IS the point and reading it is not. */
@media (max-width: 860px) {
  .cs-fig.-pan .pan-box {
    overflow-x: auto; -webkit-overflow-scrolling: touch;
    border-radius: var(--radius); scrollbar-width: thin;
  }
  .cs-fig.-pan .pan-box img { width: 900px; max-width: none; }
  .cs-fig.-pan figcaption::after {
    content: "Drag the diagram sideways to read it.";
    display: block; margin-top: 7px; opacity: .75;
  }
}
/* The caption belongs to the image, so it takes the image's width and centres
   under it rather than running to a measure of its own. Slightly inset so the
   two edges read as related without the caption becoming a second block. */
.cs-fig figcaption {
  font-size: 13px; line-height: 1.55; color: var(--text-dim);
  max-width: 92%; margin: 0 auto; text-align: center;
}

/* The invitation under a clip. It sits below the caption, quieter than body
   text, and the links carry the same underline the footer uses: the colour
   arrives on hover rather than tinting the text, which keeps contrast high on
   both paper and ink. The address is written out as well as linked, because a
   mail link does nothing for someone whose browser has no mail app attached. */
.cs-ask {
  margin: 8px auto 0; max-width: 92%; text-align: center;
  font-size: 13px; line-height: 1.55; color: var(--text-dim);
}
/* Underlined with the text's own decoration rather than a drawn bar, because
   these links wrap across lines on a phone and a positioned bar only follows
   the first line. */
.cs-ask a {
  color: var(--text);
  text-decoration: underline; text-decoration-thickness: 2px;
  text-underline-offset: 3px; text-decoration-color: var(--kw3);
  transition: text-decoration-color .2s ease;
}
.cs-ask a:nth-of-type(2) { text-decoration-color: var(--kw2); }
.cs-ask a:nth-of-type(3) { text-decoration-color: var(--kw1); }
.cs-ask a:hover { text-decoration-color: currentColor; }


/* ---------- BEFORE / AFTER WIPE ----------
   .ba is the positioning context and .ba-stage is the frame. They are separate
   so full screen can resize the frame without the figure's own layout fighting
   it.

   The two screens were captured at different widths (2056 and 1441), so fitting
   both to the frame renders the same UI at different sizes and the top navs sit
   at different heights. `--ratio` is before-width / after-width: the after fills
   the frame, the before is drawn that much larger, and both land at one scale so
   the navs line up. Matched exports would make --ratio 100%.

   The clip runs on the WRAPPER, not the image. Clipping the image itself would
   measure the percentage against the image's own oversized box and the handle
   would stop tracking the pointer.

   The control is a real <input type="range">, invisible over the frame. A div
   with a pointer listener looks identical and is unusable by keyboard. */
.ba { --pos: 50%; --ratio: 100%; position: relative; margin-bottom: 12px; }
.ba-stage {
  position: relative; aspect-ratio: 16 / 9; overflow: hidden;
  border-radius: var(--radius); touch-action: pan-y;
}
.cs-fig .ba-stage img {
  position: absolute; top: 0; left: 0;
  width: 100%; height: auto; border-radius: 0; margin: 0;
  /* .cs-fig.-wide caps images at 1600px. The oversized layer has to escape that
     cap or it gets clamped back and the two scales stop matching. */
  max-width: none;
}
.ba-wipe {
  position: absolute; inset: 0; overflow: hidden;
  clip-path: inset(0 calc(100% - var(--pos)) 0 0);
}
.cs-fig .ba-wipe img { width: var(--ratio); }
.ba-range {
  position: absolute; inset: 0; width: 100%; height: 100%; margin: 0;
  opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none;
  background: none;
}
.ba-line {
  position: absolute; top: 0; bottom: 0; left: var(--pos); width: 2px;
  background: var(--surface); box-shadow: 0 0 0 1px var(--border-strong);
  pointer-events: none;
}
.ba-grip {
  position: absolute; top: 50%; left: var(--pos);
  width: 38px; height: 38px; margin: -19px 0 0 -19px; border-radius: 50%;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border-strong);
  box-shadow: 0 2px 10px rgba(0,0,0,.28);
  display: grid; place-items: center;
  font-family: var(--body); font-size: 15px; line-height: var(--lh-ui);
  pointer-events: none;
}
.ba-range:focus-visible ~ .ba-grip { outline: 3px solid var(--focus); outline-offset: 3px; }

/* Both labels and the handle sit ON the screenshots, not on the page, so they
   carry a surface of their own rather than relying on whatever pixel is behind
   them. --surface plus --border-strong reads in both themes: a white pill with
   dark ink in light, a dark pill with light ink in dark, and the border keeps
   the edge visible where pill and screenshot are the same value. */
.ba-tag {
  position: absolute; top: 12px; padding: 5px 11px; border-radius: 99px;
  font-family: var(--body); font-size: 11px; font-weight: 500;
  line-height: var(--lh-ui);
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border-strong);
  pointer-events: none;
  transition: opacity .25s ease, transform .25s ease,
              background-color .25s ease, color .25s ease;
}
.ba-tag.-l { left: 12px; }
.ba-tag.-r { right: 12px; }

/* Each label tracks how much of its own screen is on show, so the pair reads as
   a readout of the handle rather than two static stickers. --p is the handle as
   0-1; the left label owns it and the right label owns its complement. */
.ba-tag.-l { opacity: calc(.3 + .7 * var(--p, .5)); }
.ba-tag.-r { opacity: calc(.3 + .7 * (1 - var(--p, .5))); }

/* At either end one screen fills the frame, and the label for it takes the
   accent and pops once, so hitting the end of the drag is felt and not just
   arrived at. The other label stays dimmed by the rule above. */
/* --blue inverts across themes (deep blue on cream, light blue on near-black),
   so pairing it with --bg for the ink keeps the filled state readable in both
   rather than assuming white text. */
.ba[data-end="before"] .ba-tag.-l,
.ba[data-end="after"]  .ba-tag.-r {
  opacity: 1; background: var(--blue); color: var(--bg);
  border-color: var(--blue); transform: scale(1.14);
  animation: ba-pop .42s ease;
}
@keyframes ba-pop {
  0%   { transform: scale(1); }
  55%  { transform: scale(1.24); }
  100% { transform: scale(1.14); }
}
@media (prefers-reduced-motion: reduce) {
  .ba-tag { transition: none; }
  .ba[data-end] .ba-tag { animation: none; }
}

/* ---------- FULL SCREEN (small screens) ----------
   At phone width the frame is about 220px tall and neither screen is legible,
   so on small screens the slider stops being a slider: the drag is disabled and
   the whole thing becomes one big button that opens it full screen. Half a
   gesture on a picture nobody can read is worse than no gesture. */
.ba-open, .ba-close, .ba-rotate { display: none; }

@media (max-width: 860px) {
  .ba-stage .ba-range { pointer-events: none; }
  /* The button covers the whole picture so the tap target is the picture, not a
     small pill, and the pill inside it is only the label. */
  .ba-open {
    display: flex; align-items: flex-end; justify-content: center;
    position: absolute; inset: 0; width: 100%;
    background: none; border: 0; padding: 0 0 12px; cursor: zoom-in;
  }
  .ba-open span {
    padding: 7px 13px; border-radius: 99px; white-space: nowrap;
    font-family: var(--body); font-size: 11px; font-weight: 500;
    letter-spacing: .06em; line-height: var(--lh-ui); text-transform: uppercase;
    background: var(--surface); color: var(--text);
    border: 1px solid var(--border-strong);
    box-shadow: 0 2px 10px rgba(0,0,0,.28);
  }
  .ba-open:focus-visible span { outline: 3px solid var(--focus); outline-offset: 3px; }
}

/* Open. The frame is sized from BOTH viewport axes so it always fits: whichever
   of width or height runs out first decides, and the 16/9 box follows. */
.ba.-full {
  position: fixed; inset: 0; z-index: 200; margin: 0;
  background: var(--bg); display: grid; place-items: center;
}
.ba.-full .ba-stage {
  width: min(100vw, calc(100dvh * 16 / 9));
  border-radius: 0;
}
.ba.-full .ba-stage .ba-range { pointer-events: auto; }
.ba.-full .ba-open { display: none; }
.ba.-full .ba-close {
  display: grid; place-items: center; position: fixed;
  top: 14px; right: 14px; z-index: 2;
  width: 40px; height: 40px; border-radius: 50%;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border-strong);
  font-family: var(--body); font-size: 15px; line-height: 1; cursor: pointer;
}
/* Only worth saying while the phone is upright, and only once it is open. */
@media (orientation: portrait) and (max-width: 860px) {
  .ba.-full .ba-rotate {
    display: block; position: fixed; left: 50%;
    bottom: calc(22px + env(safe-area-inset-bottom, 0px));
    transform: translateX(-50%); margin: 0;
    padding: 8px 14px; border-radius: 99px; white-space: nowrap;
    font-family: var(--body); font-size: 12px; line-height: var(--lh-ui);
    background: var(--surface); color: var(--text-dim);
    border: 1px solid var(--border-strong);
  }
}
/* The pills and handle are sized for a 820px frame. On a phone the frame is a
   third of that, and fixed px chrome ends up dominating the picture it is
   supposed to label. */
@media (max-width: 860px) {
  .ba-tag { top: 8px; padding: 3px 8px; font-size: 10px; }
  .ba-tag.-l { left: 8px; }
  .ba-tag.-r { right: 8px; }
  .ba-grip { width: 28px; height: 28px; margin: -14px 0 0 -14px; font-size: 12px; }
  .ba[data-end="before"] .ba-tag.-l,
  .ba[data-end="after"]  .ba-tag.-r { transform: scale(1.1); }
}

/* Drag zone for the handle at phone width. The picture is a button there, so
   without this a thumb on the handle opens full screen instead of dragging.
   A 56px strip, full height, centred on the line: wider than the 28px grip so
   it is easy to land on, narrow enough that the rest of the picture still
   reads as "tap to open". touch-action: none stops the browser scrolling or
   zooming while the drag is in progress. */
.ba-hit { display: none; }
@media (max-width: 860px) {
  .ba-hit {
    display: block; position: absolute; top: 0; bottom: 0; z-index: 2;
    left: var(--pos); width: 56px; margin-left: -28px;
    touch-action: none; cursor: ew-resize;
    -webkit-tap-highlight-color: transparent;
  }
  /* The label moves off the centre line so the handle and the button are
     never under the same thumb. */
  .ba-open { justify-content: flex-end; padding: 0 10px 10px 0; }
  .ba-grip { transition: transform .15s ease; }
  .ba.-dragging .ba-grip { transform: scale(1.2); }
}

/* The page must not scroll behind the overlay. */
body.ba-locked { overflow: hidden; }

/* ---------- LOOPING CLIP ----------
   Sized from the recording's own proportions rather than a fixed 16/9, so
   nothing is cropped or letterboxed. --ratio is height / width as a
   percentage, set on the element. */
.cs-vid {
  position: relative; margin-bottom: 12px;
  border-radius: var(--radius); overflow: hidden;
  background: var(--surface-2);
}
.cs-vid::before { content: ""; display: block; padding-top: var(--ratio, 56.25%); }
.cs-vid video {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; display: block;
}
/* The invitation to watch the full thing. It sits over the clip, bottom left,
   opposite the pause control, and the clip itself is the target: the whole
   frame responds, the pill only names what happens. */
.cs-vid-open {
  position: absolute; inset: 0; z-index: 1;
  display: flex; align-items: flex-end; justify-content: flex-start;
  width: 100%; padding: 0 0 10px 10px;
  background: none; border: 0; cursor: pointer;
}
.cs-vid-open > span {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 7px 13px; border-radius: 99px; white-space: nowrap;
  font-family: var(--body); font-size: 12px; font-weight: 500;
  line-height: var(--lh-ui); color: var(--text);
  background: var(--surface); border: 1px solid var(--border-strong);
  /* A whisper of a shadow, only enough to lift the pill off a busy screenshot.
     Nothing else on the page casts one, so anything heavier reads as a
     different design arriving. */
  box-shadow: 0 1px 3px rgba(0,0,0,.12);
  transition: transform .2s ease;
}
.cs-vid-open:hover > span { transform: translateY(-1px); }
.cs-vid-open { cursor: pointer; }
.cs-vid-open:focus-visible > span { outline: 3px solid var(--focus); outline-offset: 3px; }

/* The overlay player. Sized from both axes so the frame always fits, with the
   page dimmed behind it rather than replaced, which keeps the reader's place. */
.vplayer {
  --ease-panel: cubic-bezier(.32, .72, .28, 1);
  /* The controls hang above the frame, so the padding has to clear the frame
     AND them. At 24px they sat on the picture's top edge, which on a recording
     of an interface is exactly where that interface's own header lives: the
     one strip a viewer needs in order to know what they are looking at. */
  --bar: 52px;
  position: fixed; inset: 0; z-index: 300;
  display: grid; place-items: center;
  padding: calc(var(--bar) + 16px) 24px 24px;
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  backdrop-filter: blur(6px);
}
.vplayer[hidden] { display: none; }
/* Sized from BOTH axes before the file even loads: the box is as wide as it
   can be without the 16/9-ish frame growing taller than the window. Capping
   the height instead would letterbox the video inside its own box, which is
   where the black band above and below came from on a short window. --ar is
   the clip's width over its height. */
/* Video and transcript sit side by side, and the row is as wide as both of
   them want to be. The picture keeps its old rule, sized from BOTH axes so the
   frame never grows taller than the window, and the panel takes a fixed column
   next to it and stretches to the same height. */
.vplayer-box {
  position: relative; display: flex; justify-content: center;
  width: min(100%, calc(1180px + 18px + 320px));
  max-height: 100%;
}
.vplayer-main {
  flex: 1 1 auto; min-width: 0;
  max-width: calc((100dvh - var(--bar) - 96px) * var(--ar, 1.7505));
}
.vplayer video {
  width: 100%; height: auto; aspect-ratio: var(--ar, 1.7505);
  border-radius: var(--radius); display: block; background: #000;
}

/* The narration, tied to the picture rather than parked beneath it. A line
   lights as it is spoken and a click moves the video to it, which turns eight
   minutes of walkthrough into something a reader can aim at. */
/* The list is taken out of flow on purpose. Left in it, a long transcript would
   be the tallest thing in the row and would set the row's height, stretching
   the panel past the bottom of the picture instead of scrolling beside it. Out
   of flow it contributes no height, so the video decides how tall the row is
   and the words scroll inside whatever that turns out to be. */
/* The words hang under the button rather than sitting in a card of their own.
   A bordered box beside the frame read as a second component that happened to
   be parked there; with the chrome gone the transcript belongs to the player,
   and the only furniture left is the button that opened it.

   It stays in the layout when shut, at zero width, so opening and closing is
   one thing moving rather than two states swapping. Nothing between a pair of
   states can be animated. */
.vplayer-script {
  position: relative; flex: 0 0 320px; align-self: stretch;
  min-height: 0; margin-left: 18px; overflow: hidden;
  transition: flex-basis .38s var(--ease-panel), margin-left .38s var(--ease-panel),
              opacity .26s ease;
}
.vplayer-script.-shut { flex-basis: 0; margin-left: 0; opacity: 0; pointer-events: none; }
.vplayer-script[hidden] { display: none; }
/* The reading edges soften instead of ending on a hard line, which is most of
   what made the old panel read as a box. */
.vplayer-script::before, .vplayer-script::after {
  content: ""; position: absolute; left: 0; right: 0; height: 24px;
  z-index: 1; pointer-events: none;
}
.vplayer-script::before { top: 0; background: linear-gradient(var(--bg), transparent); }
.vplayer-script::after { bottom: 0; background: linear-gradient(transparent, var(--bg)); }
/* The way in. It mirrors the close button across the top of the frame, and it
   is the only thing that says a transcript exists, so it carries a word and not
   just a mark. */
/* Beside the close button, on the side the panel opens on. Across the frame
   from it, the pill pointed one way and the panel appeared the other. */
/* Matched to the cross's 40px, so the pair share a top edge and a centre line.
   Sized by padding alone the pill came out 33px, and sitting them at the same
   offset left their centres four pixels apart, which is exactly the sort of
   gap you cannot unsee once you have seen it. */
.vplayer-script-toggle {
  position: absolute; z-index: 2; top: calc(-1 * var(--bar)); right: 44px;
  display: inline-flex; align-items: center; gap: 6px;
  height: 40px; padding: 0 15px; border-radius: 99px; cursor: pointer;
  background: var(--surface); color: var(--text-dim);
  border: 1px solid var(--border-strong);
  font-family: var(--body); font-size: 12px; line-height: var(--lh-ui);
  transition: color .15s ease;
}
.vplayer-script-toggle[hidden] { display: none; }
.vplayer-script-toggle:hover { color: var(--text); }
.vplayer-script-toggle[aria-pressed="true"] {
  color: var(--text); border-color: var(--text-dim);
}
.vplayer-script-toggle:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .vplayer-script-toggle { transition: none; } }
/* Fixed width and anchored left, so the lines hold still while the column
   opens and closes around them. Dropping down from the button on the way in
   and lifting back toward it on the way out says where the list came from. */
.vplayer-script ol {
  position: absolute; top: 0; bottom: 0; left: 0; width: 320px;
  /* Clears the button, which floats above the column's first line. */
  padding: 22px 6px 4px; overflow-y: auto; overscroll-behavior: contain;
  list-style: none; margin: 0;
  transition: transform .38s var(--ease-panel);
}
.vplayer-script.-shut ol { transform: translateY(-14px); }
@media (prefers-reduced-motion: reduce) {
  .vplayer-script, .vplayer-script ol { transition: none; }
}
.vplayer-script button {
  display: block; width: 100%; text-align: left; cursor: pointer;
  margin: 0; padding: 9px 11px; border: 0; background: none;
  color: var(--text-dim);
  font-family: var(--body); font-size: 13.5px; line-height: 1.68;
  transition: color .15s ease;
}
.vplayer-script button:hover { color: var(--text); }
.vplayer-script button:focus-visible { outline: 3px solid var(--focus); outline-offset: -3px; }
/* The spoken line. Weight and a marker on the words themselves, nothing drawn
   around the row: a filled rectangle puts the emphasis on the rectangle, and
   what is meant to be read is the sentence. box-decoration-break makes the
   marker follow each line's own width, so it ends where the words end instead
   of running to the edge of the column. */
/* The marker is the first accent of whichever palette is up, so the highlight
   belongs to the system rather than being a grey invented for this one panel.
   Two strengths, because the same ink over cream and over near-black does not
   carry the same weight. */
/* Light takes the first accent, which lands on the yellow and reads as a
   marker. Dark takes the second: the first there is the red, and a red slab
   behind a sentence reads as something having gone wrong. */
.vplayer-script { --mark: color-mix(in srgb, var(--kw2) 30%, transparent); }
html[data-theme="light"] .vplayer-script { --mark: color-mix(in srgb, var(--kw1) 48%, transparent); }
.vplayer-script .-on button { color: var(--text); }
.vplayer-script .-on span {
  font-weight: 500;
  background: var(--mark);
  box-decoration-break: clone; -webkit-box-decoration-break: clone;
  padding: 1px 4px; margin: 0 -2px; border-radius: 3px;
}
@media (prefers-reduced-motion: reduce) {
  .vplayer-script button { transition: none; }
}
/* On a phone the two cannot sit side by side, so the transcript goes under the
   picture and takes a share of the height rather than all of it. */
@media (max-width: 860px) {
  /* Full height, so the transcript takes everything the picture does not.
     Upright, a phone gives a desktop walkthrough a strip about a third of the
     screen, so the words are the larger half of this view, and reading them is
     a real way through the video rather than a consolation. */
  /* Top-aligned, and kept at full height. Centred, the controls stayed pinned
     to the top of the box while the picture floated to the middle, leaving a
     270px void between a button and the thing it operates. Full height still
     gives the transcript definite space to grow into, which is what stopped it
     snapping shut instead of closing. */
  .vplayer-box {
    flex-direction: column; justify-content: flex-start; width: 100%; height: 100%;
  }
  .vplayer-main { flex: 0 0 auto; max-width: none; }
  /* Stacked, the column grows downward, so height is what has to move. A
     flex-basis of zero does nothing here: the item still has flex-grow and
     fills whatever is left. max-height collapses it, and the cap is written in
     vh rather than a percentage because a transition cannot interpolate from a
     length to a percentage: 0 to 100% simply jumps. */
  .vplayer-script {
    flex: 1 1 auto; min-height: 0; align-self: auto;
    margin-left: 0; margin-top: 10px; max-height: 60vh;
    transition: max-height .38s var(--ease-panel), margin-top .38s var(--ease-panel),
                opacity .26s ease;
  }
  .vplayer-script.-shut { flex-basis: auto; max-height: 0; margin-top: 0; }
  .vplayer-script ol { width: 100%; }
  .vplayer-script-toggle { right: 48px; }
  .vplayer-script button { font-size: 13px; padding: 8px 10px; }
}
.vplayer-close {
  position: absolute; z-index: 2; top: calc(-1 * var(--bar)); right: -6px;
  display: grid; place-items: center;
  width: 40px; height: 40px; border-radius: 50%;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border-strong);
  font-family: var(--body); font-size: 15px; line-height: 1; cursor: pointer;
}
.vplayer-close:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
/* The walkthrough is a desktop interface, and a phone upright gives it a strip.
   The way out of that is the browser's own full screen player, which handles
   rotation itself once it is open. Asking for it from a tap is the whole point:
   full screen is only ever granted to a gesture, so a button gets it and an
   instruction to rotate does not. Portrait only, since landscape already runs
   edge to edge and the native controls carry their own full screen button. */
.vplayer-full { display: none; }
@media (orientation: portrait) and (max-width: 860px) {
  .vplayer-full {
    display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
    position: fixed; left: 50%;
    bottom: calc(22px + env(safe-area-inset-bottom, 0px));
    transform: translateX(-50%); margin: 0;
    padding: 9px 15px; border-radius: 99px; white-space: nowrap;
    font-family: var(--body); font-size: 12px; line-height: var(--lh-ui);
    background: var(--surface); color: var(--text);
    border: 1px solid var(--border-strong);
    box-shadow: 0 1px 3px rgba(0, 0, 0, .12);
    transition: opacity .22s ease;
  }
  @media (prefers-reduced-motion: reduce) { .vplayer-full { transition: none; } }
  .vplayer-full:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
  /* Reading mode takes it away. It is fixed to the bottom of the window, so
     left up it would sit over the last lines of the very thing they opened,
     and the offer is moot anyway: full screen is the video's answer, and they
     have just said they want the words. */
  .vplayer.-reading .vplayer-full { opacity: 0; pointer-events: none; }
  /* And the room it was reserving goes back to the transcript. */
  .vplayer.-reading { padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); }
}
@media (max-width: 860px) {
  /* env() keeps the controls clear of a notch or a Dynamic Island, and falls
     back to plain numbers on anything without one. */
  .vplayer {
    padding: calc(var(--bar) + 20px + env(safe-area-inset-top, 0px)) 12px
             calc(12px + env(safe-area-inset-bottom, 0px));
  }
  .vplayer-close { right: 0; }
}
/* A phone held sideways: the whole screen goes to the picture.
   Our band of controls plus the frame does not fit in 390px of height, which is
   what was cutting the player off. So in landscape the band collapses, the
   transcript and the rotate prompt are not offered at all, and the video runs
   edge to edge with the browser's own controls doing the work. Reading is a
   portrait job, and the panel lives there. */
@media (orientation: landscape) and (max-height: 500px) {
  .vplayer { --bar: 0px; padding: 0; background: #000; backdrop-filter: none; }
  .vplayer-script, .vplayer-script-toggle, .vplayer-full { display: none !important; }
  /* Sized from the viewport, not in percentages. A percentage height here
     resolves against a grid row that is itself sized by this content, so the
     picture asked for the full width, grew taller than the screen, and the row
     grew with it: the video ended up 482px tall in a 390px window, which is the
     cut-off. dvh breaks that loop. */
  .vplayer-box { width: auto; height: 100dvh; max-height: none; }
  .vplayer-main {
    flex: 0 0 auto; max-width: none; height: 100dvh;
    display: flex; align-items: center; justify-content: center;
  }
  .vplayer video { height: 100dvh; width: auto; max-width: 100vw; border-radius: 0; }
  /* The one control left behaves like the native ones: there on a tap, gone
     again while the video is running. */
  .vplayer-close {
    top: calc(10px + env(safe-area-inset-top, 0px));
    right: calc(10px + env(safe-area-inset-right, 0px));
    transition: opacity .25s ease;
  }
  .vplayer.-idle .vplayer-close { opacity: 0; pointer-events: none; }
  @media (prefers-reduced-motion: reduce) {
    .vplayer-close { transition: none; }
  }
}

/* Room at the foot for the rotate prompt, which is fixed to the window and
   would otherwise sit on top of the last few lines of the transcript. */
@media (orientation: portrait) and (max-width: 860px) {
  .vplayer { padding-bottom: calc(52px + env(safe-area-inset-bottom, 0px)); }
}

/* Two marks after the label. The speaker says the walkthrough has narration, so
   nobody opens it in a quiet room by surprise; the captions mark says the
   narration is also available in writing, which is what most people skimming a
   portfolio at work actually need. */
.cs-vid-open .snd, .cs-vid-open .cc { opacity: .6; margin-left: 2px; }
/* On a phone the frame is a third of the width, so the pill has to come down
   with it or it becomes the picture. */
@media (max-width: 860px) {
  .cs-vid-open { padding: 0 0 8px 8px; }
  .cs-vid-open > span { font-size: 10.5px; padding: 5px 10px; gap: 5px; }
  .cs-vid-open > span svg { width: 11px; height: 11px; }
  .cs-vid-open .snd, .cs-vid-open .cc { width: 11px; height: 11px; }
}

/* Full screen for a still. A diagram whose argument lives in its labels cannot
   be read at column width on a phone: the blueprint's type lands near three
   pixels there, which is not a smaller version of the point, it is the absence
   of it. So a figure carrying an image becomes a button, and the overlay shows
   the file at its own size, where the words are words and the reader travels
   along it. Same grammar as the video player: dim the page, do not replace it. */
.cs-zoom {
  position: relative; display: block; width: 100%;
  margin: 0 0 12px; padding: 0; border: 0; background: none;
  border-radius: var(--radius); cursor: zoom-in;
}
.cs-zoom img { margin-bottom: 0; }
.cs-zoom:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
/* The affordance sits in the corner of the picture rather than under it, so the
   caption keeps saying what the picture means instead of how to operate it. */
.cs-zoom-mark {
  position: absolute; right: 10px; bottom: 10px; pointer-events: none;
  display: grid; place-items: center; width: 30px; height: 30px;
  border-radius: 50%; background: var(--surface); color: var(--text-dim);
  border: 1px solid var(--border-strong);
  box-shadow: 0 1px 3px rgba(0, 0, 0, .12);
  opacity: .85; transition: opacity .2s ease, transform .2s ease;
}
.cs-zoom:hover .cs-zoom-mark { opacity: 1; transform: translateY(-1px); }
/* On a touch screen the corner mark is the only cue, and the picture is small
   enough that it needs one in words too. */
@media (max-width: 860px) {
  .cs-fig.-zoomable figcaption::after {
    content: "Tap the image to open it full screen.";
    display: block; margin-top: 7px; opacity: .75;
  }
}
/* Upright, the page itself gives a wide diagram about a third of the room it
   gets sideways, and that reflow is a far bigger gain than any overlay. So on a
   portrait phone the caption asks for the turn rather than the tap. */
@media (orientation: portrait) and (max-width: 860px) {
  .cs-fig.-zoomable figcaption::after {
    content: "Turn your phone sideways for a bigger view.";
  }
}

.lbox {
  position: fixed; inset: 0; z-index: 300; padding: 24px;
  background: color-mix(in srgb, var(--bg) 92%, transparent);
  backdrop-filter: blur(6px);
}
.lbox[hidden] { display: none; }
.lbox-frame { position: relative; width: 100%; height: 100%; }
/* margin:auto inside a flex box centres the picture while it fits and stops
   centring the moment it does not, which is the one way to centre something
   scrollable without cutting off its top and left edge. */
.lbox-scroll {
  width: 100%; height: 100%; display: flex; overflow: auto;
  -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
}
.lbox-scroll img {
  display: block; margin: auto; border-radius: var(--radius);
  max-width: 100%; max-height: 100%; width: auto; height: auto;
}
/* The picture always fits the window, on every screen. A phone held sideways
   gives it more than twice the width the page can, which is the gain on offer
   here; panning for the rest would trade one whole picture for a series of
   fragments, and with a blueprint the size IS the point. */
.lbox-close {
  position: absolute; top: -14px; right: -14px;
  display: grid; place-items: center;
  width: 40px; height: 40px; border-radius: 50%;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border-strong);
  font-family: var(--body); font-size: 15px; line-height: 1; cursor: pointer;
}
.lbox-close:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
/* A blueprint is wider than it is tall by a long way. Held upright a phone gives
   it a strip, so the same prompt the comparison and the player use. It sits
   above the size toggle rather than beside it, since both are centred. */
.lbox-rotate { display: none; }
@media (orientation: portrait) and (max-width: 860px) {
  .lbox-rotate {
    display: block; position: absolute; left: 50%; bottom: 34px;
    transform: translateX(-50%); margin: 0;
    padding: 8px 14px; border-radius: 99px; white-space: nowrap;
    font-family: var(--body); font-size: 12px; line-height: var(--lh-ui);
    background: var(--surface); color: var(--text-dim);
    border: 1px solid var(--border-strong);
  }
}
@media (max-width: 860px) {
  .lbox { padding: 16px; }
  .lbox-close { top: -10px; right: -6px; }
}

/* The numbers, given room. Three at most: a fourth turns a claim into a
   dashboard and invites arithmetic nobody asked for. */
.cs-stats {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: 26px 34px; margin: 0 0 54px; padding: 30px 0;
  border-bottom: 1px solid var(--border);
}
/* The numbers belong to the Outcome text above them: no rule between the
   two, and the section's usual bottom space is shortened so they read as
   one block. */
.cs-section:has(+ .cs-stats) { margin-bottom: 0; }
.cs-stats dt {
  font-family: var(--display); font-weight: 600;
  font-size: clamp(34px, 4.4vw, 46px); line-height: 1;
  letter-spacing: -0.02em; color: var(--chan-hl); margin-bottom: 8px;
}
/* Every digit the same width, so the numbers do not jitter sideways while
   they count, and the box is sized to the final value so nothing reflows. */
.cs-count { font-variant-numeric: tabular-nums; white-space: nowrap; }
/* One window per digit. A hidden zero stays in the flow so each window has a
   real text baseline and lines up with the "+" and "%" beside it.

   The window reaches 0.25em above and below the digit (padding, cancelled by a
   negative margin so the line does not grow), and a mask fades those edges.
   That margin is where a digit is seen leaving and arriving, so the roll
   reads as travel with a fade rather than a cut.

   Reel rows are 1.2em apart, which puts the neighbouring digits fully outside
   the window at rest: no ghost of the previous number above the current one. */
.cs-digit {
  position: relative; display: inline-block; width: var(--dw, 0.62em);
  padding: 0.25em 0; margin: -0.25em 0;
  clip-path: inset(0 -0.1em);
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 0.3em, #000 calc(100% - 0.3em), transparent 100%);
          mask-image: linear-gradient(to bottom, transparent 0, #000 0.3em, #000 calc(100% - 0.3em), transparent 100%);
}
.cs-digit > .ph { visibility: hidden; }
.cs-reel {
  position: absolute; left: 0; right: 0; top: 0.15em;
  display: block; will-change: transform;
}
.cs-reel > span {
  display: block; height: 1.2em; padding-top: 0.1em; box-sizing: border-box;
  line-height: 1; text-align: center;
}
.cs-stats dd { font-size: 14px; line-height: 1.5; color: var(--text-dim); max-width: 26ch; }

/* The org in the eyebrow carries the weight; the surface type trails it. */
.cs-eyebrow b { font-weight: 600; }
.cs-eyebrow { display: flex; gap: 0.32em; align-items: baseline; flex-wrap: wrap; }
/* Every case study eyebrow is painted with its channel running into the
   next one: the same pair, in the same order, as that project's card
   underline on the home page (yellow into pink for Community and
   Conqueror, magenta into cyan for Training Platform). --chan-next is set
   by the page. */
.cs-eyebrow.-grad {
  display: inline-flex;
  background: linear-gradient(90deg, var(--chan-hl), var(--chan-next, var(--chan-hl)));
  -webkit-background-clip: text; background-clip: text;
  color: transparent;
}

.cs-section b { font-weight: 600; color: var(--text); }

/* ---------- "you came from here" ----------
   Shown only when a visitor arrived by choosing one of the three words in the
   statement. It closes the loop the home page opened: the word they picked is
   named back to them, in the colour it was wearing when they picked it.

   The word carries the keyword's REST underline, not its filled highlight. A
   filled highlight would say "clickable", and this one is not. Same vocabulary,
   one step quieter, which is also why it can sit inside a sentence when the
   filled version cannot. */
.cs-from {
  font-family: var(--body); font-size: 13.5px; color: var(--text-dim);
  margin-bottom: 14px;
}
.cs-from[hidden] { display: none; }
.cs-from .lens {
  position: relative; color: var(--text); font-weight: 500;
  padding: 0 0.08em;
}
.cs-from .lens::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -2px; height: 2px;
  border-radius: 2px; background: var(--lens-chan, var(--chan-hl));
}

/* ---------- TWO-MODE ARTWORK ----------
   The site has an explicit toggle that overrides the OS preference, so
   `<picture>` with `prefers-color-scheme` is WRONG here: it would keep serving
   the dark file to someone who switched the site to light while their Mac was
   still dark. It only ever listens to the OS.

   So the swap runs off `data-theme`, the same attribute everything else on the
   site follows. Both files are fetched, which for two SVGs is about 50KB and
   not worth solving. */
.mode-swap { display: block; }
.mode-swap img { display: block; width: 100%; height: auto; }
html[data-theme="dark"]  .mode-swap .-light,
html[data-theme="light"] .mode-swap .-dark { display: none; }

/* Project thumbnails with a dark-mode version: both sit in the card,
   data-theme picks one (same reasoning as .mode-swap above). */
html[data-theme="dark"]  .card-img img.-light,
html[data-theme="light"] .card-img img.-dark { display: none; }

/* The Noto lockup. Not a captioned figure: it names who the work was for and
   then gets out of the way. */
.cs-brands { max-width: 460px; margin: 0 0 44px; }

/* ==========================================================================
   In-construction page. One illustration, two directions to compare
   (construction-a.html static, construction-b.html with motion); everything
   else on the page is identical between them, and swapping direction later
   is just adding or removing the -motion class below.

   Reuses the same diagonal hatch already used for "Coming soon" card
   thumbnails, so the site has one visual grammar for "not built yet" rather
   than two competing ones. */
.construction-wrap { display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 20px; min-height: calc(100dvh - 260px); padding: 40px 0; }
.construction-art { width: min(320px, 78vw); color: var(--chan); }
.construction-art svg { width: 100%; height: auto; display: block; }
.construction-label { font-family: var(--display); font-size: 20px; font-weight: 500; color: var(--text-dim); margin: 0; }
.construction-note {
  font-family: var(--body); font-size: 15px; line-height: 1.6; color: var(--text-dim);
  max-width: 380px; text-align: center; margin: 0;
}

/* Motion, added only on construction-b: the one lit window on the floor
   still being worked pulses quietly, standing in for someone at work on
   it right now. Nothing else moves. Reduced motion drops back to the
   exact static frame construction-a ships. */
.construction-art.-motion .c-glow { animation: c-pulse 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
@keyframes c-pulse { 0%, 100% { opacity: 0.5; transform: scale(0.85); } 50% { opacity: 1; transform: scale(1.25); } }
@media (prefers-reduced-motion: reduce) {
  .construction-art.-motion .c-glow { animation: none; }
}

/* Inline placeholders for a case study's not-yet-shot screens. Same hatch,
   same channel colour, so a reader clocks it as "coming" rather than
   "broken," consistent with the coming-soon cards and this page. */
.cs-fig.-placeholder .ph-box {
  aspect-ratio: var(--ph-ratio, 16 / 10);
  border-radius: 14px;
  border: 1px dashed color-mix(in srgb, var(--chan) 45%, transparent);
  background:
    repeating-linear-gradient(-45deg,
      color-mix(in srgb, var(--chan) 13%, transparent) 0 2px,
      transparent 2px 11px),
    color-mix(in srgb, var(--chan) 6%, var(--surface));
  display: flex; align-items: center; justify-content: center; text-align: center; padding: 24px;
}
/* Highlighted, not just coloured text: --chan-hl at full strength behind
   near-black text, the same "highlighter" pairing the hero keywords and
   ::selection already use. Needed because --chan itself (the text-safe
   token) went back to true kw1 yellow with no darkening at her request,
   which reads fine as a border/accent but is too low-contrast to carry a
   whole label's text on the page background. */
.cs-fig.-placeholder .ph-label {
  font-family: var(--body); font-size: 13px; font-weight: 500; color: #16171c;
  background: var(--chan-hl); border-radius: 100px; padding: 6px 16px; max-width: 340px;
}

/* Two loops meant to be watched side by side (e.g. composing a post next to
   reacting to one), rather than one image standing in for both. Each half
   keeps its own aspect ratio via --ph-ratio same as a single .ph-box; this
   just lays the pair out and lets them wrap to stacked on narrow screens. */
.ph-pair { display: flex; gap: 16px; }
.ph-pair .ph-box { flex: 1 1 0; min-width: 0; }
@media (max-width: 560px) { .ph-pair { flex-direction: column; } }

/* The community case study's lead image: real threads from the community,
   one at a time. Each thread scrolls slowly at reading pace, and the last
   reply sinks into the fade at the bottom, so the conversation reads as
   still going. It plays on its own; tapping the thread pauses it, and the
   dots or a swipe hand control to the reader (thread.js). */
.cs-fig.-thread .thread-stage {
  position: relative;
  height: 520px;
  border-radius: 20px;
  overflow: hidden;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  touch-action: pan-y;
}
.cs-fig.-thread .thread-bg {
  position: absolute; inset: 0;
  /* Exact gradient from the design file's own background slide. */
  background:
    linear-gradient(162deg, rgba(187,251,255,0.5) 2.4%, rgba(218,251,254,0) 60%),
    linear-gradient(180deg, var(--nocd-top, rgba(255,255,255,0.9)) 20.4%, rgba(255,255,255,0.1) 100%),
    var(--nocd-base),
    #fff;
}
/* The base .cs-fig img rule gives every image a radius, a bottom margin and
   full width. These PNGs carry their own rounded corners, so all three have
   to go or the cards get re-clipped and pushed apart. */
.cs-fig.-thread .thread-stage img { margin: 0; border-radius: 0; max-width: none; }
.cs-fig.-thread .thread-logo {
  position: absolute; left: 50%; top: 50%;
  width: 150px; height: auto;
  transform: translate(-50%, -50%);
  opacity: 0; pointer-events: none;
  transition: opacity 0.7s ease;
}
.cs-fig.-thread .thread-stage.-logo .thread-logo { opacity: 1; }

/* The scrolling window. The fade is a mask on the window itself, so it stays
   put at the bottom edge while the thread moves up through it. */
.cs-fig.-thread .thread-view {
  position: absolute; inset: 0;
  overflow: hidden;
  scrollbar-width: none;
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 22px, #000 calc(100% - 150px), transparent calc(100% - 10px));
          mask-image: linear-gradient(to bottom, transparent 0, #000 22px, #000 calc(100% - 150px), transparent calc(100% - 10px));
}
.cs-fig.-thread .thread-view::-webkit-scrollbar { display: none; }
/* Paused, or no script: the reader scrolls the thread themselves. */
.cs-fig.-thread:not(.-js) .thread-view { overflow-y: auto; }
.cs-fig.-thread:not(.-js) .thread-dots,
.cs-fig.-thread:not(.-js) .thread-toggle { display: none; }
.cs-fig.-thread .thread-stage.-paused .thread-view { overflow-y: auto; overscroll-behavior: contain; }
.cs-fig.-thread.-js .thread-slide { opacity: 0; transition: opacity 0.25s ease; }
.cs-fig.-thread .thread-slide.-in { opacity: 1; }
.cs-fig.-thread .thread-slide[hidden] { display: none; }
.cs-fig.-thread .thread-track {
  width: min(400px, calc(100% - 32px));
  margin: 0 auto;
  padding: 28px 0 150px;
  display: flex; flex-direction: column; align-items: center; gap: 10px;
}
.cs-fig.-thread .thread-card {
  display: block; height: auto; flex: none;
  filter: drop-shadow(0 4px 14px rgba(22,23,28,0.08));
}
.cs-fig.-thread.-js .thread-card {
  opacity: 0; transform: translateY(8px);
  transition: opacity 0.5s ease, transform 0.5s ease;
}
.cs-fig.-thread .thread-slide.-in .thread-card { opacity: 1; transform: none; }
.cs-fig.-thread .thread-slide.-in .thread-card:nth-child(2) { transition-delay: 0.35s; }
.cs-fig.-thread .thread-slide.-in .thread-card:nth-child(3) { transition-delay: 0.7s; }
.cs-fig.-thread .thread-slide.-in .thread-card:nth-child(n+4) { transition-delay: 1.05s; }
.cs-fig.-thread .thread-card.-post { width: 100%; }
.cs-fig.-thread .thread-card.-reply { width: 90.1%; } /* 446 / 495, as in Figma */

/* Pause and play sit in the middle of the picture, the way a video's do:
   shown on hover or keyboard focus, and kept on screen while paused. A tap
   anywhere on the thread does the same thing. */
.cs-fig.-thread .thread-toggle {
  position: absolute; left: 50%; top: 50%;
  width: 56px; height: 56px; padding: 0;
  display: grid; place-items: center;
  border: 0; border-radius: 100px;
  background: rgba(255,255,255,0.86);
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
  color: #16171c;
  box-shadow: 0 6px 20px rgba(22,23,28,0.14);
  cursor: pointer;
  opacity: 0;
  transform: translate(-50%, -50%) scale(0.92);
  transition: opacity 0.25s ease, transform 0.25s ease;
}
.cs-fig.-thread .thread-toggle .i-play { display: none; margin-left: 2px; }
.cs-fig.-thread .thread-toggle[aria-pressed="true"] .i-play { display: block; }
.cs-fig.-thread .thread-toggle[aria-pressed="true"] .i-pause { display: none; }
@media (hover: hover) {
  .cs-fig.-thread .thread-stage:hover .thread-toggle { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
.cs-fig.-thread .thread-stage.-paused .thread-toggle,
.cs-fig.-thread .thread-stage.-flash .thread-toggle,
.cs-fig.-thread .thread-toggle:focus-visible { opacity: 1; transform: translate(-50%, -50%) scale(1); }
.cs-fig.-thread .thread-toggle:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
.cs-fig.-thread .thread-stage.-logo .thread-toggle { opacity: 0; pointer-events: none; }
/* Press-and-hold feedback: a small "2x" chip at the top while the thread
   plays fast, gone the moment the reader lets go. The press itself must
   not select text or pop the phone's image menu. */
.cs-fig.-thread .thread-stage { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
.cs-fig.-thread .thread-card { -webkit-user-drag: none; }
.cs-fig.-thread .thread-fast {
  position: absolute; left: 50%; top: 14px;
  display: inline-flex; align-items: center; gap: 5px;
  padding: 6px 11px 6px 9px;
  border-radius: 100px;
  /* Dark, not white like the pause button: it sits over the top of a white
     card most of the time, where a white chip disappears. */
  background: rgba(22,23,28,0.8);
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
  box-shadow: 0 4px 14px rgba(22,23,28,0.18);
  color: #fff;
  font-family: var(--body); font-size: 13px; font-weight: 600; line-height: 1;
  opacity: 0; transform: translate(-50%, -4px);
  transition: opacity 0.2s ease, transform 0.2s ease;
  pointer-events: none;
}
.cs-fig.-thread .thread-stage.-fast .thread-fast { opacity: 1; transform: translate(-50%, 0); }
.cs-fig.-thread .thread-stage.-fast .thread-toggle { opacity: 0; }
.cs-fig.-thread .thread-stage.-dragging { cursor: grabbing; }
/* Arrows inside the picture, at its left and right edges, desktop only.
   Same chip as the pause button so the controls read as one set. */
.cs-fig.-thread .thread-arrow {
  position: absolute; top: 50%; z-index: 3;
  width: 40px; height: 40px; margin-top: -20px; padding: 0;
  display: grid; place-items: center;
  border: 0; border-radius: 100px;
  background: rgba(255,255,255,0.86);
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
  color: #16171c; box-shadow: 0 4px 14px rgba(22,23,28,0.14);
  cursor: pointer; transition: transform 0.2s ease;
}
.cs-fig.-thread .thread-arrow:hover { transform: scale(1.06); }
.cs-fig.-thread .thread-arrow:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
.cs-fig.-thread .thread-arrow.-prev { left: 14px; }
.cs-fig.-thread .thread-arrow.-next { right: 14px; }
.cs-fig.-thread .thread-stage.-logo .thread-arrow { opacity: 0; pointer-events: none; }
.cs-fig.-thread:not(.-js) .thread-arrow { display: none; }
@media (max-width: 760px) { .cs-fig.-thread .thread-arrow { display: none; } }
.cs-fig.-thread .thread-stage.-dragging .thread-toggle { opacity: 0; }

/* The same dial as the home page deck, under the picture. The one change:
   the current dot fills up as its thread plays, so it doubles as a timer. */
.cs-fig.-thread .dial { margin: 14px 0 12px; }
.cs-fig.-thread .dial button[aria-current="true"] { background: var(--border-strong); }
.cs-fig.-thread .thread-dot { position: relative; overflow: hidden; }
.cs-fig.-thread .thread-dot i {
  position: absolute; inset: 0; border-radius: inherit;
  background: var(--chan-hl, var(--text));
  transform-origin: left center;
  transform: scaleX(var(--p, 0));
}
.cs-fig.-thread .thread-dot:not([aria-current="true"]) i { transform: scaleX(0); }

/* The how-to line under the caption, same treatment as the pan hint on
   the blueprint figure. Only when the script is running, since that's the
   only time there's anything to pause. */
.cs-fig.-thread.-js figcaption::after {
  content: "Tap to pause, or press and hold to speed up.";
  display: block; margin-top: 4px; opacity: .75;
}
@media (max-width: 560px) {
  .cs-fig.-thread .thread-stage { height: 440px; border-radius: 16px; }
  .cs-fig.-thread .thread-track { width: calc(100% - 24px); padding-top: 18px; gap: 8px; }
  .cs-fig.-thread .thread-logo { width: 120px; }
}

/* Reduced motion: no logo, no auto scroll, no fades. The first thread sits
   still and the reader scrolls or switches threads themselves. */
@media (prefers-reduced-motion: reduce) {
  .cs-fig.-thread .thread-logo { display: none; }
  .cs-fig.-thread .thread-view { overflow-y: auto; }
  .cs-fig.-thread .thread-slide,
  .cs-fig.-thread .thread-card { transition: none; }
  .cs-fig.-thread .thread-toggle { display: none; }
  .cs-fig.-thread.-js figcaption::after { content: none; }
}

/* The NOCD community gradient, behind every community image (threads
   carousel, timeline, hero card). Light: the warm blue to peach to amber
   from the design file. Dark: teal to indigo (design file node 232:7784),
   under the same top wash and cyan glow. --nocd-top is the wash colour;
   a full-bleed band in dark mode swaps it for the page colour so the band
   fades in from the page. */
:root { --nocd-base: linear-gradient(108deg, rgba(140,198,252,0.6) 0%, rgba(255,175,164,0.6) 49.8%, rgba(248,196,110,0.6) 110.4%); }
html[data-theme="dark"] { --nocd-base: linear-gradient(105deg, #00d2e0 0%, #545cea 100%); }

/* ---------- Community feed: timeline, search before/after, reactions ---------- */

/* The timeline: one year at a time, newest first (timeline.js), from the
   design file (node 232:7522). The current screenshot sits in front with
   every older year splayed out behind it to the right, each a step smaller
   and more tilted, under a wash of the page colour rather than going
   see-through. Behind the screenshots, a pill in the community gradient
   holds the copy: two layers offset like the stacked cards at the top of
   the page, the front one multiplied. On a phone the pill becomes a
   full-width band under the screenshots that grows with the copy, so
   nothing can spill out of it. Never plays on its own. */
.cs-fig.-timeline .tl-steps { list-style: none; margin: 0; padding: 0; }
.cs-fig.-timeline .tl-shot,
.cs-fig.-timeline .tl-card img {
  display: block; width: 100%; height: auto; margin: 0;
  /* The hairline is a ring outside the picture, not a border inside it.
     With a border, the screenshot started 1px in, and on the screens whose
     notch sits flush with the top (2023, 2024) a sliver of white showed
     above the notch. */
  border-radius: 28px;
  background: #fff; box-shadow: 0 0 0 1px rgba(22,23,28,0.1), 0 14px 34px rgba(0,0,0,0.12);
  -webkit-user-drag: none; user-select: none;
}
.cs-fig.-timeline .tl-year {
  margin: 0 0 7px; color: var(--text);
  font-family: var(--display); font-weight: 700; font-size: 28px; line-height: 1.15;
}
.cs-fig.-timeline .tl-note { margin: 0; color: var(--text-dim); font-size: 16px; line-height: 1.55; }
.cs-fig.-timeline .dial { --dot: var(--chan-hl, var(--text)); }

/* No script: every year in a row that scrolls sideways. */
.cs-fig.-timeline:not(.-js) .tl-steps {
  display: flex; gap: 24px; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 12px;
}
.cs-fig.-timeline:not(.-js) .tl-step { flex: 0 0 240px; scroll-snap-align: start; }
.cs-fig.-timeline:not(.-js) .tl-copy { margin-top: 14px; }
.cs-fig.-timeline:not(.-js) .tl-dots { display: none; }

/* With script. */
/* Every year's copy sits in the same grid cell, and only the current one
   is visible, so the block is always as tall as the longest caption and
   nothing below it (the arrows, the dots) moves between years. */
.cs-fig.-timeline.-js .tl-text .tl-steps { display: grid; }
.cs-fig.-timeline.-js .tl-step { grid-area: 1 / 1; visibility: hidden; }
.cs-fig.-timeline.-js .tl-step.-on { visibility: visible; animation: tl-in 0.35s ease; }
.cs-fig.-timeline.-js .tl-step .tl-shot { display: none; }
@keyframes tl-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }

/* Desktop geometry, in the design file's own coordinates (an 820px stage). */
.cs-fig.-timeline .tl-stage { position: relative; width: 820px; max-width: 100%; height: 584px; margin: 0 auto; }
.cs-fig.-timeline .tl-pill { position: absolute; border-radius: 235px; pointer-events: none; background:
    linear-gradient(166deg, rgba(187,251,255,0.5) 2.4%, rgba(218,251,254,0) 60%),
    linear-gradient(180deg, var(--nocd-top, rgba(255,255,255,0.9)) 20.4%, rgba(255,255,255,0.1) 100%),
    var(--nocd-base),
    #fff; }
.cs-fig.-timeline .tl-pill.-back  { left: 152px; top: 104px; width: 668px; height: 337px; }
.cs-fig.-timeline .tl-pill.-front { left: 179px; top: 125px; width: 623px; height: 296px; mix-blend-mode: multiply; }
.cs-fig.-timeline .tl-deck {
  position: absolute; left: 54px; top: 0; z-index: 2;
  width: 260px; height: 563px;
  touch-action: pan-y; cursor: pointer;
}
.cs-fig.-timeline .tl-card {
  position: absolute; inset: 0 auto auto 0; width: 100%;
  transform-origin: 50% 50%;
  /* The settle: a little slower than the home page deck, so a change of
     year reads as a swipe and not a cut (timeline.js). */
  transition: transform 0.72s cubic-bezier(0.22,0.61,0.20,1);
}
/* The first half of a swipe (timeline.js, SWIPE). Out: the front card
   leaves to the left, on top. In: the card coming back is pulled out to the
   left from behind the fan before it slides in. */
/* (Scoped through .tl-deck so they outrank the depth rules below.) */
.cs-fig.-timeline .tl-deck .tl-card.-out,
.cs-fig.-timeline .tl-deck .tl-card.-in {
  transform: translateX(-62%) rotate(-7deg) scale(0.94);
  transition: transform 0.46s cubic-bezier(0.45,0,0.35,1);
}
.cs-fig.-timeline .tl-deck .tl-card.-out { z-index: 8; }
.cs-fig.-timeline .tl-deck .tl-card.-in  { z-index: 1; }
/* Full-bleed and clipped at the screen edge, like the home page deck, so a
   card swiped out to the left is cut by the window rather than adding a
   sideways scroll. The padding puts the content back in the column. */
.cs-fig.-timeline {
  margin-left: calc(50% - 50vw); margin-right: calc(50% - 50vw);
  padding-left: calc(50vw - 50%); padding-right: calc(50vw - 50%);
  overflow-x: clip;
}
.cs-fig.-timeline .tl-deck.-dragging { cursor: grabbing; }
.cs-fig.-timeline .tl-deck.-dragging .tl-card { transition: none; }
/* The wash: a layer in the page colour on top of the screenshot. */
.cs-fig.-timeline .tl-card::after {
  content: ""; position: absolute; inset: 0; border-radius: 28px; pointer-events: none;
  background: var(--bg); opacity: 0; transition: opacity 0.35s ease;
}
.cs-fig.-timeline .tl-card img { transition: box-shadow 0.35s ease; }
/* Depths: the first two match the design file exactly (x offset, tilt,
   scale); the rest continue the same curve and stay clear of the copy. */
.cs-fig.-timeline .tl-card[data-d="0"] { z-index: 7; transform: translateX(var(--dx, 0px)) rotate(calc(var(--dx, 0px) / 1px * 0.03deg)); }
.cs-fig.-timeline .tl-card[data-d="1"] { z-index: 6; transform: translateX(26px) rotate(6.53deg) scale(0.885); }
.cs-fig.-timeline .tl-card[data-d="2"] { z-index: 5; transform: translateX(62px) rotate(10.36deg) scale(0.79); }
.cs-fig.-timeline .tl-card[data-d="3"] { z-index: 4; transform: translateX(76px) rotate(13deg) scale(0.70); }
.cs-fig.-timeline .tl-card[data-d="4"] { z-index: 3; transform: translateX(86px) rotate(15deg) scale(0.62); }
.cs-fig.-timeline .tl-card[data-d="5"] { z-index: 2; transform: translateX(94px) rotate(16.5deg) scale(0.55); }
.cs-fig.-timeline .tl-card:not([data-d="0"])::after { opacity: 0.4; }
@media (prefers-reduced-motion: reduce) {
  .cs-fig.-timeline .tl-card, .cs-fig.-timeline .tl-card::after, .cs-fig.-timeline .tl-card img { transition: none; }
  .cs-fig.-timeline.-js .tl-step.-on { animation: none; }
}
/* Small arrows either side of the dots: never on top of a screenshot. */
.cs-fig.-timeline .tl-nav { display: flex; align-items: center; justify-content: center; gap: 10px; margin: 6px 0 12px; }
.cs-fig.-timeline .tl-nav .dial { margin: 0; }
.cs-fig.-timeline .tl-arrow {
  width: 32px; height: 32px; padding: 0;
  display: grid; place-items: center;
  border: 0; border-radius: 100px; background: transparent;
  color: var(--text-dim); cursor: pointer;
  transition: background-color 0.2s ease, color 0.2s ease;
}
.cs-fig.-timeline .tl-arrow svg { width: 16px; height: 16px; }
.cs-fig.-timeline .tl-arrow:hover { color: var(--text); background: color-mix(in srgb, var(--text) 8%, transparent); }
.cs-fig.-timeline .tl-arrow:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

/* The copy, inside the pill's flat stretch and clear of its round end.
   The pill is light in both themes, so the ink is fixed dark. */
.cs-fig.-timeline .tl-text {
  position: absolute; left: 410px; top: 125px; z-index: 3;
  width: 336px; height: 296px;
  display: flex; align-items: center;
}
.cs-fig.-timeline .tl-text .tl-steps { width: 100%; }
.cs-fig.-timeline .tl-text .tl-year { color: #16171c; }
.cs-fig.-timeline .tl-text .tl-note { color: #5d5d66; }
.cs-fig.-timeline .tl-peek {
  margin-top: 18px; padding: 0; border: 0; background: none; cursor: pointer;
  color: #5d5d66; font-family: var(--body); font-size: 14px; font-weight: 500;
  text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px;
}
.cs-fig.-timeline .tl-peek:hover { color: #16171c; }
.cs-fig.-timeline .tl-peek:focus-visible { outline: 2px solid #16171c; outline-offset: 3px; border-radius: 4px; }

/* Dark mode keeps its teal to indigo gradient, but over the same white base
   and top wash as light mode, so the colours match the threads above and the
   rest of the community art, and the ink stays dark like in light mode.
   (It used to wash from the page colour with light ink, which shifted the
   gradient's colours.) Indigo is deeper than the light mode peach, so the
   note and link go fully black here to stay readable lower in the pill. */
html[data-theme="dark"] .cs-fig.-timeline .tl-text .tl-note,
html[data-theme="dark"] .cs-fig.-timeline .tl-peek { color: #16171c; }

/* Narrow screens: screenshots centred at the top, the pill becomes a band
   across the full width of the screen, starting partway up the screenshots
   (it stays behind them) and growing with the copy. No arrows (swipe and
   the dots do the job) and no "Earlier" link: the year title says it. */
@media (max-width: 860px) {
  .cs-fig.-timeline .tl-stage {
    --w: min(190px, 48vw);
    width: auto; max-width: none; height: auto;
    margin: 0 calc(50% - 50vw);
    display: flex; flex-direction: column; align-items: center;
  }
  .cs-fig.-timeline .tl-pill.-front { display: none; }
  /* Fades in from the page colour around the middle of the screenshots,
     so the band has no hard top edge. */
  .cs-fig.-timeline .tl-pill.-back {
    left: 0; right: 0; width: auto; top: calc(var(--w) * 0.9); bottom: 0; height: auto;
    border-radius: 0;
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 calc(var(--w) * 0.75));
            mask-image: linear-gradient(to bottom, transparent 0, #000 calc(var(--w) * 0.75));
  }
  .cs-fig.-timeline .tl-deck {
    position: relative; left: calc(var(--w) * -0.2); top: 0;
    width: var(--w); height: calc(var(--w) * 2.164);
  }
  .cs-fig.-timeline .tl-card[data-d="1"] { transform: translateX(calc(var(--w) * 0.10)) rotate(6.53deg) scale(0.885); }
  .cs-fig.-timeline .tl-card[data-d="2"] { transform: translateX(calc(var(--w) * 0.24)) rotate(10.36deg) scale(0.79); }
  .cs-fig.-timeline .tl-card[data-d="3"] { transform: translateX(calc(var(--w) * 0.31)) rotate(13deg) scale(0.70); }
  .cs-fig.-timeline .tl-card[data-d="4"] { transform: translateX(calc(var(--w) * 0.35)) rotate(15deg) scale(0.62); }
  .cs-fig.-timeline .tl-card[data-d="5"] { transform: translateX(calc(var(--w) * 0.38)) rotate(16.5deg) scale(0.55); }
  .cs-fig.-timeline .tl-text {
    position: relative; left: auto; top: auto; width: 100%; max-width: 520px; height: auto;
    display: block; padding: 16px 24px 20px; box-sizing: border-box; text-align: left;
  }
  .cs-fig.-timeline .tl-year { font-size: 22px; margin-bottom: 4px; }
  .cs-fig.-timeline .tl-note { font-size: 15px; line-height: 1.5; }
  .cs-fig.-timeline .tl-peek { display: none; }
  .cs-fig.-timeline .tl-nav { margin-top: 10px; }
  /* On a phone the year copy already says what this is. */
  .cs-fig.-timeline .tl-caption { display: none; }

}

/* Search before/after: the same wipe as the training case study, sized as a
   phone. Portrait screenshots stay legible at phone width, so unlike the
   landscape wipe this one is dragged in place rather than opened full screen. */
.ba.-phone { max-width: 340px; margin: 0 auto 12px; }
.ba.-phone .ba-stage {
  aspect-ratio: 378 / 818; border-radius: 28px;
  border: 1px solid var(--border); box-shadow: 0 14px 34px rgba(0,0,0,0.12);
  background: #fff;
}
/* The years sit above the frame rather than on it: on a phone screenshot
   the top corners are the status bar, and a pill there covers the clock.
   Each one brightens as more of its own screen is showing. */
.ba.-phone .ba-years {
  display: flex; justify-content: space-between; padding: 0 8px; margin-bottom: 8px;
  font-family: var(--body); font-size: 14px; font-weight: 600; color: var(--text);
}
.ba.-phone .ba-years .-l { opacity: calc(.35 + .65 * var(--p, .5)); }
.ba.-phone .ba-years .-r { opacity: calc(.35 + .65 * (1 - var(--p, .5))); }
@media (max-width: 860px) {
  .ba.-phone .ba-stage .ba-range { pointer-events: auto; }
}

/* Reactions: today's set as a clip, shown like any other image, with its
   caption underneath. The caption carries a "View earlier versions" toggle
   (a native <details>, so it works without script and by keyboard) that
   opens the two earlier sets on a white panel. The clip sits on the same
   white panel so its edges never look clipped against the page. */
.cs-fig.-reactions .rx-stage {
  background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
  padding: 28px 32px; margin-bottom: 12px;
}
.cs-fig.-reactions .rx-vid { max-width: 560px; margin: 0 auto; border-radius: 0; background: #fff; }
.cs-fig.-reactions .rx-vid video { object-fit: contain; }
.cs-fig.-reactions figcaption { text-align: center; }
.cs-fig.-reactions .rx-earlier { margin-top: 4px; }
.cs-fig.-reactions .rx-earlier > summary {
  display: inline-block; list-style: none; cursor: pointer;
  color: var(--text); font-weight: 500;
  text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px;
}
.cs-fig.-reactions .rx-earlier > summary::-webkit-details-marker { display: none; }
.cs-fig.-reactions .rx-earlier > summary::after { content: " \2193"; }
.cs-fig.-reactions .rx-earlier[open] > summary::after { content: " \2191"; }
.cs-fig.-reactions .rx-earlier > summary:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: 4px; }
.cs-fig.-reactions .rx-list {
  list-style: none; margin: 14px 0 0; padding: 26px 32px;
  display: grid; gap: 26px;
  background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
  text-align: left;
}
.cs-fig.-reactions .rx-row { display: grid; grid-template-columns: 140px 260px 1fr; gap: 24px; align-items: center; }
.cs-fig.-reactions .rx-when { margin: 0; color: #16171c; font-family: var(--body); font-size: 14px; font-weight: 600; }
.cs-fig.-reactions .rx-bar {
  margin: 0; border-radius: 0; height: auto;
  filter: drop-shadow(0 8px 18px rgba(24,20,14,0.12));
}
.cs-fig.-reactions .rx-bar.-v1, .cs-fig.-reactions .rx-bar.-v2 { width: 259px; }
.cs-fig.-reactions .rx-note { margin: 0; color: #5d5d66; font-size: 15px; line-height: 1.5; }
@media (max-width: 760px) {
  .cs-fig.-reactions .rx-stage { padding: 16px; }
  .cs-fig.-reactions .rx-list { padding: 20px; gap: 22px; }
  .cs-fig.-reactions .rx-row { grid-template-columns: 1fr; gap: 8px; }
  .cs-fig.-reactions .rx-bar.-v1, .cs-fig.-reactions .rx-bar.-v2 { width: min(259px, 100%); }
}

/* Cross-link card between the two split case studies. Reuses the -soon
   card's tag styling for the small "part two" flag, but the card itself is
   live, not disabled. */
.cs-crosslink {
  display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
  margin: 8px 0 40px; padding: 18px 22px; border-radius: 16px;
  border: 1px solid color-mix(in srgb, var(--chan) 30%, transparent);
  background: color-mix(in srgb, var(--chan) 6%, transparent);
  text-decoration: none; color: inherit;
  transition: background 0.2s ease, border-color 0.2s ease;
}
.cs-crosslink:hover, .cs-crosslink:focus-visible { background: color-mix(in srgb, var(--chan) 11%, transparent); border-color: var(--chan); }
.cs-crosslink .tag {
  display: inline-block; font-size: 12px; font-weight: 500; text-transform: none;
  color: #16171c; background: var(--chan-hl, var(--chan)); border-radius: 100px; padding: 3px 10px;
}
.cs-crosslink h3 { margin: 8px 0 2px; }
.cs-crosslink p { margin: 0; color: var(--text-dim); font-size: 14px; }
.cs-crosslink .arrow { font-size: 20px; color: var(--text); flex: none; }

/* Inline flag for something in a draft that still needs the subject's own
   words rather than a guess. Distinct from the placeholder art above:
   this is missing TEXT, not missing an image. */
.cs-gap {
  border-left: 3px solid color-mix(in srgb, var(--chan) 55%, transparent);
  background: color-mix(in srgb, var(--chan) 6%, transparent);
  border-radius: 0 10px 10px 0;
  padding: 12px 16px; margin: 18px 0;
  font-size: 14.5px; color: var(--text-dim);
}
.cs-gap b { color: var(--chan); font-weight: 600; }

/* ── More work: the shelf ─────────────────────────────────────────────────
   Work that may never become a case study: a short note and the screens,
   nothing more. Each entry is one row. The note sits left; the screens run
   right and keep going past the edge of the column, so the row reads as
   "there is more" without a carousel control. Sideways scroll on every size. */
.more { padding-top: 40px; }
.more-intro { margin-bottom: 64px; max-width: 60ch; }
.more-intro h1 { font-family: var(--display); font-weight: 600; font-size: clamp(34px, 4vw, 48px);
  line-height: var(--lh-display); letter-spacing: -0.015em; margin-bottom: 14px; }
.more-intro p { color: var(--text-dim); font-size: 17px; line-height: var(--lh-body); }

.shelf { display: grid; grid-template-columns: 1fr; gap: 28px;
  padding: 48px 0; border-top: 1px solid var(--border); }
.shelf.-r { --chan: var(--kw1-txt); --card-hl: var(--kw1); }
.shelf.-g { --chan: var(--kw3-txt); --card-hl: var(--kw3); }
.shelf.-b { --chan: var(--kw2-txt); --card-hl: var(--kw2); }
.shelf-meta { font-family: var(--body); font-size: 13px; font-weight: 500; color: var(--chan); margin-bottom: 10px; }
.shelf-head h2 { font-family: var(--display); font-weight: 600; font-size: 26px;
  line-height: var(--lh-display); letter-spacing: -0.01em; margin-bottom: 14px; text-wrap: balance; }
.shelf-head p { color: var(--text-dim); font-size: 15.5px; line-height: 1.6; margin-bottom: 12px; text-wrap: pretty; }
.shelf-head .shelf-role { padding-left: 12px; border-left: 2px solid var(--card-hl); }
.shelf-head .shelf-role p { color: var(--text); margin-bottom: 0; }
/* Same pill as the tag on the case-study crosslinks: dark text on the
   channel colour, sentence case. */
.shelf-head .shelf-role .shelf-role-tag { display: inline-block; margin-bottom: 8px;
  font-family: var(--body); font-size: 12px; font-weight: 500; line-height: 1.4;
  color: #16171c; background: var(--card-hl); border-radius: 100px; padding: 3px 10px; }

.shelf-strip { display: flex; gap: 20px; overflow-x: auto; scroll-snap-type: x mandatory;
  padding: 4px 0 18px;
  scroll-padding-left: 0;
  scrollbar-width: thin; }
/* More work sits in the same 820 column as the case studies, so the titles
   start where every other page starts. Each row of screens begins on that
   same edge and runs out to the right edge of the window, so it still reads
   as "there is more". */
.more .shelf-strip { margin-right: calc(50% - 50vw); padding-right: calc(50vw - 50%); }
.more .shelf-head { max-width: 640px; }
.shelf-strip:focus-visible { outline: 2px solid var(--focus); outline-offset: 4px; border-radius: 6px; }
.shelf-shot { flex: 0 0 230px; scroll-snap-align: start; margin: 0; }
.shelf-shot img { display: block; width: 100%; height: auto; border-radius: 11.8% / 5.45%;
  box-shadow: 0 0 0 1px var(--border), 0 12px 30px rgba(0,0,0,0.12); }
.shelf-shot figcaption { font-family: var(--body); font-size: 13px; color: var(--text-dim); margin-top: 12px; }

@media (max-width: 860px) {
  .shelf { gap: 24px; padding: 36px 0; }
  .shelf-shot { flex-basis: 200px; }
}

/* ── Home page: the More work card ───────────────────────────────────────
   Shaped like every other card: picture on top, title and line below with
   the same underline. The picture is a short, full-width strip of every
   screen on more.html, mixed across projects, drifting left on a long loop.
   The one accent is a light running along the strip's top edge, the same
   pass as the process line on the training platform: a faint channel line
   with a brighter head crossing it every few seconds. Nothing is hidden. */
.card.-reel .card-img { aspect-ratio: auto; height: clamp(200px, 20vw, 250px); display: block; }
.reel-track { position: absolute; top: 0; left: 0; display: flex; height: 100%;
  animation: reel-drift 150s linear infinite; animation-play-state: paused; }
.reel-run { display: flex; gap: 18px; padding-right: 18px; height: 100%; align-items: flex-start; }
/* Taller than the strip, so every screen bleeds off the bottom. Every other
   screen sits a little lower, the same stagger the snapshots use. These
   override the generic card image rule (full-bleed cover). */
.card-img .reel-run img { display: block; height: 112%; width: auto; object-fit: initial; margin-top: 20px;
  border-radius: 11.8% / 5.45%; box-shadow: 0 0 0 1px var(--border), 0 10px 26px rgba(0,0,0,0.12);
  transition: none; }
.card-img .reel-run img:nth-child(even) { margin-top: 42px; }
@keyframes reel-drift { to { transform: translateX(-50%); } }

/* The border draws itself once, the first time the card is properly in
   view: a line in the card's own gradient (its channel colour leaning into
   the next one, the same pair as the title underline, so it shifts with the
   theme) runs clockwise from the top left corner until the frame is closed.
   Then it swells for a moment with a soft glow around the card, and settles
   back to an ordinary border thickness, now in the gradient.

   The line is an SVG outline the size of the frame; pathLength=100 makes the
   draw a share of the perimeter at any width. The frame clips, so only the
   inner half of the stroke shows, sitting flush with the edge on every side.
   While it is in colour the neutral 1px border steps aside, so there is one
   line, not two. */
.reel-trace { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 2;
  pointer-events: none; overflow: visible; }
.reel-trace stop:first-child { stop-color: var(--card-hl); }
.reel-trace stop:last-child  { stop-color: color-mix(in oklab, var(--card-next) 70%, var(--card-hl)); }
.reel-trace rect { fill: none; stroke-width: 3; stroke-linecap: butt;
  stroke-dasharray: 100 100; stroke-dashoffset: 100; }
.reel.-traced .reel-trace rect { animation: reel-draw 2.9s forwards; }
@keyframes reel-draw {
  0%   { stroke-dashoffset: 100; stroke-width: 3; animation-timing-function: cubic-bezier(.45,0,.2,1); }
  62%  { stroke-dashoffset: 0;   stroke-width: 3; animation-timing-function: cubic-bezier(.3,0,.2,1); }
  76%  { stroke-dashoffset: 0;   stroke-width: 7; animation-timing-function: cubic-bezier(.4,0,.2,1); }
  100% { stroke-dashoffset: 0;   stroke-width: 2.5; }
}
.card.-reel .card-img.-traced { border-color: transparent; animation: reel-glow 1.4s cubic-bezier(.4,0,.2,1) 1.6s backwards; }
@keyframes reel-glow {
  0%   { border-color: var(--border); box-shadow: 0 0 0 0 transparent; }
  35%  { border-color: transparent; box-shadow: 0 0 26px 4px color-mix(in srgb, var(--card-hl) 38%, transparent); }
  100% { border-color: transparent; box-shadow: 0 0 0 0 transparent; }
}
.reel.-live .reel-track { animation-play-state: running; }
/* No white edge fade here: the strip is not a screenshot with a white
   background of its own, and white would smear in dark mode. */
.card.-reel .card-img::after { display: none; }

@media (max-width: 620px) {
  .card.-reel .card-img { height: 190px; }
  .reel-run { gap: 12px; padding-right: 12px; }
  .card-img .reel-run img { margin-top: 16px; }
  .card-img .reel-run img:nth-child(even) { margin-top: 34px; }
}
@media (prefers-reduced-motion: reduce) {
  .reel-track { animation: none; }
  /* No drawing and no swell: the finished gradient border is simply there. */
  .reel.-traced .reel-trace rect { animation: none; stroke-dashoffset: 0; stroke-width: 2.5; }
  .card.-reel .card-img.-traced { animation: none; border-color: transparent; }
}

/* ── Community: the Conqueror posts ──────────────────────────────────────
   The Figma composition (Noto, Slide 4) rebuilt from its parts so each card
   can move on its own: the background, then each card at its exact Figma
   position. Every card image carries its own shadow (Figma's: 0 14 34,
   12% black), so its box is the card plus room for that shadow: 34 either
   side, 20 above, 48 below, at Figma scale. .cq-face is the card itself
   inside that box, for the glow.

   The glow pair follows the page: in light the community art runs teal to
   orange, in dark teal to indigo (the same stops as --nocd-base). */
.cq { position: relative; width: 100%; aspect-ratio: 1349 / 1353; margin-bottom: 12px;
  border-radius: var(--radius); overflow: hidden; isolation: isolate;
  background: #f4fbfb url(images/conqueror/bg.webp) center / cover no-repeat;
  --cq-glow-a: #00d2e0; --cq-glow-b: #f8c46e; }
/* Dark: the same slide with the dark community gradient (teal to indigo,
   design file node 232:7784) in place of the light one, and the overlay
   shapes softened to a third so they stay as quiet as they are in light
   (Figma: "Slide 4 · dark", beside Slide 4). */
html[data-theme="dark"] .cq { --cq-glow-b: #545cea;
  background: #bdeefa url(images/conqueror/bg-dark.webp) center / cover no-repeat; }
.cq-card { position: absolute; transform-origin: 50% 50%; will-change: transform; }
.cs-fig .cq-card img { display: block; width: 100%; height: auto; margin: 0; border-radius: 0; }
.cq-card.-c1 { left: 2.0756%;  top: 4.1818%;  width: 48.4062%; }
.cq-card.-c2 { left: 2.0756%;  top: 44.8263%; width: 48.4062%; }
.cq-card.-c3 { left: 49.5182%; top: 1.6999%;  width: 48.4804%; }
.cq-card.-c4 { left: 49.5182%; top: 67.9623%; width: 48.4804%; }
.cq-face { position: absolute; left: 5.2%; right: 5.2%; top: var(--ft); bottom: var(--fb);
  border-radius: calc(24 / 585 * 100%) / calc(24 / var(--fh) * 100%); pointer-events: none; }
.-c1 .cq-face { --ft: calc(20 / 588 * 100%); --fb: calc(48 / 588 * 100%); --fh: 520; }
.-c2 .cq-face { --ft: calc(20 / 718 * 100%); --fb: calc(48 / 718 * 100%); --fh: 650; }
.-c3 .cq-face { --ft: calc(20 / 934 * 100%); --fb: calc(48 / 934 * 100%); --fh: 866; }
.-c4 .cq-face { --ft: calc(20 / 434 * 100%); --fb: calc(48 / 434 * 100%); --fh: 371; }

/* An inline link that hands off to another page at the end of a paragraph.
   Underlined in the page's own channel color, with the text's own decoration
   so it follows the line when it wraps. */
a.cs-inline {
  color: var(--text);
  text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px;
  text-decoration-color: var(--chan-hl, var(--kw1));
  transition: text-decoration-color .2s ease;
}
a.cs-inline:hover { text-decoration-color: currentColor; }

/* Placeholders get the same gap to their caption as a real image (12px). */
.cs-fig.-placeholder .ph-box { margin-bottom: 12px; }

/* The "More work" row with three cards: on desktop all three sit side by side
   across the page width, so nothing hides off to the side. Below that it
   stays a swipeable row. */
@media (min-width: 861px) {
  .rail.-three { display: grid; grid-template-columns: repeat(3, 1fr); overflow: visible; scroll-snap-type: none; }
  .rail.-three .card { flex: none; min-width: 0; }
}

/* ── Conqueror illustration ─────────────────────────────────────────────
   From the Figma frame "Live_QA_070824_YT" (node 318:1264): the summit and
   flag every Conqueror post carries, under "You've become an OCD
   Conqueror!", with its own sky, sparkles and two clouds. Used for the
   Conqueror card in the More work rows and as the Conqueror page hero.

   Motion: the first time it comes into view, the two clouds glide in from
   the edges and come to rest exactly where the Figma frame has them. After
   that only small things move: the flag waves and the sparkles twinkle.
   Without script, or with reduced motion, it is simply the finished frame.

   Light: the frame's own sky (#A8D4FD into #86D4D9, with #6FB8FC coming down
   from the top and a white wash rising from the bottom). Dark: the same
   layers in the community's dark pair, indigo into teal (#545CEA, #00D2E0). */
.cq-thumb { display: block; position: relative; overflow: hidden; }
.cq-thumb .cqt-art { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.cq-thumb .a0 { stop-color: #a8d4fd; } .cq-thumb .a1 { stop-color: #86d4d9; }
.cq-thumb .b0 { stop-color: #6fb8fc; } .cq-thumb .b1 { stop-color: #8cc6fc; }
.cq-thumb .cqt-wash { opacity: .8; }
html[data-theme="dark"] .cq-thumb .a0 { stop-color: #545cea; }
html[data-theme="dark"] .cq-thumb .a1 { stop-color: #00d2e0; }
html[data-theme="dark"] .cq-thumb .b0 { stop-color: #3c43c4; }
html[data-theme="dark"] .cq-thumb .b1 { stop-color: #545cea; }
html[data-theme="dark"] .cq-thumb .cqt-wash { opacity: .35; }
.cqt-title { font-family: "SF Pro Display", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
  font-weight: 700; font-size: 32px; letter-spacing: 0; }

/* Clouds: parked off to the sides until script says the frame is in view,
   then they glide to their places and stop. */
.cqt-cloud { transition: transform 2.6s cubic-bezier(.16, .84, .3, 1), opacity 1.6s ease-out; }
.cq-thumb.-ready:not(.-in) .cqt-cloud { transition: none; opacity: 0; }
.cq-thumb.-ready:not(.-in) .cqt-cloud.-l { transform: translateX(-90px); }
.cq-thumb.-ready:not(.-in) .cqt-cloud.-r { transform: translateX(90px); }
.cq-thumb.-in .cqt-cloud.-r { transition-delay: .15s; }

/* The flag waves from the pole, with a small ripple in the tail. */
.cqt-flag { transform-box: view-box; transform-origin: 273px 158px; animation: cqt-wave 3.2s ease-in-out infinite; }
@keyframes cqt-wave {
  0%, 100% { transform: skewY(0deg) scaleX(1); }
  30% { transform: skewY(-3.5deg) scaleX(.95); }
  60% { transform: skewY(2deg) scaleX(.985); }
}
.cqt-flag-tail { transform-box: fill-box; transform-origin: 100% 50%; animation: cqt-ripple 3.2s ease-in-out infinite; animation-delay: -.4s; }
@keyframes cqt-ripple { 0%, 100% { transform: skewY(0); } 40% { transform: skewY(5deg); } 70% { transform: skewY(-3deg); } }

/* Sparkles: a slow, staggered twinkle. */
.cqt-spark { transform-box: fill-box; transform-origin: center; animation: cqt-twinkle 4.8s ease-in-out infinite; }
.cqt-spark.-s2 { animation-delay: -1.6s; }
.cqt-spark.-s3 { animation-delay: -3.2s; }
@keyframes cqt-twinkle { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.8); } }

/* The phone-width white fade on screenshot cards is for screenshots; this
   is a drawing with its own sky, so it goes without. */
.card .card-img.cq-thumb::after { display: none; }
/* As the Conqueror page hero: full column width, the Figma frame's shape. */
.cq-hero { aspect-ratio: 553 / 306; border-radius: var(--radius); }

@media (prefers-reduced-motion: reduce) {
  .cqt-flag, .cqt-flag-tail, .cqt-spark { animation: none; }
  .cqt-cloud { transition: none; }
  .cq-thumb.-ready:not(.-in) .cqt-cloud { opacity: 1; transform: none; }
}

/* Screens in a case study: a hairline ring so white screenshots keep their
   edge on the light page. -phone rounds them like the device. */
.cs-fig img.-shot { box-shadow: 0 0 0 1px var(--border); }
.cs-fig img.-shot.-phone { border-radius: 22px; margin-bottom: 0; }
/* Two phone screens side by side, centered, with an optional small label
   under each (Before / After). */
.cs-duo { display: grid; grid-template-columns: repeat(2, minmax(0, 260px)); justify-content: center; align-items: start; gap: 28px; margin-bottom: 12px; }
.cs-duo > span { display: block; }
.cs-duo-label { display: block; margin-top: 10px; text-align: center; font-family: var(--body); font-size: 13px; font-weight: 500; color: var(--text-dim); }
@media (max-width: 620px) { .cs-duo { gap: 14px; } }

/* Conqueror: profile before/after on the phone wipe, parked so the current
   profile fills three quarters of the frame. The crops are 744 x 1618. */
.cs-fig.-profileba .ba.-phone .ba-stage { aspect-ratio: 744 / 1618; }
/* Therapist validation: just the dialog, at a size where its copy reads. */
.cs-fig.-modal img.-shot { display: block; max-width: 640px; width: 100%; margin: 0 auto 12px; border-radius: 10px; }

/* Home grid: a row of three for the cards still to come. */
.span-4 { grid-column: span 4; }
@media (max-width: 900px) { .span-4 { grid-column: span 12; } }
@media (max-width: 760px) { .span-4 { grid-column: span 1; } }
/* The Conqueror deck card: the summit fills the screen, framed upright. */
.deck-card .screen.-art .cq-thumb { position: absolute; inset: 0; }
/* Conqueror beside Community on the home grid: framed so the two pictures
   end on the same line (a span-5 at 9:8 is as tall as a span-7 at 16:10). */
@media (min-width: 901px) { .work-grid .card.-match .card-img { aspect-ratio: 9 / 8; } }
@media (max-width: 900px) { .work-grid .card.-match .card-img { aspect-ratio: 4 / 3; } }
/* A phone recording shown as a phone: upright, framed like the profile
   before/after above it, and the same width. */
.cs-vid.-phone {
  max-width: 340px; margin: 0 auto 12px; border-radius: 28px;
  border: 1px solid var(--border); box-shadow: 0 14px 34px rgba(0,0,0,0.12);
  background: #fff;
}
/* Four stats sit two by two rather than three and an orphan. */
@media (min-width: 621px) { .cs-stats.-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }

/* Conqueror: the member's milestone journey as stills with arrows, using the
   More work shelf strip. Left edge stays on the reading column; the strip
   runs off the right edge of the window and scrolls sideways. */
.cs-fig.-journey .cq-journey {
  gap: 14px; align-items: flex-start;
  margin-right: calc(50% - 50vw); padding-right: calc(50vw - 50%);
  scroll-snap-type: x proximity;
}
.cq-journey .shelf-shot { flex: 0 0 200px; position: relative; }
.cq-journey .shelf-shot figcaption { display: flex; gap: 8px; align-items: flex-start; text-align: left;
  margin-top: 12px; color: var(--text); font-weight: 500; line-height: 1.45; text-wrap: balance; }
.cq-journey .cq-step {
  flex: none; display: grid; place-items: center; width: 20px; height: 20px; margin-top: -1px;
  border-radius: 50%; background: var(--chan-hl); color: #111; font-size: 11px; font-weight: 600;
}
.cq-arrow {
  flex: 0 0 22px; color: var(--text-dim);
  /* Centered on the screens, not on screen plus caption. */
  margin-top: calc(200px * 1266 / 585 / 2 - 11px);
}
@media (max-width: 860px) {
  .cq-journey .shelf-shot { flex-basis: 170px; }
  .cq-arrow { margin-top: calc(170px * 1266 / 585 / 2 - 11px); }
}

/* Conqueror outcome: three recovery stories on the community background,
   laid out at Figma scale (1349 wide, cards 585 wide, as on the Community
   page) but with different posts. Usernames are community handles; the one
   photo avatar is swapped for a default illustrated one, and a therapist's
   first name is taken out of the post text. */
.cq.-stories { aspect-ratio: 1349 / 1130; }
.cqs-card { position: absolute; width: calc(585 / 1349 * 100%); }
.cs-fig .cqs-card img { display: block; width: 100%; height: auto; margin: 0;
  border-radius: clamp(7px, 1.8vw, 15px); box-shadow: 0 8px 22px rgba(0,0,0,0.12); }
.cqs-card.-s1 { left: calc(62 / 1349 * 100%);  top: calc(72 / 1130 * 100%); }
.cqs-card.-s2 { left: calc(702 / 1349 * 100%); top: calc(48 / 1130 * 100%); }
.cqs-card.-s3 { left: calc(702 / 1349 * 100%); top: calc(712 / 1130 * 100%); }

/* Desktop: the journey is centered on the window and runs off both edges
   when it doesn't fit, so neither side looks cut by the reading column.
   Auto margins on the ends center it without ever hiding the first screen. */
@media (min-width: 861px) {
  .cs-fig.-journey .cq-journey {
    margin-left: calc(50% - 50vw); margin-right: calc(50% - 50vw);
    padding-left: 32px; padding-right: 32px; scroll-snap-type: none;
  }
  .cq-journey > :first-child { margin-left: auto; }
  .cq-journey > :last-child { margin-right: auto; }
  .cq-journey .shelf-shot { flex-basis: 180px; }
  .cq-arrow { margin-top: calc(180px * 1266 / 585 / 2 - 11px); }
}

/* Recovery stories: a face behind each card for the glow, the card's own
   size and corners. */
.cqs-card .cq-face { left: 0; right: 0; top: 0; bottom: 0; border-radius: clamp(7px, 1.8vw, 15px); }
.cs-fig .cqs-card img { position: relative; }
.cqs-card { transform-origin: 50% 50%; will-change: transform; }
/* Progress tab: one upright phone, the same width as the profile slider. */
.cs-fig.-progress img.-shot { display: block; width: 100%; max-width: 300px; margin: 0 auto 12px; }

/* ── Deck: animate only the card in front ────────────────────────────────
   The Conqueror art's flag and sparkles loop forever. Inside the deck every
   card sits under frosted glass, so a loop on a side card meant redrawing
   the whole stack on every frame. They now run only on the card in front,
   and pause while it is out of focus. */
.deck-card:not([aria-current="true"]) .cqt-flag,
.deck-card:not([aria-current="true"]) .cqt-flag-tail,
.deck-card:not([aria-current="true"]) .cqt-spark { animation-play-state: paused; }
/* Everywhere else (the hero, the grid card, the More work cards), the loops
   run only while the picture is on screen: script marks it -live. */
.cq-thumb.-ready:not(.-live) .cqt-flag,
.cq-thumb.-ready:not(.-live) .cqt-flag-tail,
.cq-thumb.-ready:not(.-live) .cqt-spark { animation-play-state: paused; }

/* Booking draft: phone-shaped placeholders, and the "to confirm" marker for
   copy still to settle. */
.cs-fig.-placeholder.-phoneph .ph-box { max-width: 300px; margin: 0 auto 12px; }
.cs-duo.-three { grid-template-columns: repeat(3, minmax(0, 220px)); }
@media (max-width: 620px) { .cs-duo.-three { gap: 10px; } }
.cs-fig.-bkduo .cs-duo .ph-box { margin-bottom: 0; border-radius: 22px; padding: 16px; }
.cs-fig.-journey.-placeholder .shelf-shot .ph-box { display: flex; border-radius: 22px; padding: 12px; margin-bottom: 0; }
mark.tbc { background: var(--chan-hl); color: #16171c; border-radius: 4px; padding: 0 4px; }
/* Booking hero: the directory and a therapist profile on the NOCD gradient,
   the same background as the Conqueror posts. The same picture is the home
   card, the rail card and the deck card. The two phones float a little,
   slowly and out of step, so they drift over each other; they only move
   while the picture is on screen (-live, from rgb-shape.js, or the card in
   front of the deck). Rotation and float use the separate rotate/translate
   properties so neither overrides the other. */
.bk-hero { position: relative; display: block; aspect-ratio: 16 / 9; border-radius: var(--radius); overflow: hidden;
  background: #f4fbfb url(images/conqueror/bg.webp) center / cover no-repeat; }
html[data-theme="dark"] .bk-hero { background: #bdeefa url(images/conqueror/bg-dark.webp) center / cover no-repeat; }
.card-img .bk-hero, .deck-card .screen .bk-hero { position: absolute; inset: 0; aspect-ratio: auto; border-radius: 0; }
.bk-hero img, .card-img .bk-hero img, .deck-card .screen .bk-hero img {
  position: absolute; width: 30%; height: auto; margin: 0; max-width: none; object-fit: initial;
  border-radius: 7.5% / 3.5%; box-shadow: 0 0 0 1px rgba(0,0,0,0.06), 0 18px 40px rgba(20,20,40,0.18);
  animation: bk-float-a 7s ease-in-out infinite; animation-play-state: paused; }
.bk-hero img.-dir { left: 19%; top: 15%; rotate: -5deg; }
.bk-hero img.-pro { left: 51%; top: 8%; rotate: 4deg; animation-name: bk-float-b; animation-duration: 8.5s; animation-delay: -3s; }
.card-img .bk-hero img.-dir { top: 13%; }
.card-img .bk-hero img.-pro { top: 6%; }
.bk-hero.-live img { animation-play-state: running; }
.deck-card:not([aria-current="true"]) .bk-hero.-live img { animation-play-state: paused; }
/* The booking picture is not a cropped screenshot, so on a phone in dark
   mode it skips the white fade at the right edge. */
@media (max-width: 620px) { html[data-theme="dark"] .card-img:has(.bk-hero)::after { display: none; } }
@keyframes bk-float-a { 0%, 100% { translate: 0 0; } 50% { translate: 1.2% -2.2%; } }
@keyframes bk-float-b { 0%, 100% { translate: 0 0; } 50% { translate: -1.2% -1.8%; } }
@media (prefers-reduced-motion: reduce) { .bk-hero img { animation: none; } }

/* Booking: the profile before/after uses the 2022 and 2024 screens */
.cs-fig.-bkba .ba.-phone .ba-stage { aspect-ratio: 750 / 1562; }
/* Journey steps that are not numbered (Start) get a pill that fits the word */
.cq-journey .cq-step.-text { width: auto; padding: 0 7px; border-radius: 10px; }

/* Fan on a phone, dark mode: the band used to start in a grey-white smear
   (the white top wash fading over black) and stop at a hard bottom edge.
   Here it rises from the black in its own colors over a longer, eased fade,
   and fades back out at the bottom, below the copy. */
@media (max-width: 860px) {
  html[data-theme="dark"] .cs-fig.-timeline .tl-pill.-back {
    background: linear-gradient(166deg, rgba(187,251,255,0.35) 2.4%, rgba(218,251,254,0) 60%), var(--nocd-base);
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, rgba(0,0,0,.12) calc(var(--w) * 0.35), rgba(0,0,0,.45) calc(var(--w) * 0.7), rgba(0,0,0,.85) calc(var(--w) * 1.0), #000 calc(var(--w) * 1.2), #000 calc(100% - 64px), rgba(0,0,0,.5) calc(100% - 28px), transparent 100%);
            mask-image: linear-gradient(to bottom, transparent 0, rgba(0,0,0,.12) calc(var(--w) * 0.35), rgba(0,0,0,.45) calc(var(--w) * 0.7), rgba(0,0,0,.85) calc(var(--w) * 1.0), #000 calc(var(--w) * 1.2), #000 calc(100% - 64px), rgba(0,0,0,.5) calc(100% - 28px), transparent 100%);
  }
  html[data-theme="dark"] .cs-fig.-timeline .tl-text { padding-bottom: 52px; }
}

/* Theme switch with reduced motion on: a short, gentle cross-fade of the
   whole page instead of the circle animation (see toggleTheme). */
html.-theme-fade::view-transition-old(root),
html.-theme-fade::view-transition-new(root) {
  animation-duration: 0.4s; animation-timing-function: ease;
}

/* Rebound: the hero is the booking hero's layout with three phones on the
   deep Rebound green, the practice path, the therapy home in front, and a
   grounding exercise. Same float, same -live rule. The green is the app's
   own, so it stays the same in both themes. */
.bk-hero.-rb, html[data-theme="dark"] .bk-hero.-rb {
  background: radial-gradient(120% 90% at 50% 20%, #2f625c 0%, #1a4441 55%, #12302e 100%); }
.bk-hero.-rb img { width: 27%; box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 22px 44px rgba(0,0,0,0.32); }
.bk-hero.-rb img.-a { left: 11%; top: 16%; rotate: -6deg; }
.bk-hero.-rb img.-b { left: 36.5%; top: 7%; z-index: 2; animation-name: bk-float-b; animation-duration: 8.5s; animation-delay: -3s; }
.bk-hero.-rb img.-c { left: 62%; top: 16%; rotate: 6deg; animation-delay: -5s; }
.card-img .bk-hero.-rb img.-b { top: 5%; }
/* Phone screens side by side, a little smaller than the booking pair. */
.cs-fig.-rbduo .cs-duo { margin-bottom: 12px; }
/* Rebound: phone recordings sit beside phone stills, same shape and corners. */
.cs-vid.-phone { display: block; --ratio: 216.67%; border-radius: 22px; margin-bottom: 0; border: 0; box-shadow: 0 0 0 1px var(--border); background: #193a38; }
.cs-fig.-rbbrand img.-shot { display: block; width: 100%; height: auto; border-radius: var(--radius); margin-bottom: 12px; box-shadow: none; }
/* In the deck the card is taller than it is wide, so the three phones grow
   to fill it. */
.deck-card .screen .bk-hero.-rb img { width: 46%; }
.deck-card .screen .bk-hero.-rb img.-a { left: 1%; top: 17%; }
.deck-card .screen .bk-hero.-rb img.-b { left: 27%; top: 7%; }
.deck-card .screen .bk-hero.-rb img.-c { left: 53%; top: 17%; }

/* Rebound picture: one phone in focus over the breathing exercise. The
   leaf is the background of the app's own breathing screen, the bloom is
   its eight soft circles, opening and closing while the whole flower turns
   a little, slowly, like a breath. The logo sits at the top, the way the
   NOCD logo does on the Community card. Moves only while on screen (-live,
   same as the booking picture) and stands still for reduced motion. */
.bk-hero.-rbart, html[data-theme="dark"] .bk-hero.-rbart { background: #1d4038; }
.bk-hero.-rbart img.rb-bg, .card-img .bk-hero.-rbart img.rb-bg, .deck-card .screen .bk-hero.-rbart img.rb-bg {
  left: 0; top: 0; width: 100%; height: 100%; object-fit: cover; rotate: none;
  border-radius: 0; box-shadow: none; animation: none; }
.rb-bloom { position: absolute; left: 50%; top: 60%; width: 64%; aspect-ratio: 1; translate: -50% -50%;
  animation: rb-turn 10s ease-in-out infinite; animation-play-state: paused; }
.rb-bloom i { position: absolute; left: 25%; top: 25%; width: 50%; height: 50%; border-radius: 50%;
  background: rgba(255, 255, 255, 0.2); box-shadow: 0 0 40px rgba(255, 255, 255, 0.06);
  transform: rotate(calc(var(--k) * 45deg)) translateY(-24%);
  animation: rb-petal 10s ease-in-out infinite; animation-play-state: paused; }
.bk-hero.-rbart.-live .rb-bloom, .bk-hero.-rbart.-live .rb-bloom i { animation-play-state: running; }
.deck-card:not([aria-current="true"]) .bk-hero.-rbart .rb-bloom,
.deck-card:not([aria-current="true"]) .bk-hero.-rbart .rb-bloom i { animation-play-state: paused; }
@keyframes rb-turn { 0%, 100% { rotate: -14deg; scale: 0.82; } 50% { rotate: 18deg; scale: 1; } }
@keyframes rb-petal {
  0%, 100% { transform: rotate(calc(var(--k) * 45deg)) translateY(-8%); }
  50% { transform: rotate(calc(var(--k) * 45deg)) translateY(-30%); } }
.bk-hero.-rbart img.rb-phone, .card-img .bk-hero.-rbart img.rb-phone, .deck-card .screen .bk-hero.-rbart img.rb-phone {
  width: 27%; left: 36.5%; top: 25%; rotate: none; z-index: 2;
  box-shadow: 0 0 0 1px rgba(255,255,255,0.1), 0 24px 50px rgba(0,0,0,0.35);
  animation-name: bk-float-b; animation-duration: 9s; }
.bk-hero.-rbart img.rb-logo, .card-img .bk-hero.-rbart img.rb-logo, .deck-card .screen .bk-hero.-rbart img.rb-logo {
  width: 19%; left: 40.5%; top: 8%; rotate: none; border-radius: 0; box-shadow: none; animation: none; z-index: 3; }
/* The deck card is taller than wide: a bigger phone, a bigger flower. */
.deck-card .screen .bk-hero.-rbart img.rb-phone { width: 54%; left: 23%; top: 23%; }
.deck-card .screen .bk-hero.-rbart img.rb-logo { width: 42%; left: 29%; top: 7%; }
.deck-card .screen .bk-hero.-rbart .rb-bloom { width: 125%; top: 52%; }
@media (prefers-reduced-motion: reduce) { .rb-bloom, .rb-bloom i { animation: none; } }
.cs-fig.-rbstreak .cs-vid.-phone { max-width: 260px; margin: 0 auto 12px; }
.cs-fig.-rbstreak figcaption { text-align: center; }
.bk-hero.-rbart .rb-bloom { width: 78%; top: 58%; }
.deck-card .screen .bk-hero.-rbart .rb-bloom { width: 125%; top: 52%; }

/* Rebound, round 3. */
/* Phone screens: corners scale with the phone, so a small phone keeps its
   status bar and edges instead of being cut by a fixed 22px curve. */
.cs-fig.-rbduo img.-shot.-phone, .cs-vid.-phone { border-radius: 7% / 3.25%; }
/* Facts: one column on a phone, no empty half-rows. */
@media (max-width: 620px) { .cs-facts { grid-template-columns: 1fr; gap: 16px; } }
/* Desktop picture: two more screens slide out from behind the one in focus
   the first time it comes into view, then stay while the bloom breathes. */
.bk-hero.-rbart img.rb-side, .card-img .bk-hero.-rbart img.rb-side {
  width: 22%; left: 39%; top: 30%; rotate: none; z-index: 1; opacity: 0;
  box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 18px 40px rgba(0,0,0,0.3);
  animation: rb-side-l 1.6s cubic-bezier(.2,.7,.2,1) 0.4s forwards; animation-play-state: paused; }
.bk-hero.-rbart img.rb-side.-r { animation-name: rb-side-r; }
.bk-hero.-rbart.-live img.rb-side { animation-play-state: running; }
@keyframes rb-side-l { to { left: 15%; top: 34%; rotate: -6deg; opacity: 1; } }
@keyframes rb-side-r { to { left: 63%; top: 34%; rotate: 6deg; opacity: 1; } }
.deck-card .screen .bk-hero.-rbart img.rb-side { display: none; }
@media (prefers-reduced-motion: reduce) {
  .bk-hero.-rbart img.rb-side { animation: none; opacity: 1; top: 34%; }
  .bk-hero.-rbart img.rb-side.-l { left: 15%; rotate: -6deg; }
  .bk-hero.-rbart img.rb-side.-r { left: 63%; rotate: 6deg; } }
/* Phone width: no side screens; a bigger phone, and the logo moves to the
   bottom right as a watermark so the screen gets the height. */
@media (max-width: 620px) {
  .bk-hero.-rbart img.rb-side { display: none; }
  .bk-hero.-rbart img.rb-phone, .card-img .bk-hero.-rbart img.rb-phone { width: 46%; left: 27%; top: 7%; }
  .bk-hero.-rbart img.rb-logo, .card-img .bk-hero.-rbart img.rb-logo { width: 21%; left: auto; right: 4%; top: auto; bottom: 6%; }
  .bk-hero.-rbart .rb-bloom { width: 96%; top: 52%; }
}
/* The carousel card is tall: the phone takes most of it, the logo sits at
   the bottom right. */
.deck-card .screen .bk-hero.-rbart img.rb-phone { width: 64%; left: 9%; top: 6%; }
.deck-card .screen .bk-hero.-rbart img.rb-logo { width: 27%; left: auto; right: 5%; top: auto; bottom: 5%; }
.deck-card .screen .bk-hero.-rbart .rb-bloom { width: 130%; left: 41%; top: 46%; }
/* Dark mode: lighter leaves, and the flower never closes as far, so the
   picture doesn't sink into the dark page. */
html[data-theme="dark"] .bk-hero.-rbart img.rb-bg { filter: brightness(1.55) saturate(0.9); }
html[data-theme="dark"] .rb-bloom { animation-name: rb-turn-dk; }
html[data-theme="dark"] .rb-bloom i { animation-name: rb-petal-dk; }
@keyframes rb-turn-dk { 0%, 100% { rotate: -10deg; scale: 0.92; } 50% { rotate: 16deg; scale: 1.02; } }
@keyframes rb-petal-dk {
  0%, 100% { transform: rotate(calc(var(--k) * 45deg)) translateY(-18%); }
  50% { transform: rotate(calc(var(--k) * 45deg)) translateY(-30%); } }

/* Rebound, round 4.
   Cards (home grid and carousel) go back to one phone under the logo. The
   phone's status bar fades into the leaves, so the logo brands the top
   the way the NOCD logo does on the Community card. The case study header
   keeps the side screens on desktop and the watermark on phones. */
.card-img .bk-hero.-rbart img.rb-side { display: none; }
.card-img .bk-hero.-rbart img.rb-phone, .deck-card .screen .bk-hero.-rbart img.rb-phone,
.card-img .bk-hero.-rbart picture img.rb-phone {
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, transparent 2.2%, #000 9%);
          mask-image: linear-gradient(to bottom, transparent 0, transparent 2.2%, #000 9%); }
.card-img .bk-hero.-rbart img.rb-phone { width: 30%; left: 35%; top: 16%; }
.card-img .bk-hero.-rbart img.rb-logo { width: 21%; left: 39.5%; right: auto; top: 6%; bottom: auto; }
.card-img .bk-hero.-rbart .rb-bloom { width: 74%; left: 50%; top: 62%; }
.deck-card .screen .bk-hero.-rbart img.rb-phone { width: 58%; left: 21%; top: 13%; }
.deck-card .screen .bk-hero.-rbart img.rb-logo { width: 44%; left: 28%; right: auto; top: 5%; bottom: auto; }
.deck-card .screen .bk-hero.-rbart .rb-bloom { width: 128%; left: 50%; top: 55%; }
@media (max-width: 620px) {
  .card-img .bk-hero.-rbart img.rb-phone { width: 36%; left: 32%; top: 18%; }
  .card-img .bk-hero.-rbart img.rb-logo { width: 26%; left: 37%; right: auto; top: 6%; bottom: auto; }
}
/* Case study header: the side screens open more slowly and a little less. */
.bk-hero.-rbart.-page img.rb-side { animation-duration: 2.6s; animation-delay: 0.5s; }
@keyframes rb-side-l { to { left: 17.5%; top: 34%; rotate: -5deg; opacity: 1; } }
@keyframes rb-side-r { to { left: 60.5%; top: 34%; rotate: 5deg; opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  .bk-hero.-rbart img.rb-side.-l { left: 17.5%; rotate: -5deg; }
  .bk-hero.-rbart img.rb-side.-r { left: 60.5%; rotate: 5deg; } }
/* Case study header on a phone: the screen starts at Welcome (no status bar
   or top bar), and the flower sits lower. */
@media (max-width: 620px) {
  .bk-hero.-rbart.-page img.rb-phone { width: 48%; left: 26%; top: 8%; border-radius: 6% / 3%; }
  .bk-hero.-rbart.-page .rb-bloom { width: 100%; top: 68%; }
}
/* Light mode: softer, lighter leaves, still dark enough for the white logo. */
.bk-hero.-rbart img.rb-bg { filter: brightness(1.25) saturate(0.92); }
html[data-theme="dark"] .bk-hero.-rbart img.rb-bg { filter: brightness(1.55) saturate(0.9); }

/* The recordings sit on a Rebound panel, so they read as part of the brand
   instead of floating on the page: cream in light mode, deep green in dark,
   with the ribbon from the app drawn in outline. */
.cs-fig.-rbstreak {
  position: relative; overflow: hidden; border-radius: var(--radius);
  padding: 44px 16px 22px; background: #f1e9db;
}
.cs-fig.-rbstreak::before {
  content: ""; position: absolute; inset: 0; pointer-events: none; opacity: 0.9;
  background: no-repeat right -60px top -40px / 520px auto url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 520 420' fill='none'%3E%3Cpath d='M-40 330 C 90 250 210 210 300 236 C 400 266 420 360 352 384 C 280 410 236 330 266 250 C 300 160 400 96 560 70' stroke='%239CC2AB' stroke-width='26' stroke-linecap='round' opacity='.55'/%3E%3C/svg%3E"),
              no-repeat left -90px bottom -70px / 380px auto url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 380 300' fill='none'%3E%3Cpath d='M-30 60 C 80 40 170 80 200 150 C 226 214 180 262 140 240 C 100 218 120 150 190 120 C 260 90 330 110 420 170' stroke='%239CC2AB' stroke-width='22' stroke-linecap='round' opacity='.4'/%3E%3C/svg%3E");
}
.cs-fig.-rbstreak > * { position: relative; }
.cs-fig.-rbstreak figcaption { color: #4c5a55; margin-top: 4px; }
html[data-theme="dark"] .cs-fig.-rbstreak { background: #15302c; }
html[data-theme="dark"] .cs-fig.-rbstreak::before { opacity: 0.35; }
html[data-theme="dark"] .cs-fig.-rbstreak figcaption { color: #b9cfc6; }

/* Rebound, round 5. Cards: no fade, the screen is simply cut just above the
   top bar, so the logo sits over leaves, not over a see-through phone. */
.card-img .bk-hero.-rbart img.rb-phone, .deck-card .screen .bk-hero.-rbart img.rb-phone {
  -webkit-mask-image: none; mask-image: none; border-radius: 7.5% / 3.8%; }
/* Rebound panels: full width on a phone, a lighter green in dark mode so the
   dark screens stand off it. */
@media (max-width: 620px) {
  .cs-fig.-rbstreak { margin-left: calc(50% - 50vw); margin-right: calc(50% - 50vw); border-radius: 0; padding: 36px 16px 20px; }
}
html[data-theme="dark"] .cs-fig.-rbstreak { background: #35554d; }
html[data-theme="dark"] .cs-fig.-rbstreak::before { opacity: 0.5; }
html[data-theme="dark"] .cs-fig.-rbstreak figcaption { color: #dbe8e2; }
.deck-card .screen .bk-hero.-rbart img.rb-phone { top: 17.5%; }
.card-img .bk-hero.-rbart img.rb-phone { top: 20%; }
@media (max-width: 620px) { .card-img .bk-hero.-rbart img.rb-phone { top: 22%; } }

/* Rebound, round 6. Case study header: the side screens are solid from the
   start, hidden behind the phone in focus, and slide out slowly with a small
   settle at the end. */
.bk-hero.-rbart.-page img.rb-side { opacity: 1; animation-duration: 2.8s; animation-delay: 0.5s;
  animation-timing-function: cubic-bezier(.3, 1.35, .45, 1); }
@keyframes rb-side-l { from { left: 39%; top: 30%; rotate: 0deg; opacity: 1; } to { left: 17.5%; top: 34%; rotate: -5deg; opacity: 1; } }
@keyframes rb-side-r { from { left: 39%; top: 30%; rotate: 0deg; opacity: 1; } to { left: 60.5%; top: 34%; rotate: 5deg; opacity: 1; } }
/* Home card: the header's finished picture, still: three screens open. */
.card-img .bk-hero.-rbart.-still img.rb-side { display: block; opacity: 1; animation: none; top: 34%; width: 22%; }
.card-img .bk-hero.-rbart.-still img.rb-side.-l { left: 17.5%; rotate: -5deg; }
.card-img .bk-hero.-rbart.-still img.rb-side.-r { left: 60.5%; rotate: 5deg; }
.card-img .bk-hero.-rbart.-still img.rb-phone { width: 27%; left: 36.5%; top: 25%; border-radius: 7.5% / 3.5%; }
.card-img .bk-hero.-rbart.-still img.rb-logo { width: 19%; left: 40.5%; top: 8%; right: auto; bottom: auto; }
.card-img .bk-hero.-rbart.-still .rb-bloom { width: 78%; left: 50%; top: 58%; }
@media (max-width: 620px) {
  .card-img .bk-hero.-rbart.-still img.rb-phone { width: 27%; left: 36.5%; top: 25%; }
  .card-img .bk-hero.-rbart.-still img.rb-logo { width: 19%; left: 40.5%; top: 8%; }
}

/* Rebound, round 7. Case study header on a phone: a taller frame, the logo
   on top like the carousel card, and a bigger phone that starts just above
   the bell, so the streak flame and the whole program list show. */
@media (max-width: 620px) {
  .bk-hero.-rbart.-page { aspect-ratio: 4 / 5; }
  .bk-hero.-rbart.-page img.rb-phone { width: 62%; left: 19%; top: 15%; border-radius: 7.5% / 3.8%; }
  .bk-hero.-rbart.-page img.rb-logo { width: 40%; left: 30%; right: auto; top: 5%; bottom: auto; }
  .bk-hero.-rbart.-page .rb-bloom { width: 125%; left: 50%; top: 56%; }
}
/* The carousel phone sits a little higher and smaller, so Begin next session stays in frame. */
.deck-card .screen .bk-hero.-rbart img.rb-phone { width: 55%; left: 22.5%; top: 15%; }
@media (min-width: 621px) { .deck-card .screen .bk-hero.-rbart img.rb-phone { width: 52%; left: 24%; top: 17%; } }
/* Light-screen recordings (NOCD, forms) get a light frame instead of green. */
.cs-vid.-phone.-light { background: #fff; }
/* Carousel card on a phone: the screen ends just under Begin next session,
   and sits lower, with more room under the logo. */
@media (max-width: 620px) {
  .deck-card .screen .bk-hero.-rbart picture img.rb-phone { width: 58%; left: 21%; top: 19%; border-radius: 7.5% / 4.8%; }
}
@media (max-width: 620px) {
  .deck-card .screen .bk-hero.-rbart picture img.rb-phone { width: 49%; left: 25.5%; top: 19%; }
}

/* The smallest detail: a wall of short loops. Each tile is a soft panel with
   the recording centered and one line under it on the decision it holds. */
.dw-wall { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 18px; margin-bottom: 64px; }
.dw-tile { margin: 0; grid-column: span 2; display: flex; flex-direction: column; }
.dw-tile.-wide { grid-column: span 3; }
.dw-media { flex: 1; display: flex; align-items: center; justify-content: center; padding: 22px;
  border-radius: var(--radius); background: var(--surface-2, color-mix(in srgb, var(--text) 5%, var(--bg))); }
.dw-media video { display: block; width: 100%; height: auto; border-radius: 10px; box-shadow: 0 0 0 1px var(--border); }
.dw-tile.-phone .dw-media video { width: 78%; border-radius: 7% / 3.25%; }
.dw-tile figcaption { margin-top: 10px; font-size: 14px; line-height: 1.45; color: var(--text-dim); }
.dw-from { display: block; font-weight: 600; color: var(--text); margin-bottom: 2px; }
@media (max-width: 620px) {
  .dw-wall { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
  .dw-tile, .dw-tile.-wide { grid-column: span 2; }
  .dw-tile.-phone { grid-column: span 1; }
  .dw-tile.-phone:last-child { grid-column: span 2; }
  .dw-tile.-phone:last-child .dw-media video { width: 50%; }
  .dw-media { padding: 14px; }
}
/* Carousel card on a phone: the screen runs off the bottom of the frame,
   cut just under Begin next session, so it still reads as a phone. */
@media (max-width: 620px) {
  .deck-card .screen .bk-hero.-rbart picture img.rb-phone { width: 60%; left: 20%; top: auto; bottom: -1px;
    border-radius: 7.5% 7.5% 0 0 / 4.8% 4.8% 0 0; }
}
.dw-wall { align-items: start; }
.dw-media { flex: none; aspect-ratio: 4 / 3; }
.dw-tile.-phone .dw-media { aspect-ratio: 3 / 5; }
.dw-media video { max-height: 100%; width: auto; max-width: 100%; }
.dw-tile.-phone .dw-media video { width: auto; height: 100%; max-width: 100%; }
@media (max-width: 620px) {
  .dw-tile.-phone:last-child .dw-media { aspect-ratio: 4 / 3; }
  .dw-tile.-phone:last-child .dw-media video { width: auto; height: 100%; }
  .deck-card .screen .bk-hero.-rbart picture img.rb-phone { width: 52%; left: 24%; }
}
@media (max-width: 620px) {
  .dw-tile.-phone:last-child .dw-media { aspect-ratio: auto; }
  .dw-tile.-phone:last-child .dw-media video { width: 46%; height: auto; }
}

/* Smallest detail, round 2: a mural. Wider than the text column, tiles in
   columns of different heights, the loops doing the talking and one short
   line under each. */
.dw-wall { display: block; columns: 3 240px; column-gap: 16px;
  width: min(1080px, calc(100vw - 32px)); margin-left: calc(50% - min(540px, 50vw - 16px)); margin-bottom: 64px; }
.dw-tile, .dw-tile.-wide, .dw-tile.-phone { break-inside: avoid; margin: 0 0 18px; display: block; }
.dw-media, .dw-tile.-phone .dw-media { aspect-ratio: auto; padding: 0; overflow: hidden; border-radius: 18px; display: block; }
.dw-media video, .dw-tile.-phone .dw-media video { display: block; width: 100%; height: auto; max-width: none; max-height: none; border-radius: 0; box-shadow: none; }
.dw-tile.-phone .dw-media { padding: 22px 22px 0; background: var(--surface-2, color-mix(in srgb, var(--text) 6%, var(--bg))); }
.dw-tile.-phone .dw-media video { border-radius: 8.5% 8.5% 0 0 / 4% 4% 0 0; }
.dw-tile.-wide .dw-media { padding: 22px; background: var(--surface-2, color-mix(in srgb, var(--text) 6%, var(--bg))); }
.dw-tile.-wide .dw-media video { border-radius: 10px; }
.dw-tile.-square .dw-media { box-shadow: 0 0 0 1px var(--border); }
.dw-tile figcaption { margin-top: 8px; font-size: 13px; line-height: 1.45; color: var(--text-dim); }
.dw-tile figcaption b { color: var(--text); font-weight: 600; }
@media (max-width: 620px) {
  .dw-wall { columns: 2 140px; column-gap: 12px; }
  .dw-tile, .dw-tile.-wide, .dw-tile.-phone { margin-bottom: 14px; }
  .dw-tile.-phone .dw-media { padding: 12px 12px 0; }
  .dw-tile.-wide .dw-media { padding: 12px; }
  .dw-tile.-phone:last-child .dw-media video { width: 100%; }
}
/* The mural is laid out by script into columns of near-equal height
   (tallest tiles first, each to the shortest column). Without script it
   falls back to CSS columns above. */
.dw-wall.-js { columns: auto; display: flex; gap: 16px; align-items: flex-start; }
.dw-wall.-js .dw-col { flex: 1 1 0; min-width: 0; }
@media (max-width: 620px) { .dw-wall.-js { gap: 12px; } }

/* Smallest detail, no panels: each loop stands on its own. Phone screens are
   clipped to a device-like corner (sized in container width units, so the
   corner stays round at any size) which also hides the simulator's frame. */
.dw-media, .dw-tile.-phone .dw-media, .dw-tile.-wide .dw-media { padding: 0; background: none; border-radius: 0; overflow: visible; box-shadow: none; container-type: inline-size; }
.dw-media video, .dw-tile.-phone .dw-media video, .dw-tile.-wide .dw-media video { width: 100%; height: auto; border-radius: 14px; box-shadow: 0 0 0 1px var(--border); }
.dw-tile.-phone .dw-media video { border-radius: 11% / 5%; border-radius: 11cqw; }
.dw-tile.-square .dw-media { box-shadow: none; }
.dw-tile.-square .dw-media video { border-radius: 18px; }
.dw-tile, .dw-tile.-wide, .dw-tile.-phone { margin-bottom: 16px; }
@media (max-width: 620px) {
  .dw-tile, .dw-tile.-wide, .dw-tile.-phone { margin-bottom: 12px; }
  .dw-tile.-phone .dw-media { padding: 0; }
  .dw-tile.-wide .dw-media { padding: 0; }
  .dw-media video, .dw-tile.-wide .dw-media video, .dw-tile.-square .dw-media video { border-radius: 10px; }
}
/* Smallest detail, round 3: the wall keeps the same content width as every
   other page, and each loop gets one short line (two at most on a phone). */
.dw-wall, .dw-wall.-js { width: auto; margin-left: 0; }
.dw-tile.-phone .dw-media video { border-radius: 12% / 5.5%; border-radius: 12.5cqw; }
.dw-tile figcaption { margin-top: 8px; font-size: 13px; line-height: 1.4; color: var(--text-dim); }

/* Rebound, round 9. The phone always runs off the bottom of its frame and is
   cut just under Begin next session, so the tab bar never shows. Wider
   screens get the version with both checked steps. */
@media (min-width: 621px) {
  .deck-card .screen .bk-hero.-rbart picture img.rb-phone { width: 51%; left: 24.5%; top: auto; bottom: -1px;
    border-radius: 7.5% 7.5% 0 0 / 4.3% 4.3% 0 0; }
  .bk-hero.-rbart.-page picture img.rb-phone { width: 24%; left: 38%; top: auto; bottom: -1px;
    border-radius: 7.5% 7.5% 0 0 / 4.3% 4.3% 0 0; }
}
@media (max-width: 620px) {
  .bk-hero.-rbart.-page picture img.rb-phone { width: 62%; left: 19%; top: auto; bottom: -1px;
    border-radius: 7.5% 7.5% 0 0 / 4.6% 4.6% 0 0; }
}
/* Rebound on a phone: screens sit closer to the text that introduces them,
   and their labels sit right under them. */
@media (max-width: 620px) {
  .cs-section:has(+ .cs-fig.-rbduo) { margin-bottom: 22px; }
  .cs-fig.-rbduo { margin-bottom: 40px; }
  .cs-fig.-rbduo .cs-duo-label { margin-top: 6px; }
}
/* Rebound, round 10: the phone images now continue in green below Begin next
   session, so the floating phone runs past the bottom edge again and never
   shows its end or the tab bar. */
@media (min-width: 621px) {
  .deck-card .screen .bk-hero.-rbart picture img.rb-phone { width: 51%; left: 24.5%; top: 19%; bottom: auto;
    border-radius: 7.5% / 3.6%; }
  .bk-hero.-rbart.-page picture img.rb-phone { width: 24%; left: 38%; top: 24%; bottom: auto;
    border-radius: 7.5% / 3.6%; }
}
@media (max-width: 620px) {
  .bk-hero.-rbart.-page picture img.rb-phone { width: 62%; left: 19%; top: 15%; bottom: auto;
    border-radius: 7.5% / 3.6%; }
}
/* Smallest detail, round 4: signals for what is playing. A small play mark
   on resting tiles (tap to play), a thin progress line under the playing one. */
.dw-media { position: relative; }
.dw-play { position: absolute; right: 10px; bottom: 10px; width: 30px; height: 30px; border-radius: 50%;
  background: rgba(18, 18, 28, 0.5); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
  display: grid; place-items: center; pointer-events: none; transition: opacity 0.3s ease; }
.dw-play::before { content: ""; margin-left: 2px; border-left: 9px solid #fff; border-top: 6px solid transparent; border-bottom: 6px solid transparent; }
.dw-tile.-phone .dw-play { right: 7cqw; bottom: 5cqw; }
.dw-tile.-on .dw-play { opacity: 0; }
.dw-tile { cursor: pointer; }
.dw-bar { display: block; height: 2px; margin-top: 8px; border-radius: 2px; overflow: hidden;
  background: color-mix(in srgb, var(--text) 12%, transparent); }
.dw-bar i { display: block; height: 100%; width: 0; background: var(--text); opacity: 0.75; }
.dw-tile .dw-bar + figcaption { margin-top: 6px; }
.dw-tile.-on figcaption { color: var(--text); }
@media (max-width: 620px) { .dw-play { width: 26px; height: 26px; right: 8px; bottom: 8px; } .dw-play::before { border-left-width: 8px; border-top-width: 5px; border-bottom-width: 5px; } }

/* Smallest detail, round 5: a strict grid. Every phone screen is the same
   height, and two mosaic tiles stacked on top of each other make exactly one
   phone height, captions included. Desktop: phone | two mosaics | phone, two
   rows. Phone: two phones side by side, then two mosaics side by side. */
.dw-wall { display: grid; columns: auto; grid-template-columns: repeat(3, minmax(0, 1fr));
  grid-auto-rows: 1fr; gap: 16px; width: auto; margin: 0 0 64px; }
.dw-tile, .dw-tile.-phone, .dw-tile.-wide, .dw-tile.-mosaic { margin: 0; display: flex; flex-direction: column; min-height: 0; break-inside: auto; }
.dw-media, .dw-tile.-phone .dw-media, .dw-tile.-mosaic .dw-media { position: relative; padding: 0; background: none; box-shadow: none;
  overflow: hidden; container-type: inline-size; aspect-ratio: auto; }
.dw-tile.-phone .dw-media { aspect-ratio: 540 / 1160; border-radius: 12.5cqw; }
.dw-tile.-mosaic .dw-media { flex: 1 1 0; min-height: 0; border-radius: 16px; }
.dw-media video, .dw-tile.-phone .dw-media video, .dw-tile.-mosaic .dw-media video {
  display: block; width: 100%; height: 100%; max-width: none; max-height: none; object-fit: cover; border-radius: 0; box-shadow: none; }
.dw-media::before { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 0 0 1px var(--border); pointer-events: none; z-index: 2; }
.dw-tile figcaption, .dw-tile .dw-bar + figcaption { flex: none; margin-top: 8px; font-size: 13px; line-height: 18px; height: 36px; overflow: hidden; color: var(--text-dim); }
.dw-tile.-welcome   { grid-column: 1; grid-row: 1 / span 2; }
.dw-tile.-theme     { grid-column: 2; grid-row: 1; }
.dw-tile.-insurance { grid-column: 2; grid-row: 2; }
.dw-tile.-lock      { grid-column: 3; grid-row: 1 / span 2; }
.dw-tile.-avatar    { grid-column: 1; grid-row: 3 / span 2; }
.dw-tile.-reactions { grid-column: 2; grid-row: 3; }
.dw-tile.-tracker   { grid-column: 2; grid-row: 4; }
.dw-tile.-credits   { grid-column: 3; grid-row: 3 / span 2; }
@media (max-width: 620px) {
  .dw-wall { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: auto; gap: 12px; }
  .dw-tile.-mosaic .dw-media { flex: none; aspect-ratio: 1; }
  .dw-tile.-welcome   { grid-column: 1; grid-row: 1; }
  .dw-tile.-lock      { grid-column: 2; grid-row: 1; }
  .dw-tile.-theme     { grid-column: 1; grid-row: 2; }
  .dw-tile.-insurance { grid-column: 2; grid-row: 2; }
  .dw-tile.-avatar    { grid-column: 1; grid-row: 3; }
  .dw-tile.-credits   { grid-column: 2; grid-row: 3; }
  .dw-tile.-reactions { grid-column: 1; grid-row: 4; }
  .dw-tile.-tracker   { grid-column: 2; grid-row: 4; }
  .dw-tile.-mosaic .dw-media { border-radius: 12px; }
}
/* Resting tiles sit under a soft veil in the page color, with a play mark
   in the middle: tap to view. The playing tile is clear. */
.dw-play, .dw-bar { display: none !important; }
.dw-veil { position: absolute; inset: 0; z-index: 1; display: grid; place-items: center; pointer-events: none;
  background: color-mix(in srgb, var(--bg) 42%, transparent); transition: opacity 0.35s ease; }
.dw-veil::before { content: ""; width: 44px; height: 44px; border-radius: 50%;
  background: color-mix(in srgb, var(--bg) 78%, transparent); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
.dw-veil::after { content: ""; position: absolute; margin-left: 3px; border-left: 12px solid var(--text); border-top: 8px solid transparent; border-bottom: 8px solid transparent; opacity: 0.8; }
.dw-tile.-on .dw-veil { opacity: 0; }
.dw-tile.-on figcaption { color: var(--text); }
.dw-media { cursor: pointer; touch-action: pan-y; -webkit-user-select: none; user-select: none; }
.dw-media.-scrub { cursor: ew-resize; }
/* While dragging to scrub, a thin line shows where you are in the loop. */
.dw-scrub { position: absolute; left: 12%; right: 12%; bottom: 14px; height: 3px; border-radius: 3px; z-index: 3; opacity: 0;
  background: color-mix(in srgb, var(--bg) 70%, transparent); transition: opacity 0.2s ease; pointer-events: none; }
.dw-scrub i { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--text); }
.dw-media.-scrub .dw-scrub { opacity: 1; }
.dw-wall { align-items: stretch; }
.dw-tile.-phone .dw-media { border-radius: 12.5% / 5.82%; }
/* Clear the earlier last-child rules from the first version of the wall,
   which were stretching the last phone tile across two columns on phones. */
@media (max-width: 620px) {
  .dw-wall .dw-tile.-phone:last-child { grid-column: 2; grid-row: 3; }
  .dw-wall .dw-tile.-phone:last-child .dw-media { aspect-ratio: 540 / 1160; }
  .dw-wall .dw-tile.-phone:last-child .dw-media video { width: 100%; height: 100%; }
}

/* Videos that autoplay: the poster sits behind the video, and the video
   fades in only once its first frame is on screen, so starting a clip never
   flashes to black. (Set up from each page's playback script.) */
.-smooth { background-size: cover; background-position: center; background-repeat: no-repeat; }
.-smooth > video { opacity: 0; transition: opacity 0.25s ease; }
.-smooth > video.-shown { opacity: 1; }
.cs-vid.-smooth, .cs-vid.-phone.-smooth, .dw-media.-smooth { background-size: cover; background-position: center; background-repeat: no-repeat; }
.dw-wall .dw-tile .dw-media.-smooth { background-size: cover; background-position: center; background-repeat: no-repeat; }
