:root{
    --paper:   #f4ecd9;
    --paper-2: #ede1c4;
    --paper-3: #e4d5b2;
    --walnut:  #5b3a24;
    --walnut-2:#7a5230;
    /* Rules, dotted borders and the scrollbar thumb only — NOT text. On paper
       it measures 3.4:1, and 3.1:1 on paper-2, which is under AA for body copy;
       every one of the eighteen places that used it for type now uses
       --walnut-2 (5.8:1 / 5.3:1). Borders are held to 3:1, so they can stay. */
    --walnut-3:#9a7a52;
    --ink:     #241a0f;
    --red:     #a4211b;
    --serif: "Playfair Display", Georgia, serif;
    --mono:  "IBM Plex Mono", "Courier New", monospace;
    /* "Apple Symbols" is here for iOS and macOS, and it is load-bearing: every
       other family in this list is Windows- or Linux-only, so an iPhone fell
       through to system fallback and resolved the pawn (U+265F, the one piece
       with an emoji presentation) to Apple Color Emoji. That is a colour bitmap
       font — `color` and -webkit-text-stroke do nothing to it — so White's pawns
       came out identical to Black's. Keep a text font that has these glyphs
       ahead of any emoji fallback. */
    --pieces: "Segoe UI Symbol", "Apple Symbols", "Noto Sans Symbols 2", "DejaVu Sans", "Arial Unicode MS", sans-serif;
    /* form controls / UA scrollbars follow the theme; the dark block flips this */
    color-scheme: light;
    /* rgb TRIPLETS, for the translucent families that need a different base per
       theme but keep their alphas: the linen card fills, the paper halo rings,
       and the brown hatch/vignette dust. Used as rgba(var(--linen), .55) etc. */
    --linen: 255,251,240;
    --halo:  244,236,217;
    --hatch: 91,58,36;
    /* the hard-offset "stacked paper" shadows — the 3D print idiom. Ink on
       paper; on the dark ground an ink shadow is invisible, so dark mode flips
       this to a pale edge and the stack reads as catching light instead. */
    --shade: 36,26,15;
  }
  *{ margin:0; padding:0; box-sizing:border-box; }
  /* overflow-x guard belongs on the root, not on body: on body it computes to
     `overflow: hidden auto`, which makes body its own nested scroll container
     instead of letting the viewport scroll. */
  html{
    scroll-behavior:smooth; overflow-x:hidden;
    /* reserve the scrollbar's width always, so locking scroll below cannot
       shift the layout sideways when the bar disappears */
    scrollbar-gutter: stable;
  }
  /* set while any dialog is open: the page behind must not scroll */
  html.dialog-open{ overflow-y: hidden; }
  @media (prefers-reduced-motion: reduce){ html{ scroll-behavior:auto; } }
  body{
    background:
      radial-gradient(1100px 500px at 50% -8%, rgba(var(--hatch),.07), transparent 65%),
      var(--paper);
    color: var(--ink);
    font-family: var(--serif);
    font-size: 17px;
    line-height: 1.55;
  }
  ::selection{ background: var(--red); color: var(--paper); }
  a{ color: inherit; }
  button{ font: inherit; cursor: pointer; }

  /* ---------- scrollbars ----------
     Squared off and drawn in ink on paper, like everything else here — no
     rounded corners, no grey. Declared twice on purpose: `scrollbar-color`
     is the standard property (Firefox, Chrome 121+) and the -webkit-
     pseudo-elements cover older Chromium and Safari. Elements that hide
     their bars entirely (.tray, .nav-links) override this further down. */
  :root{
    scrollbar-color: var(--walnut-3) var(--paper-2);
    scrollbar-width: thin;
  }
  ::-webkit-scrollbar{ width: 11px; height: 11px; }
  ::-webkit-scrollbar-track{
    background: var(--paper-2);
    box-shadow: inset 1px 0 0 var(--paper-3), inset -1px 0 0 var(--paper-3);
  }
  ::-webkit-scrollbar-thumb{
    background: var(--walnut-3);
    /* a paper margin around the bar so it reads as ruled, not as a slab */
    border: 2px solid var(--paper-2);
  }
  ::-webkit-scrollbar-thumb:hover{ background: var(--walnut-2); }
  ::-webkit-scrollbar-thumb:active{ background: var(--walnut); }
  ::-webkit-scrollbar-corner{ background: var(--paper-2); }

  .skip{
    position: absolute; left: -9999px; top: 0; z-index: 200;
    background: var(--ink); color: var(--paper);
    font-family: var(--mono); font-size: 11px; padding: 8px 14px;
  }
  .skip:focus{ left: 10px; top: 10px; }

  /* ---------- focus ----------
     There was no focus indicator anywhere on this page. :focus-visible keeps it
     off mouse clicks, so it costs the design nothing and makes the whole thing
     keyboard-operable. The board's ring is drawn INSIDE the square: squares butt
     up against each other, so an outward ring would be clipped by its
     neighbours on every side. */
  :focus-visible{
    outline: 3px solid var(--ink);
    outline-offset: 2px;
  }
  .sq:focus-visible{
    outline: 3px solid var(--red);
    outline-offset: -3px;
    z-index: 3;
  }
  /* One ink ring everywhere else. outline-offset draws it OUTSIDE the element,
     so what it has to contrast against is the surface behind the control, not
     the control — and every focusable thing here sits on paper, including the
     ink-on-paper skip link and the buttons inside the dialogs' paper cards. */

  /* ---------- symbols that are type, not pictures ----------
     Everything listed here is a glyph meant to be set in ink and tinted by CSS.
     Asking for the text presentation keeps it off the colour-emoji font, which
     is a bitmap and ignores `color` — that is what made White's pawns identical
     to Black's on iOS. The U+FE0E selectors in the markup are the mechanism that
     works today; this is the CSS equivalent, and it inherits, so it also covers
     glyphs injected by app.js.
     Deliberately NOT listed: .lock and the win-screen handshake. Those two are
     pictures and should stay in colour. */
  .sq .pc, .t-pc, .bg, .bgx, .cover-bounty .bg, .nub-key,
  .rc-doc, .rc-go, .fm-ico, .gs-half, .gs-piece{
    font-variant-emoji: text;
  }

  /* Visually hidden but still read out and still indexed. Clip-rect rather than
     display:none or visibility:hidden, both of which remove it from the
     accessibility tree — which would defeat the whole point. */
  .vh{
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%);
    white-space: nowrap; border: 0;
  }

  /* ---------- no-JS fallback ----------
     Sits above everything when scripting is off, since every section body is
     built by app.js and would otherwise be an empty shell. */
  .nojs{
    max-width: 660px; margin: 0 auto; padding: 64px 22px 40px;
    border-bottom: 2px solid var(--walnut);
  }
  .nojs h2{
    font-family: var(--serif); font-weight: 900; font-size: clamp(2rem, 7vw, 3rem);
    letter-spacing: -.01em;
  }
  .nojs-lede{
    margin-top: 14px; font-size: 16.5px; line-height: 1.6; color: var(--walnut);
    max-width: 60ch;
  }
  .nojs-links{
    list-style: none; margin: 22px 0 0;
    display: flex; flex-wrap: wrap; gap: 8px;
  }
  .nojs-links a{
    display: inline-block;
    font-family: var(--mono); font-size: 11px; font-weight: 600;
    letter-spacing: .12em; text-transform: uppercase;
    color: var(--ink); text-decoration: none;
    border: 1px solid var(--walnut);
    padding: 9px 13px 8px;
  }
  .nojs-links a:hover{ color: var(--paper); background: var(--walnut); }

  /* ---------- index bar ----------
     Every link here unseals the whole file: the deliberate shortcut for
     anyone who would rather read than play. */
  .topnav{
    position: fixed; top: 0; left: 0; right: 0; z-index: 80;
    display: flex; align-items: center; gap: 18px;
    padding: 9px 18px;
    background: rgba(var(--halo),.95);
    backdrop-filter: blur(6px);
    border-bottom: 1px solid var(--walnut);
    box-shadow: 0 6px 18px -12px rgba(36,26,15,.5);
  }
  .topnav .brand{
    font-family: var(--serif); font-weight: 900; font-size: 19px;
    letter-spacing: .02em; text-decoration: none; color: var(--ink);
    flex: none;
  }
  .topnav .brand .dot{ color: var(--red); }
  /* One typeface for the whole lockup — mixing mono into a 19px nav item reads
     busy at this size, so the shape of it comes from weight, colour and tracking.
     THE NAME NO LONGER STEPS BACK. It sat at walnut/700 against the wordmark's
     ink/900, which made GAMBIT the loud half of a lockup whose entire job is to say
     whose site this is. ALEX now matches it on both counts and takes a little extra
     tracking on top, so it carries at least as much weight as the wordmark. The colon
     stays walnut-3, which is what keeps the two halves legible as separate words now
     that they share a colour, and the red stays on the full stop so there is still
     only one accent in the bar.
     Moving to ink also fixes the contrast: walnut measures 5.9:1 on paper, ink 13:1. */
  .topnav .brand .bn{ font-weight: 900; color: var(--ink); letter-spacing: .05em; }
  .topnav .brand .bx{ font-weight: 700; color: var(--walnut-3); margin: 0 .06em; }
  .nav-links{ display: flex; gap: 6px; flex-wrap: wrap; min-width: 0; }
  /* These used to snap a 1px red rectangle around the label on hover, with no
     transition declared at all — an outlined box appearing instantly, which read
     as a glitch rather than a state. Now the label darkens and a red rule wipes
     in underneath it, which is the same gesture as ruling a line on a scoresheet.
     The rule is inset to the text by --pad-x so it underlines the word rather
     than the whole padding box; the narrow breakpoint overrides that one value
     and the inset follows it. */
  .nav-links a{
    --pad-x: 9px;
    position: relative;
    font-family: var(--mono); font-size: 10.5px; font-weight: 500;
    letter-spacing: .13em; text-transform: uppercase;
    text-decoration: none; color: var(--walnut);
    padding: 6px var(--pad-x) 6px;
    white-space: nowrap;
    transition: color .2s ease;
  }
  .nav-links a::after{
    content: "";
    position: absolute; left: var(--pad-x); right: var(--pad-x); bottom: 3px;
    height: 2px; background: var(--red);
    transform: scaleX(0); transform-origin: left center;
    transition: transform .26s cubic-bezier(.2,.7,.2,1);
  }
  .nav-links a:hover{ color: var(--ink); }
  .nav-links a:hover::after{ transform: scaleX(1); }
  /* keyboard users get the same affordance, not just the focus ring */
  .nav-links a:focus-visible::after{ transform: scaleX(1); }
  .nav-links a:active{ color: var(--red); }
  .nav-right{
    margin-left: auto; flex: none;
    display: flex; align-items: center; gap: 8px;
  }
  /* The two right-hand buttons share one physical idiom: they sit on a hard
     offset shadow, lift toward the cursor on hover, and press flat into the page
     when clicked. The shadow is ink at low alpha rather than the translucent red
     it used to be — red-on-red read as a muddy double edge instead of a shadow.
     No blur anywhere: this whole design is print, and print does not glow. */
  .nav-unlock-btn,
  .nav-btn{
    --shadow: 2px 2px 0 rgba(var(--shade),.20);
    flex: none;
    font-family: var(--mono); font-size: 10px; font-weight: 600;
    text-transform: uppercase;
    padding: 7px 13px 6px;
    box-shadow: var(--shadow);
    transition: background .18s ease, color .18s ease, border-color .18s ease,
                transform .14s cubic-bezier(.2,.7,.2,1), box-shadow .18s ease;
  }
  /* No transform in the rest state on purpose. A permanent translate(0,0) puts
     the element on its own raster layer, which softens 10px mono letterforms —
     visible on a page this typographic. The transition still animates from
     nothing, because an absent transform interpolates as translate(0,0). */
  .nav-unlock-btn:hover,
  .nav-btn:hover{
    transform: translate(-1px, -1px);
    --shadow: 3px 3px 0 rgba(var(--shade),.26);
  }
  .nav-unlock-btn:active,
  .nav-btn:active{
    transform: translate(2px, 2px);
    --shadow: 0 0 0 rgba(36,26,15,0);
  }

  /* The shortcut, deliberately styled as an action rather than an index entry:
     filled in the checker's red so it reads as "skip the game", not "a link". */
  .nav-unlock-btn{
    display: inline-flex; align-items: center; gap: 7px;
    letter-spacing: .14em;
    color: var(--paper); background: var(--red);
    border: 1px solid var(--red);
  }
  /* The key turns — the one literal gesture in the bar. It is a flex item, so it
     is already blockified and transformable; the display is stated anyway so
     this keeps working if the button ever stops being a flex container. */
  .nav-unlock-btn .nub-key{
    font-family: var(--pieces); font-size: 13px; line-height: 1;
    display: inline-block;
    transition: transform .3s cubic-bezier(.2,.7,.2,1);
  }
  .nav-unlock-btn:hover .nub-key{ transform: rotate(-28deg); }
  .nav-unlock-btn:hover{ background: #8d1c17; border-color: #8d1c17; }
  /* Once everything is open this button becomes the way back, so it drops out of
     the red to a quiet outline — a secondary action, not the headline one. It
     KEEPS the shadow and the press, because it is still a live control; taking
     those away made it read as a disabled label. */
  .nav-unlock-btn.is-done{
    background: transparent; color: var(--walnut-2);
    border-color: var(--walnut-3);
  }
  .nav-unlock-btn.is-done:hover{
    background: rgba(36,26,15,.04);
    border-color: var(--walnut-2); color: var(--ink);
  }
  /* the key turns; the reset arrow spins the other way, which is its own idea */
  .nav-unlock-btn.is-done:hover .nub-key{ transform: rotate(-150deg); }

  .nav-btn{
    letter-spacing: .16em;
    color: var(--paper); background: var(--walnut);
    border: 1px solid var(--walnut);
  }
  .nav-btn:hover{ background: var(--ink); border-color: var(--ink); }
  /* Narrow screens get a deliberate two-row bar — brand + actions on top, the
     section index scrolling underneath. Left to wrap on its own it grew to
     three ragged rows and covered the top of the board. */
  @media (max-width: 720px){
    .topnav{ flex-wrap: wrap; gap: 6px 10px; padding: 6px 12px 5px; }
    .topnav .brand{ font-size: 15px; }
    /* Below ~360px the name no longer fits beside the two action buttons: the
       brand went 68px -> 114px when ALEX: was added, which pushed the bar to the
       three ragged rows the comment above says to avoid. Drop back to the bare
       wordmark there — the document title still carries the name. */
    @media (max-width: 360px){
      .topnav .brand .bn, .topnav .brand .bx{ display: none; }
    }
    .nav-right{ margin-left: auto; gap: 6px; }
    .nav-links{
      order: 3; flex: 1 0 100%;
      overflow-x: auto; gap: 2px;
      padding-bottom: 1px;
      scrollbar-width: none;
    }
    .nav-links::-webkit-scrollbar{ display: none; }
    /* --pad-x carries the underline's inset with it, so it keeps hugging the
       word instead of overhanging into the tighter padding */
    .nav-links a{ --pad-x: 7px; font-size: 9.5px; padding: 4px var(--pad-x) 4px; letter-spacing: .08em; }
    .nav-links a::after{ bottom: 1px; }
    .nav-btn, .nav-unlock-btn{ font-size: 9px; padding: 6px 8px 5px; letter-spacing: .1em; }
    /* The bar is two crowded rows here; the offset shadows only add noise. The
       :hover state has to be named too — it sets --shadow at (0,2,0), which
       outranks a bare class selector no matter that this block comes later. */
    .nav-unlock-btn, .nav-btn,
    .nav-unlock-btn:hover, .nav-btn:hover{ --shadow: 0 0 0 rgba(36,26,15,0); }
  }

  /* ---------- cover / rules dialog ---------- */
  .cover{
    position: fixed; inset: 0; z-index: 140;
    display: flex; align-items: center; justify-content: center;
    padding: 20px 16px;
    background: rgba(36,26,15,.55);
    /* the card owns its overflow, so this layer never scrolls */
    overflow: hidden;
  }
  .cover[hidden]{ display: none; }
  .cover-card{
    position: relative;
    width: min(660px, 100%);
    max-height: calc(100svh - 40px);
    overflow-y: auto;
    scrollbar-width: thin;
    margin: auto;
    background: rgba(var(--halo),.99);
    border: 1px solid var(--walnut);
    outline: 3px double var(--walnut);
    outline-offset: 4px;
    box-shadow: 0 24px 60px -18px rgba(36,26,15,.6);
    padding: clamp(20px, 3.4vw, 32px) clamp(18px, 4vw, 36px) clamp(20px, 3vw, 28px);
    animation: coverIn .34s cubic-bezier(.2,.86,.3,1) both;
  }
  @keyframes coverIn{
    from{ opacity: 0; transform: translateY(16px) scale(.975); }
    to{ opacity: 1; transform: none; }
  }
  /* closing: the sheet is lifted away and the scrim fades with it. The class is
     applied first, then the element is hidden on animationend (see app.js). */
  /* The scrim is animated, not transitioned: this element goes from hidden to
     shown, and a transition cannot run from display:none — so on opening the
     scrim used to snap to full strength while the card animated in. */
  .cover.closing .cover-card{
    animation: coverOut .28s cubic-bezier(.4,.03,.7,.2) both;
  }
  @keyframes coverOut{
    from{ opacity: 1; transform: none; }
    to{ opacity: 0; transform: translateY(-14px) scale(.97); }
  }
  .cover-x{
    position: absolute; top: 5px; right: 6px;
    font-family: var(--mono); font-size: 22px; line-height: 1;
    color: var(--walnut-2); background: none; border: 1px solid transparent;
    width: 32px; height: 32px;
  }
  /* transparent border in the rest state is deliberate — it is what stops the
     hover reflowing. Only the transition was missing, so it snapped. */
  .cover-x{ transition: color .18s ease, border-color .18s ease, background .18s ease; }
  .cover-x:hover{ color: var(--red); border-color: var(--red); }
  /* a ghost control has no shadow to press into, so the press is a fill */
  .cover-x:active{ color: var(--paper); background: var(--red); border-color: var(--red); }
  .cover-x:focus-visible{ outline-offset: -3px; }   /* clip edge is 1px away */
  .cover-bounty{
    list-style: none; margin: 16px 0 0; padding: 0;
    border-top: 1px solid var(--walnut-3);
  }
  /* THE GLYPH COLUMN IS 52px, NOT 34px, AND IT IS SIZED OFF THE WIDEST ROW.
     The knight/bishop row carries TWO glyphs. Measured, the pair inks 45.6px against a
     single piece's 21.5px — so in a 34px track it overflowed by 12.4px and ran into its
     own label, ending up 2.6px PAST where the label starts while every other row had
     21.5px of daylight. That is the unevenness: one row crushed, four airy.
     52px fits the pair with slack for a different pieces font (a Mac resolves these
     from Apple Symbols, not Segoe UI Symbol, at a different width), and every label in
     the list still starts on the same x. */
  .cover-bounty li{
    display: grid; grid-template-columns: 52px 130px 1fr;
    gap: 10px; align-items: center;
    padding: 7px 2px;
    border-bottom: 1px dotted var(--walnut-3);
    font-family: var(--mono); font-size: 11px; color: var(--walnut);
  }
  .cover-bounty .bg{ font-family: var(--pieces); font-size: 22px; line-height: 1; color: var(--ink); }
  .cover-bounty .bg.r{ color: var(--red); }
  .cover-bounty b{ font-weight: 600; color: var(--ink); }
  .cover-bounty i{ font-style: normal; color: var(--walnut-2); }
  /* two glyphs on the knight/bishop row, so the cell must never wrap.
     CENTRED, which is what evens out the leftover space. Left-aligned in a track sized
     for the widest row, a single 21.5px glyph would leave 30px of slack all on one side
     and sit 40px from its label while the pair sat 16px away. Centring splits the slack,
     so the gap runs 13px on the pair and 25px on the singles instead of 16 and 40 — and
     it degrades gracefully if a platform's pieces font measures wider than this one. */
  .cover-bounty .bg{ white-space: nowrap; text-align: center; }
  @media (max-width: 520px){
    /* The glyph column keeps its full 52px here too. At 28px the knight/bishop row's
       PAIR of glyphs wrapped onto two lines inside the cell — 27x43 against 27x21 for
       every other row — which alone made that row 84px tall against 62px, and was the
       one thing breaking the alignment down the list. */
    .cover-bounty li{ grid-template-columns: 52px 1fr; }
    .cover-bounty i{ grid-column: 2; }
  }
  .cover-play{
    margin-top: 18px; width: 100%;
    font-family: var(--mono); font-size: 12px; font-weight: 600;
    letter-spacing: .2em; text-transform: uppercase;
    color: var(--paper); background: var(--ink);
    border: 1px solid var(--ink);
    padding: 13px 16px 11px;
  }
  .cover-play:hover{ background: var(--red); border-color: var(--red); }
  .sc-kicker{
    display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap;
    font-family: var(--mono); font-size: 10.5px; font-weight: 600;
    letter-spacing: .22em; text-transform: uppercase;
    color: var(--walnut-2);
    border-bottom: 1px solid var(--walnut-3);
    padding-bottom: 9px;
  }
  .sc-kicker .red{ color: var(--red); }
  /* Keep this line clear of the close button sitting over the corner. The rule
     underneath still runs the full width — only the text is inset.
     24px, down from 30, so the red half sits a touch further right. The close button is
     32px wide at right:6px, i.e. it occupies the first 38px in from the card's padding
     edge, and this text's right edge lands at (card padding-right + this). Card padding
     is clamp(18px, 4vw, 36px), so the clearance runs 22px at desktop widths down to 4px
     at about 395px — which is the narrowest width where the two halves still share a
     line at all; below that the kicker wraps and the button is not on that row. Do not
     go below 24px: at 20px the tightest case touches zero. */
  .cover-card .sc-kicker{ padding-right: 24px; }
  .sc-fields{
    display: grid; grid-template-columns: repeat(4, 1fr);
    border-bottom: 1px solid var(--walnut-3);
  }
  .sc-field{
    padding: 9px 12px 8px 0;
    border-right: 1px dotted var(--walnut-3);
    min-width: 0;
  }
  .sc-field + .sc-field{ padding-left: 12px; }
  .sc-field:last-child{ border-right: 0; }
  .sc-field .lbl{
    display:block; font-family: var(--mono); font-size: 9.5px;
    letter-spacing: .18em; text-transform: uppercase; color: var(--walnut-2);
  }
  .sc-field .val{
    font-family: var(--mono); font-size: 12.5px; font-weight: 500;
    color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    display: block;
  }
  .sc-name{
    font-family: var(--serif); font-weight: 900;
    font-size: clamp(2.4rem, 7.4vw, 4.4rem);
    line-height: 1.02; letter-spacing: .02em;
    color: var(--ink);
    margin-top: 16px;
  }
  .sc-name .dot{ color: var(--red); }
  .sc-role{
    margin-top: 10px;
    font-family: var(--mono); font-size: clamp(11.5px, 1.6vw, 13px);
    line-height: 1.7; color: var(--walnut);
    max-width: 72ch;
  }
  .sc-role b{ font-weight: 600; color: var(--ink); }
  .sc-role em{ font-style: normal; color: var(--red); font-weight: 600; }
  @media (max-width: 640px){
    .sc-fields{ grid-template-columns: 1fr 1fr; }
    .sc-field:nth-child(2){ border-right: 0; }
    .sc-field:nth-child(3){ border-top: 1px dotted var(--walnut-3); padding-left: 0; }
    .sc-field:nth-child(4){ border-top: 1px dotted var(--walnut-3); }
  }

  /* ---------- shared section chrome ---------- */
  main{ max-width: 1080px; margin: 0 auto; padding: 0 20px 40px; }
  section{ padding: 56px 0 8px; }
  main section{ scroll-margin-top: 62px; }   /* clear the fixed index bar */
  .sec-head{
    display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap;
    border-top: 3px double var(--walnut);
    padding-top: 14px;
    margin-bottom: 26px;
  }
  .sec-phase{
    font-family: var(--mono); font-size: 11px; font-weight: 600;
    letter-spacing: .2em; text-transform: uppercase; color: var(--red);
  }
  .sec-head h2{
    font-family: var(--serif); font-weight: 900; font-style: italic;
    font-size: clamp(1.9rem, 4.5vw, 2.7rem); line-height: 1.05;
    color: var(--ink);
  }
  .sec-sub{
    font-family: var(--mono); font-size: 11.5px; color: var(--walnut-2);
    margin-left: auto;
  }
  @media (max-width: 640px){ .sec-sub{ margin-left: 0; width: 100%; } }
  .mv-no{
    font-family: var(--mono); font-weight: 600; font-size: 12px;
    color: var(--walnut-2); letter-spacing: .06em;
  }

  /* ---------- bounty legend ---------- */
  /* ---------- the arena: board fills the screen ----------
     --board drives everything (square size, glyph size), so the board stays
     square and as large as the viewport allows. The value subtracts the
     chrome stacked above/below it (trays, status, buttons) and the side
     rails, then clamps so it never collapses on small screens. */
  .arena{
    /* 140px = the only chrome left in the board column (two 26px capture
       trays) plus this element's padding. The controls and status moved into
       the right rail so the board could take the space. Keep this in step with
       that column or the arena spills past the fold. */
    --board: clamp(260px, min(100svh - 140px, 100vw - 660px), 900px);
    --tray-h: 26px;
    min-height: 100svh;
    display: grid;
    grid-template-columns: minmax(0, 290px) var(--board) minmax(0, 300px);
    /* hug the board instead of flinging the rails at the screen edges */
    justify-content: center;
    align-content: center;
    align-items: start;
    gap: 12px clamp(14px, 1.6vw, 26px);
    padding: 58px 18px 22px;
  }
  .board-col{
    min-width: 0;
    grid-row: 1;
    display: flex; flex-direction: column;
  }
  .under{
    margin-top: 5px;
    display: flex; align-items: center; justify-content: space-between;
    gap: 14px; flex-wrap: wrap;
  }

  /* Side rails: bounty (left), scoresheet (right). Both are exactly as tall as
     the board and offset by the tray above it, so all three columns share a top
     AND a bottom edge — the whole arena reads as one ruled sheet. */
  .rail{
    min-width: 0;
    grid-row: 1;
    /* exactly the tray's height: the tray sits flush above the board, so this
       lands the rail's top edge on the board's top edge */
    margin-top: var(--tray-h);
    height: var(--board);
    border: 1px solid var(--walnut);
    background: rgba(var(--linen),.62);
    box-shadow: 3px 3px 0 rgba(var(--shade),.16);
    padding: 0 0 4px;
    display: flex; flex-direction: column;
    overflow: hidden;
  }
  .rail-l{ justify-self: end; width: 100%; }
  .rail-r{ justify-self: start; width: 100%; }
  .rail-title{
    font-family: var(--mono); font-size: 9.5px; font-weight: 600;
    letter-spacing: .24em; text-transform: uppercase;
    color: var(--paper); background: var(--walnut);
    padding: 7px 12px;
  }
  .rail-sub{
    font-family: var(--mono); font-size: 9px; letter-spacing: .1em;
    text-transform: uppercase; color: var(--walnut-2);
    padding: 7px 12px 5px;
    border-bottom: 1px solid var(--walnut-3);
  }
  /* The engine credit at the foot of the scoresheet. Block so it takes its own
     line under the button row, and a dotted rule above it so it reads as a
     footnote to the rail rather than a fourth control. */
  .rail-note{
    display: block;
    margin-top: 9px;
    padding: 7px 0 2px;
    border-top: 1px dotted var(--walnut-3);
    font-family: var(--mono); font-size: 8.5px; letter-spacing: .04em;
    color: var(--walnut-2); line-height: 1.4;
  }
  .bounty{
    list-style: none; margin: 0; padding: 0;
    overflow-y: auto;
  }
  .bounty li{
    display: flex; align-items: center; gap: 10px;
    padding: 9px 12px;
    border-bottom: 1px dotted var(--walnut-3);
    font-family: var(--mono); font-size: 10px; line-height: 1.4;
    color: var(--walnut);
  }
  .bounty .bg{
    font-family: var(--pieces); font-size: 22px; line-height: 1;
    color: var(--ink); flex: none;
  }
  .bounty .bg.r{ color: var(--red); }
  .bounty b{ font-weight: 600; color: var(--ink); display: block; }
  .bounty i{ font-style: normal; color: var(--walnut-2); }

  /* live progress readout under the bounty */
  .rail-prog{
    margin-top: auto;
    padding: 8px 12px 2px;
    border-top: 1px solid var(--walnut-3);
    font-family: var(--mono); font-size: 9.5px;
  }
  .rail-prog span{
    display: flex; justify-content: space-between; gap: 10px;
    padding: 3px 0;
  }
  .rail-prog i{
    font-style: normal; letter-spacing: .14em; text-transform: uppercase;
    color: var(--walnut-2);
  }
  .rail-prog b{ font-weight: 600; color: var(--ink); }

  /* ---------- the win modal's execution ----------
     The piece that actually delivered mate runs the black king through, big,
     inside the result card. Two copies of the king clipped along one diagonal
     are the halves. The attacker's glyph is set by app.js from the real mating
     move; everything else is fixed geometry, so it reads the same every time.
     REVERT: delete this block, #go-slice in index.html, and buildWinSlice() +
     its call in showGameOver(). */
  .gs{
    position: relative;
    height: 168px;
    margin: -4px -6px 10px;
    overflow: hidden;
  }
  .gs:empty{ display: none; }
  .gs > *{
    position: absolute; left: 50%; top: 50%;
    font-family: var(--pieces); line-height: 1;
    will-change: transform, opacity;
  }
  .gs-half{
    font-size: 118px; color: var(--ink);
    text-shadow: 0 3px 4px rgba(36,26,15,.3);
  }
  /* the cut: both halves share one diagonal edge */
  .gs-top{ clip-path: polygon(0 0, 100% 0, 100% 38%, 0 58%); }
  .gs-bot{ clip-path: polygon(0 58%, 100% 38%, 100% 100%, 0 100%); }
  .gs-piece{
    font-size: 92px;
    color: #fbf4e0;
    -webkit-text-stroke: 2px rgba(36,26,15,.95);
    filter: drop-shadow(0 4px 6px rgba(36,26,15,.4));
  }
  .gs-slash{
    width: 150%; height: 5px; border-radius: 3px;
    background: linear-gradient(90deg, transparent, var(--red) 34%, #fff 50%, var(--red) 66%, transparent);
    box-shadow: 0 0 22px rgba(164,33,27,.85);
    opacity: 0;
  }
  /* a flash of paper-white across the whole card at the moment of impact */
  .gs-burst{
    width: 200%; height: 200%;
    background: radial-gradient(circle at 50% 50%, rgba(255,255,255,.85), rgba(255,255,255,0) 62%);
    opacity: 0;
  }
  .modal.show.is-win .gs-piece{ animation: gsPiece 1.55s cubic-bezier(.32,.05,.2,1) .18s both; }
  .modal.show.is-win .gs-slash{ animation: gsSlash 1.55s ease-out .18s both; }
  .modal.show.is-win .gs-burst{ animation: gsBurst 1.55s ease-out .18s both; }
  .modal.show.is-win .gs-top{ animation: gsTop 1.55s cubic-bezier(.2,.65,.25,1) .18s both; }
  .modal.show.is-win .gs-bot{ animation: gsBot 1.55s cubic-bezier(.2,.65,.25,1) .18s both; }
  @keyframes gsPiece{
    0%{ opacity: 0; transform: translate(-320%, -50%) rotate(-18deg) scale(.8); }
    12%{ opacity: 1; }
    42%{ transform: translate(-50%, -50%) rotate(6deg) scale(1.16); }
    70%{ opacity: 1; }
    100%{ opacity: 0; transform: translate(220%, -50%) rotate(14deg) scale(.95); }
  }
  @keyframes gsSlash{
    0%, 34%{ opacity: 0; transform: translate(-50%, -50%) rotate(-13deg) scaleX(0); }
    44%{ opacity: 1; transform: translate(-50%, -50%) rotate(-13deg) scaleX(1); }
    70%{ opacity: 0; transform: translate(-50%, -50%) rotate(-13deg) scaleX(1); }
    100%{ opacity: 0; }
  }
  @keyframes gsBurst{
    0%, 36%{ opacity: 0; transform: translate(-50%, -50%) scale(.4); }
    44%{ opacity: 1; transform: translate(-50%, -50%) scale(1); }
    72%{ opacity: 0; transform: translate(-50%, -50%) scale(1.15); }
    100%{ opacity: 0; }
  }
  @keyframes gsTop{
    0%, 42%{ transform: translate(-50%, -50%) rotate(0deg); opacity: 1; }
    100%{ transform: translate(-96%, -104%) rotate(-24deg); opacity: 0; }
  }
  @keyframes gsBot{
    0%, 42%{ transform: translate(-50%, -50%) rotate(0deg); opacity: 1; }
    100%{ transform: translate(-6%, 6%) rotate(17deg); opacity: 0; }
  }
  /* ONE timeline for the card: it stamps down, settles, then takes the hit when
     the blade lands. Two separate animations both driving `transform` handed the
     property back and forth and the entrance stuttered — this is the same
     motion, expressed once. */
  /* 1.5s so the impact beat can be placed precisely: the slash peaks at 44% of
     its 1.55s run plus a 0.18s delay = 0.86s, which is 57% of this timeline.
     Get these out of step and the card jolts before the blade lands. */
  .modal.show.is-win .modal-card{ animation: winCard 1.5s cubic-bezier(.2,.9,.3,1) both; }
  @keyframes winCard{
    0%{ opacity: 0; transform: scale(1.22) rotate(-1.6deg); }
    13%{ opacity: 1; transform: scale(.985) rotate(.4deg); }
    22%{ transform: none; }
    57%{ transform: translate(-5px, 2px) rotate(-.55deg); }   /* blade lands */
    64%{ transform: translate(4px, -2px) rotate(.45deg); }
    71%{ transform: translate(-2px, 1px) rotate(-.15deg); }
    100%{ transform: none; }
  }

  /* ---------- after the cut: the handshake ----------
     Chess ends with a handshake, so the stage does not sit empty once the king
     is gone. Rises in as the halves fade and stays. */
  .gs-gg{
    left: 50%; top: 50%;
    display: flex; flex-direction: column; align-items: center; gap: 7px;
    transform: translate(-50%, -50%);
    opacity: 0;
  }
  .gg-hands{
    font-size: 84px; line-height: 1;
    /* pushed out of emoji colour into the paper palette */
    filter: grayscale(1) sepia(.62) saturate(1.5) hue-rotate(-14deg) brightness(.82) contrast(1.05);
  }
  .gg-text{
    font-family: var(--mono); font-size: 10px; font-weight: 600;
    letter-spacing: .28em; text-transform: uppercase; color: var(--walnut-2);
  }
  .modal.show.is-win .gs-gg{ animation: ggIn .8s cubic-bezier(.2,.85,.3,1) 1.34s both; }
  @keyframes ggIn{
    0%{ opacity: 0; transform: translate(-50%, -34%) scale(.82); }
    100%{ opacity: 1; transform: translate(-50%, -50%) scale(1); }
  }
  @media (max-width: 560px){
    .gg-hands{ font-size: 64px; }
  }
  @media (max-width: 560px){
    .gs{ height: 132px; }
    .gs-half{ font-size: 92px; }
    .gs-piece{ font-size: 72px; }
  }
  /* ---------- résumé: one loud call to action, nothing competing ---------- */
  .resume-cta{
    display: flex; align-items: center; gap: 16px;
    width: min(520px, 100%);
    margin: 20px auto 0;
    text-decoration: none;
    color: var(--paper); background: var(--ink);
    border: 1px solid var(--ink);
    padding: 16px 20px;
  }
  /* colour only — the shadow, lift and press come from the shared button rule */
  .resume-cta:hover{ background: var(--red); border-color: var(--red); }
  .resume-cta .rc-doc{ font-family: var(--pieces); font-size: 34px; line-height: 1; flex: none; }
  .resume-cta .rc-text{ display: flex; flex-direction: column; gap: 3px; text-align: left; flex: 1; }
  .resume-cta .rc-text b{
    font-family: var(--mono); font-size: 13.5px; font-weight: 600;
    letter-spacing: .16em; text-transform: uppercase;
  }
  .resume-cta .rc-text i{
    font-style: normal; font-family: var(--mono); font-size: 10px;
    letter-spacing: .1em; color: rgba(var(--halo),.72);
  }
  .resume-cta .rc-go{ font-family: var(--mono); font-size: 20px; flex: none; }

  /* status + controls live at the foot of the scoresheet rail */
  .rail-foot{
    margin-top: auto;
    border-top: 1px solid var(--walnut-3);
    padding: 8px 12px 4px;
  }
  .rail-foot .status-line{ min-height: 17px; font-size: 11px; margin-bottom: 8px; }
  .rail-foot .btn-row{ gap: 6px; }
  .rail-foot .gbtn{ font-size: 10.5px; padding: 5px 9px; flex: 1 1 auto; text-align: center; }

  /* stacked layout: board first, rails beneath it */
  @media (max-width: 1120px){
    .arena{
      /* 100vw - 40px, not 94vw: it has to match this element's own 18px side
         padding, or the board overshoots the space it actually has. */
      --board: clamp(240px, min(100svh - 150px, 100vw - 40px), 620px);
      grid-template-columns: minmax(0, 1fr);
      grid-template-rows: none;
      justify-items: center;
      align-content: start;
      padding-top: 62px;
    }
    /* No align-items:center here — that stops the board stretching to the
       column and it collapses to a content-derived (much smaller) size. */
    .board-col{ order: 1; grid-row: auto; width: var(--board); max-width: 100%; margin: 0 auto; }
    .rail-r{ order: 2; }   /* controls follow the board on small screens */
    .rail-l{ order: 3; }
    .rail{
      grid-row: auto;
      width: min(100%, 620px); height: auto; max-height: 420px;
      margin-top: 14px; justify-self: center;
    }
  }

  /* Must come after the block above, or its padding-top wins at these widths:
     the bar becomes two rows (~70px) below 720px, so clear it and hand the
     board back the difference. */
  @media (max-width: 720px){
    .arena{
      --board: clamp(240px, min(100svh - 170px, 100vw - 40px), 620px);
      padding-top: 82px;
    }
  }

  /* Captured pieces, nothing else — one scrolling strip, never a wrapping
     block, so a long game can't grow it and push the board off screen. */
  .tray{
    display: flex; align-items: center; flex-wrap: nowrap; gap: 1px;
    height: 26px; min-height: 26px;
    padding: 0 2px;
    overflow-x: auto; overflow-y: hidden;
    scrollbar-width: none;
  }
  .tray::-webkit-scrollbar{ display: none; }
  .tray > *{ flex: none; }
  .tray-bot{ justify-content: flex-start; }
  .t-pc{
    font-family: var(--pieces); font-size: 21px; line-height: 1;
    color: var(--ink);
    /* no `both`: a forwards fill holds transform:none, and a filled animation
       outranks the cascade — which would silently kill the hover lift below.
       The `to` frame equals the rest state, so the fill bought nothing. */
    animation: chipIn .26s cubic-bezier(.2,.9,.3,1);
    cursor: default;
  }
  #tray-player .t-pc{
    color: var(--ink);
    text-shadow: 0 1px 0 rgba(244,236,217,.5);
  }
  @keyframes chipIn{
    from{ opacity: 0; transform: translateY(-5px) scale(.9); }
    to{ opacity: 1; transform: none; }
  }

  .board-wrap{ position: relative; }
  #board{
    display: grid;
    /* minmax(0, 1fr), not 1fr: a bare 1fr track floors at its content size, so
       ranks holding tall piece glyphs grew taller than empty ranks and the
       squares stopped being square. */
    grid-template-columns: repeat(8, minmax(0, 1fr));
    grid-template-rows: repeat(8, minmax(0, 1fr));
    width: 100%; aspect-ratio: 1;
    border: 2px solid var(--walnut);
    outline: 3px double var(--walnut); outline-offset: 3px;
    box-shadow: 0 14px 34px -14px rgba(36,26,15,.4);
    user-select: none;
    /* The board paints itself as a printed diagram from CSS alone, with no script
       at all — so the pre-JS shell is a chessboard rather than an empty frame,
       and a page whose scripts die shows a board instead of a hole.
       PHASE IS LOAD-BEARING: renderBoard gives the top-left square .light
       (r=7, f=0 -> ((7-7)+0) % 2 === 0), and a conic tile puts its FIRST colour
       in the top-RIGHT quadrant — so the first colour named here is the DARK one.
       Each tile covers a 2x2 block of squares, hence 25%.
       Unconditional rather than scoped to a boot class: once the 64 .sq buttons
       exist they are opaque and cover this exactly, so it is only ever seen
       before them. Both colours are .sq.light / .sq.dark verbatim — keep all
       four in step. */
    background-image: repeating-conic-gradient(#c8a878 0 25%, #f3e8cf 0 50%);
    background-size: 25% 25%;
  }
  .sq{
    position: relative;
    border: 0; padding: 0;
    display: flex; align-items: center; justify-content: center;
    -webkit-tap-highlight-color: transparent;
    touch-action: manipulation;
  }
  .sq.light{ background: #f3e8cf; }
  .sq.dark{ background: #c8a878; }
  .sq .pc{
    font-family: var(--pieces);
    /* scales with the board so glyphs stay proportional at any viewport */
    font-size: calc(var(--board, 420px) / 8 * 0.78);
    line-height: 1;
    pointer-events: none;
    transform: translateY(-2%);
  }
  .sq .pc.w{
    color: #fbf4e0;
    -webkit-text-stroke: 1px rgba(36,26,15,.9);
    text-shadow: 0 2px 2px rgba(36,26,15,.28);
  }
  /* PINNED, not var(--ink) — this is the one place the distinction matters. A black
     chessman is black in both themes; the token flips to near-paper in dark mode, and
     riding it would have turned Black's whole army white the moment the theme changed.
     Every OTHER piece glyph on the page (trays, bounty rails, the win slice) represents
     "the bot's pieces" and deliberately KEEPS the token, because the bot really does
     swap colours with the theme. Board pieces are literal; UI pieces are semantic. */
  .sq .pc.b{
    color: #241a0f;
    text-shadow: 0 1px 0 rgba(244,236,217,.35);
  }
  .sq .coord{
    position: absolute; font-family: var(--mono); font-size: 8.5px; font-weight: 600;
    color: rgba(91,58,36,.55); pointer-events: none;
  }
  .sq .coord.rank{ top: 2px; left: 3px; }
  .sq .coord.file{ bottom: 1px; right: 3px; }
  /* The square's five highlight states are composed further down (search
     "BOARD HIGHLIGHT COMPOSITION"). They used to be five plain box-shadow
     declarations at equal specificity, so they overwrote each other: a king
     that was both checked and selected showed no selection ring at all. */
  .sq.dot::after{
    content: ""; position: absolute;
    width: 26%; aspect-ratio: 1; border-radius: 50%;
    background: rgba(164,33,27,.45);
    animation: dotIn .16s ease-out both;
  }
  @keyframes dotIn{
    from{ transform: scale(.2); opacity: 0; }
    to{ transform: scale(1); opacity: 1; }
  }

  /* piece movement: app.js offsets the glyph to its origin square and clears
     the transform, so the piece slides into place (FLIP). */
  .sq .pc.moving{ transition: transform .2s cubic-bezier(.22,.68,.3,1); will-change: transform; }
  .sq .pc.landed{ animation: landTap .22s ease-out; }
  @keyframes landTap{
    0%{ filter: none; }
    45%{ transform: translateY(-8%) scale(1.06); }
    100%{ transform: translateY(-2%) scale(1); }
  }
  #fx{
    position: absolute; inset: -8px; width: calc(100% + 16px); height: calc(100% + 16px);
    pointer-events: none; z-index: 5;
  }
  .sq-stamp{
    position: absolute; z-index: 6; pointer-events: none;
    font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: .18em;
    color: var(--red); border: 2px solid var(--red);
    padding: 1px 5px; background: rgba(var(--halo),.85);
    transform: translate(-50%,-50%) rotate(-12deg);
    animation: stampIn .8s ease both;
  }
  @keyframes stampIn{
    0%{ opacity: 0; transform: translate(-50%,-50%) rotate(-12deg) scale(2.1); }
    22%{ opacity: 1; transform: translate(-50%,-50%) rotate(-12deg) scale(1); }
    78%{ opacity: 1; }
    100%{ opacity: 0; transform: translate(-50%,-52%) rotate(-12deg) scale(.96); }
  }

  /* ============================================================
     THE BOARD BEFORE THE SCRIPTS — "SETTING THE POSITION"
     Two things, and neither is a curtain over the page. #board above is already
     a full diagram; this is the arbiter's card sitting on it while the scripts
     land, plus the beat the pieces settle on once they have. Nothing here waits
     on a font, an image, or a timer.
     ============================================================ */
  /* ============================================================
     THE INTRO — a knight runs the king through, and the screen opens
     Same cast as the checkmate slice (gsPiece / gsSlash / gsBurst / gsTop /
     gsBot) but at full-screen scale, and the thing being cut is the cover itself
     rather than a glyph inside a card. Deliberately NOT the same numbers, so the
     two never read as the same beat.

     THE PAUSES ARE THE POINT, and there are two of them doing different jobs.
     An early cut had the halves starting to part at .78s, while the blade was still
     on screen and the flash still going — everything landing together, which reads
     as one busy event rather than as a cut that takes a moment to be fatal.

     PAUSE ONE, 1.04 -> 1.52, is dead air. The blade has come and gone, the king is
     still standing, the screen is still whole. NOTHING may be scheduled in this
     window and no earlier travel may run into it — it is half a second of held
     breath, and anything moving in it spends the effect.

     Then the king POPS: the top half shoves up and LEFT while turning anticlockwise,
     the bottom down and RIGHT while turning clockwise, and that is all. The curtain
     does not move. For that moment the king is visibly cut in two while the world it
     is standing on is still intact, and the ground shows through the gap.

     PAUSE TWO, 1.66 -> 2.20, holds it there. Structurally nothing moves, though the
     debris thrown by the pop is still settling through most of it — deliberately, so
     the hold has something alive in it and does not read as a freeze. Only then does
     everything slide away.

     Timeline
       0    -  .40   the king lands
        .15 -  .95   the knight crosses, passing the king at .47
        .46 -  .58   the blade draws, left to right, in the knight's direction
        .46 -  .98   the flash
        .58 - 1.04   the blade holds, then fades out
       1.04 - 1.52   PAUSE ONE — dead air, nothing at all
       1.52 - 1.66   the halves pop apart, up-left and down-right, turning · debris
       1.66 - 2.20   PAUSE TWO — held open, chips settling
       2.20 - 2.90   the grounds part, and quickly — 104% of the height
       2.20 - 3.08   the king follows at a third the speed, 42% — the parallax
       3.15          inert   ·   3.30  app.js removes the node
     ============================================================ */
  .intro{
    position: fixed; inset: 0; z-index: 200;    /* over .cover (140) */
    overflow: hidden;
    /* Click-through for its whole life, deliberately. Nobody aims at anything
       during the curtain, and it means the page underneath is never actually
       blocked — so the one case that could otherwise strand a visitor (animations
       not running AND scripts blocked, where neither the keyframe below nor
       app.js would clear it) degrades to a dark pane you can still scroll and
       click through rather than a locked screen. */
    pointer-events: none;
    /* Inert once every travel has finished — the king halves are last, at 3.08s.
       Set this EARLIER than that and the parting is cut off mid-slide: at 1.6s
       against a 1.6s travel the grounds were only 51% of the way out, so the
       reveal snapped instead of sliding. The CSS fail-safe, needing no script. */
    animation: introDone 1ms linear 3.15s forwards;
  }
  @keyframes introDone{ to{ visibility: hidden; } }
  /* SEEN IT ALREADY. The class goes on <html> by the inline script in the head, before
     <body> is parsed, so the curtain never paints a single frame on a return visit.
     display:none rather than visibility or opacity, because those would still let the
     keyframes run — and the debris alone is fourteen animated elements nobody is
     looking at. The footer's replay link drops the class for that page view. */
  .seen-intro .intro{ display: none; }

  /* ---- the two grounds ---- */
  .intro-half{ position: absolute; inset: 0; }
  /* One diagonal, two complementary clips — the shared edge IS the cut. The king
     halves below use the SAME two clips, so both are severed on one line.
     THE TOP OVERSHOOTS BY .2%, and it is not sloppiness. Two clips meeting on the
     exact same line still antialias their own edges, so both sides of the seam are
     partially transparent and the page shows through as a hairline — a second, much
     fainter pre-cut underneath the one the wash was drawing. Overlapping them puts
     something opaque behind that soft edge.
     The TOP is the one extended because .intro-bot comes after it in the document
     and therefore paints over the overlap: the visible cut is still bot's clean edge
     at 58%/42%, exactly where the blade is drawn, so nothing has to move to match.
     The king halves are deliberately NOT extended — their hairline falls inside a
     glyph over an opaque ground rather than over the page, and overlapping them
     would double-draw a sliver of stroked glyph and darken it. */
  .intro-top{ clip-path: polygon(0 0, 100% 0, 100% 42.2%, 0 58.2%); }
  .intro-bot{ clip-path: polygon(0 58%, 100% 42%, 100% 100%, 0 100%); }

  /* ---- THE GROUND ----
     PAPER: the site's own surface. The cut then reads as a page being slit rather
     than a stage going dark, which is what the rest of this page is about — and it
     is why this won over five darker drafts (kept in
     ../website-themes/gambit-intro-grounds.css, four of them dark, for when there
     is a dark mode to hang them on).

     A ground is defined ENTIRELY by the properties below, and that is the point of
     writing it this way: swapping the whole backdrop never touches the parting, the
     clips or the timing. It is the seam a theme plugs into.

       --gnd-base   what sits underneath — the guarantee of opacity
       --gnd        the fill itself, a whole background-image list
       --wash-far   shading at the screen's top and bottom edges
       --wash-cut   shading through the band the cut travels — nothing, on paper
       --blade-1 / --blade-2 / --burst   the light effects
       --shard      the chips thrown off the king — ink on paper, paper on ink
       --frame-1 / --frame-2   the plate border's heavy rule and its hairline

     THE WASH RUNS OUTWARD, NOT INWARD, and that follows from the blade's colour.
     The original converged on ink at the cut, deliberately, so that the seam was the
     darkest line on screen and a WHITE blade had something to read against. Invert
     the blade to ink for paper and that same wash becomes a dark diagonal painted
     along the seam from the very first frame — it pre-announces the cut, visible
     before the knight has even crossed. So it now darkens toward the screen's OUTER
     edges instead: a vignette that frames the middle and leaves the seam perfectly
     clean, with the ink blade landing on the brightest part of the ground.
     A dark ground wants the original direction back, which is a matter of swapping
     these two values. The two go together — flipping the blade without flipping the
     wash is what produced the visible pre-cut.

     The light effects INVERT with the ground and that is not a nicety: a white halo
     on paper is invisible, and an ink one is exactly what makes a slit in paper look
     deep. So the blade's halo here is ink, and any dark ground has to put it back to
     white. The burst goes the other way — it is light, so it stays light, just
     pushed nearly to full white to register against paper at all.

     Both halves take the same --gnd at the same position, so any pattern in it runs
     unbroken across the cut until they part — then each carries its own half away.
     Opaque by contract: no blur, nothing showing through. The ground IS the cover. */
  .intro{
    --gnd-base: var(--paper);
    --gnd: radial-gradient(125% 95% at 50% 48%, #fdf7e6 0%, #ecdfc0 58%, #d5c39c 100%);
    --wash-far: rgba(36,26,15,.16);
    /* Zero-alpha INK rather than `transparent`, which is zero-alpha black: the two
       are not the same when a gradient interpolates through them. */
    --wash-cut: rgba(36,26,15,0);
    --blade-1: rgba(36,26,15,.45);
    --blade-2: rgba(36,26,15,.22);
    --burst: rgba(255,255,255,.95);
    /* The king is ink here, so its chips are ink. On a dark ground the king inverts
       to paper and so must these, or the debris vanishes into the backdrop. */
    --shard: rgba(36,26,15,.92);
    --frame-1: rgba(36,26,15,.52);
    --frame-2: rgba(36,26,15,.26);
    /* The blade's SVG gradient stops read this (see the .intro-slash stop rule): all
       three stops share one colour and differ only in opacity, so one token flips the
       whole mark. Ink on paper; paper on ink. */
    --gash: #0d0805;
    /* The cast: the king rides var(--ink) and so flips with the theme by itself —
       black king slain in the light world, WHITE king slain in the dark one, which is
       the story of the toggle. These four carry the parts of the pair that cannot ride
       a token: the knight's fill is the king's opposite, and each stroke is its own
       fill's opposite. */
    --ck-edge: rgba(244,236,217,.92);
    --kn-fill: #fbf4e0;
    --kn-edge: rgba(36,26,15,.92);
    /* The WHOLE filter, not just a colour: dark mode swaps the offset ink shadow
       for an unoffset pale glow, and the reason is measured, not taste. The
       shadow sits on .intro-kh, OUTSIDE the clip (so the pop cannot slice it),
       which means each half's shadow can reach the OTHER half: the bottom
       half's 14px blur climbs ~8px above its own cut edge and paints onto the
       top half's glyph. On an ink king that smudge is invisible (-1 luminance);
       on dark mode's WHITE king it was a -11.7 band exactly along the future
       cut, announcing the slice before the blade. A glow with no offset lands
       light-on-light there (+0.0), and on a near-black ground it is also the
       only shadow that reads at all. */
    --pc-shadow: drop-shadow(0 6px 14px rgba(36,26,15,.45));
  }
  /* stop-color is a presentation attribute, so this CSS wins over the markup value —
     the SVG keeps its light-theme colours as the no-CSS fallback */
  .intro-slash stop{ stop-color: var(--gash); }
  .intro-half{
    background-color: var(--gnd-base);
    background-image: var(--gnd);
    background-repeat: no-repeat;
    background-size: cover;
    background-position: center;
  }
  /* The wash is a LAYER, not another entry in --gnd: keeping it separate means a
     ground can be a one-liner and never has to restate the shading, and no ground
     can get its layer count out of step with background-size. It is clipped by the
     parent's clip-path like any other child, so it is cut on the same diagonal.

     BOTH HALVES GET THE IDENTICAL GRADIENT, and that is what makes the seam exact
     rather than merely close. The halves are the same full-viewport box, so one
     gradient resolves to the same value at the same point in both — the seam cannot
     show, by construction, at any aspect ratio.
     It has to be built this way because the cut is DIAGONAL. Mirroring a `to bottom`
     gradient between the halves, which is the obvious way to shade toward a seam,
     reaches its end colour at the element's edge and not at the seam: the seam
     travels from 58% down-left to 42% up-right, so the two halves land on different
     values along it and leave a step that is zero at mid-screen and widens toward
     both ends. Measured at .026 ink alpha here — faint, but a line.
     So instead: symmetric, and deliberately FLAT across 42%-58%, the exact band the
     seam travels through. The shading lives only where the seam never goes.
     Those two stops are the same numbers as the clip-paths above; they have to move
     together. --wash-far is the screen's top and bottom edges, --wash-cut the clear
     band through the middle, and a ground decides which of the two is darker. */
  .intro-half::before{
    content: ""; position: absolute; inset: 0;
    background: linear-gradient(to bottom,
      var(--wash-far) 0%,
      var(--wash-cut) 42%,
      var(--wash-cut) 58%,
      var(--wash-far) 100%);
  }

  /* ---- the plate border ----
     A letterpress frame: a heavy rule inset from the edge, a hairline inside it, and a
     small block at each corner. Same vocabulary as the dotted rules and case borders on
     the page proper, at curtain scale.
     ON BOTH HALVES, EXACTLY LIKE THE GROUND AND THE WASH. Each half draws the WHOLE
     frame and shows only its own side of the cut, so at rest the union is one unbroken
     border with nothing to give the cut away — and when they part, each carries its own
     piece of the frame with it, which is the entire point. Drawing it once on .intro
     instead would leave a frame hanging in mid-air while the halves slid out from under
     it.
     THREE RULES FROM ONE ELEMENT, because ::before is already the wash and there is no
     third pseudo-element available: `border` is the heavy rule, `outline` with a
     NEGATIVE offset is the hairline drawn inside it, and four one-colour gradients
     pinned to the corners are the blocks. background-origin is padding-box by default,
     so the blocks land just inside the heavy rule, in the channel between the two. */
  .intro-half::after{
    content: ""; position: absolute; inset: 2.4vmin;
    border: 2px solid var(--frame-1);
    outline: 1px solid var(--frame-2);
    outline-offset: -1.5vmin;
    background-image:
      linear-gradient(var(--frame-1), var(--frame-1)),
      linear-gradient(var(--frame-1), var(--frame-1)),
      linear-gradient(var(--frame-1), var(--frame-1)),
      linear-gradient(var(--frame-1), var(--frame-1));
    background-size: 1vmin 1vmin;
    background-position: left top, right top, left bottom, right bottom;
    background-repeat: no-repeat;
  }

  /* ---- the king, cut on the same line but travelling on its own ----
     THE SHADOW LIVES OUT HERE, OUTSIDE THE CLIP, AND THE CLIP LIVES ON THE GLYPH.
     That inversion is the whole point of this pair of rules. A filter is applied to an
     element's rendered content and the clip is applied AFTERWARDS, so with the shadow
     on the glyph and the clip on this wrapper — which is how it was — the shadow got
     generated and then sliced along the diagonal. At rest that is invisible, because
     the two clips tile the plane and their union is the whole shadow. The instant the
     halves separate, each carries a fragment of shadow ending on a hard diagonal edge,
     and the pieces read as flat cut-outs.
     Putting the clip on a DESCENDANT reverses the order: the glyph is cut first, then
     this wrapper casts a shadow from the cut silhouette. So each half throws a shadow
     off its own cut face, which is what a solid slab does, and nothing is ever cut.
     Checked against the old behaviour at rest, since that is what this risks: each half
     now also shadows its own cut EDGE, which could have darkened the seam and given the
     cut away before the pop. It does not — worst case 1.6 of 255 across the seam band.
     Where ink crosses the cut that shadow falls on the other half's ink and cannot be
     seen; where there is no ink there is no edge to cast one.
     The filter region is this element, so it is viewport-sized. That is fine because
     the content inside never changes during the animation — only transform and opacity
     do — so the blur is rasterised once and then merely composited. */
  .intro-kh{
    position: absolute; inset: 0;
    filter: var(--pc-shadow);
  }
  .intro-kh-top .intro-king{ clip-path: polygon(0 0, 100% 0, 100% 42%, 0 58%); }
  .intro-kh-bot .intro-king{ clip-path: polygon(0 58%, 100% 42%, 100% 100%, 0 100%); }

  /* Black king, and the treatment matches the real board exactly: .pc.b is ink with
     a paper edge and .pc.w is paper with an ink edge, so the curtain's two pieces
     are the same pieces, just enormous. On the paper ground that reads as a solid
     black king against an outlined white knight.
     Each carrying a stroke in the OPPOSITE colour is also what makes them
     ground-independent — invert the backdrop and neither piece vanishes into it,
     they simply trade which one is the solid and which is the line art. */
  /* Full-viewport and centring its OWN text, rather than being a shrink-wrapped span
     centred by the wrapper. It has to be viewport-sized because it now carries the
     clip, and clip-path percentages resolve against the element's own box — on a
     shrink-wrapped span the same polygon would describe a completely different line.
     The glyph ends up in exactly the same place either way: before, the wrapper
     flex-centred this span's line box in the viewport; now this span flex-centres the
     same line box inside a box that IS the viewport. Verified, not assumed.
     introKing scales this element, clip and all — which is safe precisely because the
     cut passes through the centre. A uniform scale about the centre maps that line onto
     itself: at 1.35 the polygon's corners move to (-17.5%, 60.8%) and (117.5%, 39.2%),
     which is the same -0.16 slope through the same midpoint. */
  .intro-king{
    position: absolute; inset: 0;
    display: flex; align-items: center; justify-content: center;
    font-family: var(--pieces);
    font-size: clamp(120px, 30vmin, 260px);
    line-height: 1;
    color: var(--ink);    /* flips with the theme: the fallen king is always the OLD world's */
    -webkit-text-stroke: 3px var(--ck-edge);
    animation: introKing .4s cubic-bezier(.2,.7,.2,1) both;
  }

  /* ---- the pop, the hold, and the parting ----
     THE GROUNDS DO NOT MOVE UNTIL 2.20, and that is the whole trick. The blade
     finished and faded at 1.04; the king pops at 1.52 and then sits there open. Pull
     this delay any earlier and the curtain starts leaving while the king is still
     being cut, which is the version that had no pauses in it.
     THE TIMING IS ALSO LOAD-BEARING AT THE OTHER END: .intro goes visibility:hidden
     at 3.15s, so every travel here has to FINISH before then or the slide is cut off
     mid-flight and the reveal snaps. Grounds run 2.20->3.02, king halves 2.20->3.08.
     Straight up and down: at full-screen size any rotation swings a corner into
     view, and the reveal has to be clean to the edges. */
  /* .70s, not .82s — the grounds leave FASTER than they used to, and that is half of
     what makes the parallax read. Widening the gap by slowing the king instead would
     have pushed the whole curtain past 3.4s; speeding the departure costs nothing and
     also suits it, since the world flying apart while the king hangs is the picture. */
  .intro-half{ animation: introPart .7s cubic-bezier(.5,0,.2,1) 2.2s both; }
  .intro-top{ animation-name: introPartUp; }
  .intro-bot{ animation-name: introPartDown; }
  @keyframes introPartUp  { to{ transform: translateY(-104%); } }
  @keyframes introPartDown{ to{ transform: translateY(104%); } }

  /* THREE MOVES IN ONE RUN, because they have to be one animation: two animations on
     the same element would have the later one's fill mode stomp the earlier one's
     transform, and splitting them across the two halves would risk the pair drifting
     out of sync — which is the one thing that must never happen here, since the king
     only looks whole because its halves are perfectly registered.
       0    -> 9%      THE POP — a shove, on the diagonal   1.52 -> 1.66
       9%   -> 43.6%   held open, dead still                1.66 -> 2.20
       43.6 -> 100%    away, and fading                     2.20 -> 3.08

     EACH HALF TRANSLATES ON A DIAGONAL *AND* ROTATES, and no two of those four numbers
     match. The top half goes up and LEFT, about 30 degrees off vertical, turning 4.5
     degrees anticlockwise; the bottom goes down and RIGHT, about 23 degrees off
     vertical, turning 3.4 degrees clockwise. Opposite translations, opposite spins,
     unequal everything: that is what makes them read as two independent pieces of one
     broken object instead of a symmetrical split. Mirrored values looked mechanical,
     and translation without rotation looked like two sliding plates.

     THE PIVOTS ARE EACH PIECE'S OWN CENTRE OF MASS, and that is load-bearing rather
     than fussy. The default origin is the centre of the ELEMENT, which is the whole
     viewport — so the pivot would sit on the cut, below the top half's mass and above
     the bottom's, and rotating about it would swing each piece sideways. At these
     magnitudes that swing is about the same size as the pop itself, so it would have
     cancelled the bottom half's rightward travel almost exactly. Pivoting each piece on
     its own mass makes rotation pure spin, leaving the translate solely in charge of
     direction. Measured off the glyph: its ink spans 344-558px on a 900px screen and
     the cut crosses at 450, so the halves' centres are 397px (44%) and 504px (56%).

     THE POP IS IN VMIN, THE DEPARTURE IN PERCENT, and that difference is deliberate.
     The pop has to stay a constant fraction of THE KING, which is itself sized in
     vmin, so vmin keeps the gap at a tenth of the piece on any screen: 1.55vmin is
     14px against a 260px king on a laptop and 6px against a 120px one on a phone.
     Percentages would not — and worse, since translateX resolves against width and
     translateY against height, a percentage pair changes ANGLE with the aspect ratio:
     16 degrees on a desktop collapses to about 5 on a phone. vmin fixes both axes to
     one length, so the angle holds everywhere.
     The departure is the opposite case. It is about clearing the SCREEN, not about the
     king, so it stays in percent of the viewport. The two units interpolate through
     calc() between keyframes, which is fine and is verified.
     Per-segment easing, hence `linear` on the shorthand — the pop is a hard snap, the
     hold is nothing, and the departure eases out. */
  .intro-kh{ animation: introKhPart 1.56s linear 1.52s both; }
  .intro-kh-top{ transform-origin: 50% 44%; animation-name: introKhUp; }
  .intro-kh-bot{ transform-origin: 50% 56%; animation-name: introKhDown; }
  /* PARALLAX, WIDENED. On the way out the king covers 42% of the height in .88s while
     the ground covers 104% in .70s — about a third of its speed, where it used to be
     four sevenths. It also keeps drifting along its pop angle and keeps TURNING, out to
     -9 and +7 degrees, so the king is moving on three axes while the ground moves on
     one. That is most of why the depth reads: a rigid vertical slide gives the eye
     nothing to compare against.
     Travelling only 42% means it is still on screen, fading, after the ground has gone
     — which is the whole effect: the page arrives underneath a king that has not
     finished falling. The fade is held to 82% for that reason.
     The parallax is that difference in speed and distance, NOT a difference in start
     time. An earlier cut held the king still for the first 40% of a shared travel and
     then released it, opening a ~700px gap on a 900px screen, and that read as the king
     detaching rather than as depth. */
  @keyframes introKhUp{
    0%    { transform: translate(0, 0) rotate(0deg); opacity: 1;
            animation-timing-function: cubic-bezier(.12,.9,.25,1); }
    9%    { transform: translate(-.85vmin, -1.5vmin) rotate(-4.5deg); opacity: 1;
            animation-timing-function: linear; }
    43.6% { transform: translate(-.85vmin, -1.5vmin) rotate(-4.5deg); opacity: 1;
            animation-timing-function: cubic-bezier(.45,0,.35,1); }
    82%   { opacity: 1; }
    100%  { transform: translate(-5vmin, -42%) rotate(-9deg); opacity: 0; }
  }
  @keyframes introKhDown{
    0%    { transform: translate(0, 0) rotate(0deg); opacity: 1;
            animation-timing-function: cubic-bezier(.12,.9,.25,1); }
    9%    { transform: translate(.6vmin, 1.42vmin) rotate(3.4deg); opacity: 1;
            animation-timing-function: linear; }
    43.6% { transform: translate(.6vmin, 1.42vmin) rotate(3.4deg); opacity: 1;
            animation-timing-function: cubic-bezier(.45,0,.35,1); }
    82%   { opacity: 1; }
    100%  { transform: translate(3.5vmin, 42%) rotate(7deg); opacity: 0; }
  }

  @keyframes introKing{
    from{ opacity: 0; transform: scale(1.35); }
    to  { opacity: 1; transform: scale(1); }
  }

  .intro-knight, .intro-burst{
    position: absolute; left: 50%; top: 50%;
    pointer-events: none;
  }
  .intro-knight{
    font-family: var(--pieces);
    font-size: clamp(90px, 22vmin, 200px);
    line-height: 1;
    /* White knight against a black king — the mirror of the king's treatment, so
       the ink stroke is what keeps it legible on a light ground. */
    color: var(--kn-fill);
    -webkit-text-stroke: 2px var(--kn-edge);
    filter: var(--pc-shadow);
    /* Passes the king at 40% of its run, so .15 + .8 * .4 = .47s — and the blade
       below starts drawing at .46. The two are tied: the mark has to appear as the
       knight is level with the king, not before it arrives or after it has gone. */
    animation: introKnight .8s cubic-bezier(.32,.05,.2,1) .15s both;
  }
  @keyframes introKnight{
    0%  { opacity: 0; transform: translate(-320%, -50%) rotate(-18deg) scale(.85); }
    14% { opacity: 1; }
    40% { transform: translate(-50%, -50%) rotate(6deg) scale(1.14); }
    72% { opacity: 1; }
    100%{ opacity: 0; transform: translate(240%, -50%) rotate(14deg) scale(.95); }
  }
  /* Spans the whole screen so its percentage geometry lines up with the clips; the
     shapes inside sit on the seam. The drop-shadow is the outer glow — a
     box-shadow would trace the element's box, not the tapered slash inside it. */
  .intro-slash{
    position: absolute; inset: 0;
    width: 100%; height: 100%;
    pointer-events: none;
    opacity: 0;
    /* Halo colour comes from the ground, because it has to: a white glow on the
       paper ground is invisible, and an ink one there is what sells depth. */
    filter: drop-shadow(0 0 7px var(--blade-1))
            drop-shadow(0 0 20px var(--blade-2));
    animation: introSlash .58s linear .46s both;
  }
  /* It is WIPED, via clip-path, and never scaled: scaling this element horizontally
     would change the diagonal's angle mid-animation, since preserveAspectRatio="none"
     ties the geometry to the element's own box.
     LEFT TO RIGHT, the way the knight travels — the mark is the path the blade took,
     so it has to be laid down in that direction. It used to open from the centre
     outwards in both directions, which reads as a cut spreading rather than as a
     stroke being made.
     Three moves in one: strike, hold, fade. The strike is 116ms because a slash is
     nearly instantaneous; the hold is what makes it register at all; and the fade is
     what leaves the screen empty for the beat that follows. Each segment carries its
     own timing function, so the stroke stays sharp while the fade eases — hence the
     shorthand above declaring `linear` rather than a curve for the whole run. */
  @keyframes introSlash{
    0%  { opacity: 1; clip-path: inset(0 100% 0 0);
          animation-timing-function: cubic-bezier(.16,.84,.36,1); }
    20% { opacity: 1; clip-path: inset(0 0 0 0);
          animation-timing-function: linear; }
    60% { opacity: 1; clip-path: inset(0 0 0 0);
          animation-timing-function: ease-in; }
    100%{ opacity: 0; clip-path: inset(0 0 0 0); }
  }
  .intro-burst{
    width: 160vmax; height: 160vmax;
    background: radial-gradient(circle at 50% 50%, var(--burst), rgba(255,255,255,0) 60%);
    opacity: 0;
    /* Belongs to the STRIKE, not to the split: it fires with the blade at .46 and is
       gone by .98, so the beat is genuinely empty. */
    animation: introBurst .52s ease-out .46s both;
  }
  @keyframes introBurst{
    0%  { opacity: 0; transform: translate(-50%, -50%) scale(.35); }
    30% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
    100%{ opacity: 0; transform: translate(-50%, -50%) scale(1.2); }
  }

  /* ---- the king comes apart ----
     Fourteen chips from the point of impact. The container is a ZERO-SIZED point at
     the centre of the screen, so every shard's own translate(-50%,-50%) starts it
     exactly on that point and --dx / --dy are then a plain flight vector out from it.
     THROWN BY THE POP, at 1.52. Not at the strike — they used to fly with the flash,
     which put the eruption inside the stroke and left nothing to happen afterwards —
     and not at the parting either, because the moment the king separates is the moment
     its cut faces would shed anything.
     This delay has to track the pop's. Leave it behind and the chips fly during pause
     one, which has to stay dead air.
     Runs 1.52 -> 2.19 at the latest, so it spans most of pause two and is spent right
     as the grounds start to move. That overlap is deliberate: the hold is structurally
     frozen, and the chips settling through it are the only thing keeping it from
     reading as a stalled frame. */
  .intro-debris{
    position: absolute; left: 50%; top: 50%;
    width: 0; height: 0;
    pointer-events: none;
  }
  .sh{
    position: absolute; left: 0; top: 0;
    width: var(--w); height: var(--w);
    background: var(--shard);
    animation: introShard .62s cubic-bezier(.12,.62,.28,1) calc(1.52s + var(--d)) both;
  }
  /* Four irregular chips rather than squares or dots — a rotating square reads as a
     square, and at this size a polygon reads as broken material. Cycled across the
     fourteen so no two neighbours in flight share a silhouette. */
  .sh1, .sh5,  .sh9,  .sh13{ clip-path: polygon(0 0, 100% 26%, 58% 100%); }
  .sh2, .sh6,  .sh10, .sh14{ clip-path: polygon(14% 0, 100% 44%, 72% 100%, 0 60%); }
  .sh3, .sh7,  .sh11       { clip-path: polygon(0 32%, 64% 0, 100% 70%, 28% 100%); }
  .sh4, .sh8,  .sh12       { clip-path: polygon(0 0, 100% 8%, 52% 100%); }
  /* Flight vectors, spins and sizes. Weighted along the cut and slightly downward on
     balance, so the spray follows the blade's line and then gives in to gravity
     rather than reading as a symmetrical firework. The knight travels left to right,
     so the longest throws are to the right — that is its momentum.
     SIZES ARE SET AGAINST THE KING, not picked to look small. Its glyph inks at
     179x214px when the clamp is at its 260px ceiling (measured off a canvas raster of
     Segoe UI Symbol, not guessed), so these 1.8-3.8vmin chips are 16-34px — roughly a
     tenth to a fifth of the piece they came off. An earlier pass at half this size
     read as dust on the screen rather than as broken king.
     Throws top out at 34vmin, about 1.5x the king's own height, which keeps the spray
     in the neighbourhood of the impact; at 43vmin the outliers landed far enough away
     to look like unrelated specks. */
  .sh1 { --dx: -18vmin; --dy:  -6vmin; --r: -130deg; --w: 3.0vmin; --d: .00s; }
  .sh2 { --dx: -10vmin; --dy:   7vmin; --r:   95deg; --w: 2.1vmin; --d: .03s; }
  .sh3 { --dx:  22vmin; --dy: -10vmin; --r:  205deg; --w: 3.5vmin; --d: .01s; }
  .sh4 { --dx:  14vmin; --dy:   9vmin; --r: -155deg; --w: 2.5vmin; --d: .04s; }
  .sh5 { --dx:  28vmin; --dy:  -2vmin; --r:  265deg; --w: 1.9vmin; --d: .02s; }
  .sh6 { --dx: -25vmin; --dy:   3vmin; --r: -210deg; --w: 2.6vmin; --d: .05s; }
  .sh7 { --dx:   6vmin; --dy: -15vmin; --r:  145deg; --w: 3.8vmin; --d: .00s; }
  .sh8 { --dx:  -5vmin; --dy:  13vmin; --r: -115deg; --w: 3.2vmin; --d: .03s; }
  .sh9 { --dx:  34vmin; --dy:   6vmin; --r:  300deg; --w: 1.8vmin; --d: .04s; }
  .sh10{ --dx: -30vmin; --dy:  -4vmin; --r: -265deg; --w: 2.3vmin; --d: .02s; }
  .sh11{ --dx:  10vmin; --dy:  17vmin; --r:  175deg; --w: 2.8vmin; --d: .05s; }
  .sh12{ --dx: -14vmin; --dy: -13vmin; --r: -165deg; --w: 3.3vmin; --d: .01s; }
  .sh13{ --dx:  24vmin; --dy:  14vmin; --r:  225deg; --w: 2.1vmin; --d: .03s; }
  .sh14{ --dx: -22vmin; --dy:  10vmin; --r: -235deg; --w: 2.6vmin; --d: .02s; }
  /* Shrinking on the way out is what buys depth without a second element to animate:
     a chip that recedes reads as thrown, one that holds its size reads as slid. */
  @keyframes introShard{
    0%  { opacity: 0; transform: translate(-50%, -50%) rotate(0deg) scale(.85); }
    16% { opacity: 1; }
    70% { opacity: 1; }
    100%{ opacity: 0;
          transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy)))
                     rotate(var(--r)) scale(.45); }
  }

  /* ---------- THE SET ----------
     Not a curtain going up: the pieces sitting down. Every glyph is ALREADY on
     its final square when this starts — render() has run, the handlers are
     bound, legalCache is populated, and the glyphs are pointer-events:none — and
     nothing here touches opacity, so reading and clicking the board are delayed
     by exactly zero. All that is staggered is landTap, the 220ms settle a real
     move already gets, on the 70ms beat applyUnlocks uses for a reveal cascade.
     buildBoard appends in visual order, so children 1-8 are rank 8: black's back
     rank settles first and YOURS last, a beat before you are asked to move. Only
     the four ranks that hold pieces get a beat; spending one on each empty middle
     rank would buy two dead beats in the middle of the ripple.
     NO FILL-MODE, for the reason the tray chips document: a filled animation
     outranks the cascade AND inline styles, and slide() moves a piece by writing
     el.style.transform while settleGlyph clears transition and transform but
     never animation — a filled landTap would silently swallow the FLIP.
     :not(.landed) matters too: this is (1,4,0) and would otherwise swallow the
     land tap of a real move played inside the window, which a returning visitor
     can do because they get no cover. */
  #board.setting .sq .pc:not(.landed)                 { animation: landTap .22s ease-out; }
  #board.setting .sq:nth-child(n+9)  .pc:not(.landed) { animation-delay:  70ms; }  /* rank 7 */
  #board.setting .sq:nth-child(n+49) .pc:not(.landed) { animation-delay: 140ms; }  /* rank 2 */
  #board.setting .sq:nth-child(n+57) .pc:not(.landed) { animation-delay: 210ms; }  /* rank 1 */

  .status-line{
    margin: 0; min-width: 0;
    font-family: var(--mono); font-size: 12px; color: var(--walnut);
    display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
  }
  .status-line b{ font-weight: 600; color: var(--ink); }
  .status-line .chk{ color: var(--red); font-weight: 600; }
  .btn-row{ margin: 0; display: flex; flex-wrap: wrap; gap: 8px; }
  .gbtn{
    font-family: var(--mono); font-size: 11.5px; letter-spacing: .04em;
    background: var(--paper); color: var(--walnut);
    border: 1px solid var(--walnut-3);
    padding: 6px 12px;
  }
  /* colour only; the shadow, lift and press come from the shared button rule.
     The old press set `box-shadow: none`, which cannot interpolate — so it
     vanished instantly instead of collapsing with the movement. */
  .gbtn:hover{ color: var(--red); border-color: var(--red); }
  /* Not opacity: fading the whole button dims its border and its offset shadow
     too, which reads as "smudged" rather than "unavailable". Drop the shadow so
     it sits flat on the page — the physical cue is the point. */
  .gbtn:disabled{
    color: var(--walnut-2); border-color: var(--paper-3);
    --shadow: 0 0 0 rgba(36,26,15,0);
    cursor: default;
  }
  .gbtn:disabled:hover{                 /* (0,3,0), so it outranks .gbtn:hover */
    color: var(--walnut-2); border-color: var(--paper-3);
    transform: none;
  }

  /* ---------- move list ---------- */
  .sheet-cols{
    display: grid; grid-template-columns: 44px 1fr 1fr;
    font-family: var(--mono); font-size: 9px; letter-spacing: .18em; text-transform: uppercase;
    color: var(--walnut-2);
    padding: 6px 12px 4px;
    border-bottom: 1px solid var(--walnut-3);
  }
  #movelist{
    flex: 1; min-height: 96px; max-height: 100%; overflow-y: auto;
    padding: 4px 12px 10px;
  }
  .mrow{
    display: grid; grid-template-columns: 44px 1fr 1fr;
    font-family: var(--mono); font-size: 13px; font-weight: 500;
    padding: 3px 0;
    border-bottom: 1px dotted var(--paper-3);
  }
  .mrow .mno{ color: var(--walnut-2); font-size: 11.5px; }
  .mrow .mw{ color: var(--ink); }
  .mrow .mb{ color: var(--walnut-2); }
  .mrow.empty{ display: block; font-style: italic; font-family: var(--serif); color: var(--walnut-2); font-size: 13.5px; padding: 8px 0; }
  @media (max-width: 880px){ #movelist{ max-height: 200px; } }

  /* ---------- sealed / unlockable cards ---------- */
  .ucard .u-front{ display: none; }
  .ucard.open .u-front{ display: block; animation: flipIn .45s ease both; }
  .ucard.open .u-back{ display: none; }
  /* No perspective(): a projected 3D rotation reports a bounding box WIDER than
     the card (it foreshortens toward the viewer), which pushed horizontal scroll
     onto narrow screens whenever a card sat mid-flip. Orthographic rotateX only
     squashes vertically, so the box can never grow sideways. */
  @keyframes flipIn{
    from{ transform: rotateX(-64deg); opacity: 0; }
    to{ transform: none; opacity: 1; }
  }
  /* Resealing used to happen in a single frame: every card simply lost .open and
     vanished. This is flipIn run backwards, so putting the seals back reads as
     the opposite of taking them off rather than as the page blinking. app.js
     staggers it and strips both classes when the beat is done. */
  .ucard.sealing .u-front{ display: block; animation: flipOut .3s ease both; }
  @keyframes flipOut{
    from{ transform: none; opacity: 1; }
    to{ transform: rotateX(-64deg); opacity: 0; }
  }
  .u-back{
    min-height: 118px; height: 100%;
    display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
    text-align: center;
    background:
      repeating-linear-gradient(-45deg, rgba(var(--hatch),.05) 0 10px, transparent 10px 20px),
      var(--paper-2);
    padding: 16px 12px;
  }
  /* Award tiles are a column flex container with a fixed shared height, and a
     flex item's `height: 100%` cannot resolve against that indefinite height —
     so the sealed face stopped at its 118px min-height and its hatched
     background filled only a band across the middle of the card. Growing it
     fills the tile properly; the face centres its own content regardless. */
  .cap .u-back{ flex: 1; height: auto; }
  .u-back .lock{ font-size: 21px; line-height: 1; filter: grayscale(1) contrast(1.15) opacity(.75); }
  .u-back .sealed-lbl{
    font-family: var(--mono); font-size: 9.5px; font-weight: 600;
    letter-spacing: .26em; text-transform: uppercase; color: var(--walnut-2);
  }
  .u-back .need{
    font-family: var(--serif); font-style: italic; font-size: 13px; color: var(--walnut-2);
  }
  .u-back .need .bgx{ font-family: var(--pieces); font-style: normal; font-size: 15px; color: var(--walnut-2); }

  /* wins grid */
  .captures{
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
    /* align-items:start is the important bit. Grid items stretch to their row by
       default, so the oversized flagged card dragged every tile sharing its row
       up to 195px while the next row sat at 140px. Starting them instead lets
       every tile take the one shared height below, so ALL award cards are the
       same height and the newest win differs only by being twice as wide. */
    align-items: start;
    /* one height for every award tile, flagged included. Must clear the flagged
       card's natural content (~195px: bigger glyph, ribbon, larger event text)
       or that one alone grows past the rest. */
    --cap-h: 196px;
    gap: 14px;
  }
  .cap{
    border: 1px dashed var(--walnut-3);
    background: rgba(var(--linen),.5);
    position: relative;
    overflow: hidden;
    min-height: var(--cap-h, 196px);
    /* the shared height leaves slack in the shorter tiles, so centre whichever
       face is showing instead of letting it ride at the top */
    display: flex; flex-direction: column; justify-content: center;
  }
  .cap.open{ border-style: solid; }
  .cap .u-front{ padding: 14px 12px 12px; text-align: center; }
  .cap .pn{ position: absolute; top: 6px; left: 8px; font-family: var(--mono); font-size: 9px; color: var(--walnut-2); }
  /* one glyph size for every award card, matching the flagged one */
  .cap .pg{ font-family: var(--pieces); font-size: 64px; line-height: 1.1; display: block; color: var(--ink); }
  .cap .tier{
    display: block; margin-top: 6px;
    font-family: var(--mono); font-size: 10px; font-weight: 600;
    letter-spacing: .12em; text-transform: uppercase; color: var(--red);
  }
  .cap .ev{
    display: block; margin-top: 3px;
    font-family: var(--serif); font-style: italic; font-size: 13.5px;
    color: var(--walnut); line-height: 1.3;
  }
  .cap-flag{ grid-column: span 2; }
  .cap-flag.open{
    border: 1px solid var(--red);
    outline: 3px double var(--red); outline-offset: 3px;
    background: rgba(164,33,27,.05);
  }
  .cap-flag .u-front{ padding: 20px 14px 16px; }
  .cap-flag .pg{ color: var(--red); }
  .cap-flag .tier{ font-size: 12px; }
  .cap-flag .ev{ font-size: 16.5px; color: var(--ink); }
  .cap-flag .ribbon{
    display: inline-block;
    font-family: var(--mono); font-size: 9.5px; font-weight: 600;
    letter-spacing: .18em; text-transform: uppercase;
    color: var(--paper); background: var(--red);
    padding: 3px 10px; margin-bottom: 10px;
  }
  @media (max-width: 420px){
    .captures{ grid-template-columns: 1fr 1fr; }
    .cap-flag{ grid-column: 1 / -1; }
  }

  /* project cards */
  .dgrid{
    display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 20px;
  }
  .dcard{
    border: 1px dashed var(--walnut-3);
    background: rgba(var(--linen),.55);
    display: flex; flex-direction: column;
  }
  /* A TALLER SEALED FACE, to cut how far the grid has to travel. The shared .u-back is
     118px, which against an open project card of ~500px meant the section quadrupled in
     length on reveal — smooth or not, that is a lot of page to move. 250px roughly halves
     the distance while adding only ~500px to the sealed page (four rows of three, not the
     ~6000px that matching the open height outright would have cost).
     It also reads better sealed: a proper hatched dossier rather than a thin strip. */
  .dcard .u-back{ min-height: 250px; }
  .dcard.open{
    border: 1px solid var(--walnut);
    box-shadow: 3px 3px 0 rgba(var(--shade),.20);
  }
  /* the frame used to snap: nothing on .ucard transitioned, so only the child
     cue animated. Keyboard focus gets the colour and shadow but NOT the lift —
     a card that jumps when you Tab to it is worse than one that doesn't. */
  .dcard.open:hover{
    border-color: var(--red);
    box-shadow: 4px 4px 0 rgba(var(--shade),.26);
    transform: translate(-1px,-1px);
  }
  .dcard.open:focus-visible{
    border-color: var(--red);
    box-shadow: 4px 4px 0 rgba(var(--shade),.26);
  }
  .dcard.open:active{
    transform: translate(2px,2px);
    box-shadow: 0 0 0 rgba(36,26,15,0);
  }
  /* .dcard.open, not .dcard: an unscoped `display:flex` here outranked
     `.ucard .u-front{display:none}` (same specificity, declared later), so
     project cards showed their title, blurb and stack while still "sealed". */
  .dcard.open .u-front{ padding: 0; display: flex; flex-direction: column; flex: 1; }
  .dbody{ padding: 14px 16px 16px; display: flex; flex-direction: column; flex: 1; }

  /* project screenshot */
  .dshot{
    border-bottom: 1px solid var(--walnut);
    background: var(--paper-2);
    aspect-ratio: 16 / 10;
    overflow: hidden;
  }
  .dshot img{
    width: 100%; height: 100%;
    object-fit: cover; display: block;
    filter: saturate(.92) contrast(1.02);
    transition: transform .5s cubic-bezier(.2,.8,.3,1);
  }
  .dcard.open:hover .dshot img{ transform: scale(1.035); }

  /* the card is the door; the links live inside the case sheet */
  .dcard.open{ cursor: pointer; }
  .dopen{
    margin-top: auto; padding-top: 12px;
    border-top: 1px dotted var(--walnut-3);
    font-family: var(--mono); font-size: 10px; font-weight: 600;
    letter-spacing: .12em; text-transform: uppercase;
    color: var(--walnut-2);
    transition: color .16s ease;
  }
  /* No letter-spacing flare: it re-wrapped the label, changed the card's height
     and shoved every sibling in the grid row — animated, so it juddered for the
     whole .16s. The underline wipe carries the same emphasis for free. */
  .dcard.open:hover .dopen,
  .dcard.open:focus-visible .dopen{ color: var(--red); }

  /* ---------- project case sheet ---------- */
  .case{
    position: fixed; inset: 0; z-index: 130;
    display: flex; align-items: center; justify-content: center;
    padding: 22px 16px;
    background: rgba(36,26,15,.55);
    /* the card owns its own overflow now, so this layer never scrolls */
    overflow: hidden;
    animation: caseScrim .24s ease both;
  }
  .case[hidden]{ display: none; }
  .case.closing{ animation: caseScrimOut .22s ease both; }
  @keyframes caseScrim{ from{ background: rgba(36,26,15,0); } to{ background: rgba(36,26,15,.55); } }
  @keyframes caseScrimOut{ from{ background: rgba(36,26,15,.55); } to{ background: rgba(36,26,15,0); } }
  /* Two columns from 760px up: the shot beside the text rather than above it,
     so the sheet's height is max(image, text) instead of their sum. Stacked it
     ran ~856px, which spills on any laptop shorter than ~900px. */
  .case-card{
    position: relative;
    width: min(940px, 100%);
    max-height: calc(100svh - 44px);
    margin: auto;
    display: grid;
    grid-template-columns: minmax(0, 300px) minmax(0, 1fr);
    background: rgba(var(--halo),.99);
    border: 1px solid var(--walnut);
    outline: 3px double var(--walnut); outline-offset: 4px;
    box-shadow: 0 26px 64px -20px rgba(36,26,15,.6);
    animation: caseIn .32s cubic-bezier(.18,.9,.28,1) both;
    overflow: hidden;
  }
  /* no screenshot for this project — let the text use the full width */
  .case-card:has(#case-shot:empty){ grid-template-columns: minmax(0, 1fr); }
  .case.closing .case-card{ animation: caseOut .22s cubic-bezier(.4,.03,.7,.2) both; }
  @keyframes caseIn{
    from{ opacity: 0; transform: translateY(16px) scale(.975); }
    to{ opacity: 1; transform: none; }
  }
  @keyframes caseOut{
    from{ opacity: 1; transform: none; }
    to{ opacity: 0; transform: translateY(-12px) scale(.98); }
  }
  .case-x{
    position: absolute; top: 8px; right: 10px; z-index: 2;
    font-family: var(--mono); font-size: 22px; line-height: 1;
    width: 32px; height: 32px;
    color: var(--walnut); background: rgba(var(--halo),.92);
    border: 1px solid transparent;
  }
  .case-x{ transition: color .18s ease, border-color .18s ease, background .18s ease; }
  .case-x:hover{ color: var(--red); border-color: var(--red); }
  .case-x:active{ color: var(--paper); background: var(--red); border-color: var(--red); }
  /* below 760px the screenshot restacks under this button, so an outward ink
     ring would land on a photograph. Inset it, as .sq already does. */
  .case-x:focus-visible{ outline-offset: -3px; }
  .case-shot{
    border-right: 1px solid var(--walnut);
    background: var(--paper-2);
    overflow: hidden;
    min-height: 0;
  }
  .case-shot img{ width: 100%; height: 100%; object-fit: cover; display: block; }
  .case-shot:empty{ display: none; }
  /* the only thing allowed to scroll, and only if the text is very long */
  .case-body{
    padding: clamp(16px, 2.4vw, 26px) clamp(18px, 2.6vw, 30px) clamp(18px, 2.4vw, 24px);
    overflow-y: auto;
    min-height: 0;
    scrollbar-width: thin;
  }
  .case-head{
    display: flex; justify-content: space-between; align-items: baseline; gap: 12px;
    font-family: var(--mono); font-size: 10px; letter-spacing: .16em;
    text-transform: uppercase; color: var(--walnut-2);
    /* keep the year clear of the close button, which is absolutely positioned
       over this corner. Only this row is inset — body text keeps full width. */
    padding-right: 34px;
    padding-bottom: 8px; border-bottom: 1px solid var(--walnut-3);
  }
  .case-card h3{
    font-family: var(--serif); font-weight: 700; font-size: clamp(1.5rem, 3.4vw, 2rem);
    line-height: 1.15; margin-top: 12px;
  }
  .case-hl{
    margin-top: 6px;
    font-family: var(--mono); font-size: 10.5px; font-weight: 600;
    letter-spacing: .12em; text-transform: uppercase; color: var(--red);
  }
  .case-hl.quiet{ color: var(--walnut-2); font-weight: 500; }
  .case-hl[hidden]{ display: none; }
  .case-clause{ margin-top: 15px; }
  .case-clause .cc-lbl{
    display: block; margin-bottom: 5px;
    font-family: var(--mono); font-size: 9.5px; font-weight: 600;
    letter-spacing: .2em; text-transform: uppercase; color: var(--ink);
  }
  .case-clause .cc-lbl i{ font-style: normal; color: var(--walnut-2); margin-right: 9px; }
  .case-clause p{ font-size: 15px; line-height: 1.55; color: var(--walnut); max-width: 68ch; }
  .case-tags{
    display: flex; flex-wrap: wrap; gap: 7px;
    margin-top: 16px;
  }
  .case-tags span{
    font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em;
    color: var(--walnut-2); border: 1px solid var(--walnut-3);
    padding: 4px 8px 3px;
  }
  .case-links{
    display: flex; flex-wrap: wrap; gap: 8px;
    margin-top: 16px; padding-top: 14px;
    border-top: 1px solid var(--walnut-3);
  }
  /* Narrow screens: stack, with the shot capped so the text still gets room.
     Below ~760px there is no width to put them side by side. */
  @media (max-width: 760px){
    .case-card, .case-card:has(#case-shot:empty){
      grid-template-columns: minmax(0, 1fr);
      grid-template-rows: auto minmax(0, 1fr);
      max-height: calc(100svh - 32px);
    }
    .case{ padding: 16px 12px; }
    .case-shot{
      border-right: 0; border-bottom: 1px solid var(--walnut);
      max-height: 22svh;
    }
  }
  /* phones: buy back what the stacked shot costs so most sheets still fit
     whole. A few of the longest write-ups will scroll inside the card — the
     page behind stays locked either way. */
  @media (max-width: 560px){
    .case-shot{ max-height: 15svh; }
    .case-clause{ margin-top: 12px; }
    .case-clause p{ font-size: 14px; line-height: 1.5; }
    .case-card h3{ margin-top: 9px; }
    .case-tags{ margin-top: 12px; }
    .case-links{ margin-top: 12px; padding-top: 11px; }
  }
  .case-links a{
    font-family: var(--mono); font-size: 11px; font-weight: 600;
    letter-spacing: .12em; text-transform: uppercase;
    text-decoration: none; color: var(--paper); background: var(--ink);
    border: 1px solid var(--ink);
    padding: 8px 13px 7px;
  }
  .case-links a:hover{ background: var(--red); border-color: var(--red); }
  .case-nolinks{
    font-family: var(--mono); font-size: 10.5px; font-style: italic;
    color: var(--walnut-2);
  }
  .dmove{
    display: flex; align-items: baseline; gap: 8px;
    border-bottom: 1px dotted var(--walnut-3); padding-bottom: 7px;
  }
  .dmove .yr{ margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--walnut-2); }
  .dcard h3{
    font-family: var(--serif); font-weight: 700; font-size: 1.3rem;
    margin: 10px 0 6px; line-height: 1.15;
  }
  .dcard .blurb{ font-size: 14.5px; color: var(--walnut); flex: 1; }
  .dhl{
    margin-top: 10px;
    font-family: var(--mono); font-size: 10.5px; font-weight: 600;
    letter-spacing: .05em; color: var(--red);
  }
  .dhl::before{ content: "‡ "; }
  .dhl.quiet{ color: var(--walnut-2); }
  /* margin-bottom sets a FLOOR under the tags. The cue below them is pushed to
     the bottom of the card by `margin-top: auto`, so cards with slack already
     had a gap — but on the tallest card in a row the auto margin resolves to
     zero and the dotted rule sat right on top of the tags. 12px matches the
     cue's own padding-top, so the rule now sits centred in its own space. */
  .dtags{
    margin-top: 8px; margin-bottom: 12px;
    display: flex; flex-wrap: wrap; gap: 5px;
  }
  .dtags span{
    font-family: var(--mono); font-size: 9.5px; letter-spacing: .04em;
    color: var(--walnut-2); border: 1px solid var(--walnut-3);
    padding: 1px 6px 2px;
  }

  /* ---------- experience ----------
     The two-column résumé shape from the old day/night site — dates and place
     in a left rail, role plus what it actually involved on the right — redrawn
     in this theme's paper and ink instead of glass. */
  ol.moves{ list-style: none; display: flex; flex-direction: column; gap: 14px; }
  .move{
    border: 1px dashed var(--walnut-3);
    background: rgba(var(--linen),.5);
  }
  .move.open{
    border: 1px solid var(--walnut);
    background: rgba(var(--linen),.62);
    box-shadow: 3px 3px 0 rgba(var(--shade),.16);
  }
  .move .u-front{ padding: 0; }
  /* sealed: the hatched face is the whole card, edge to edge — shorter than the
     default 118px so six sealed rows don't run down the page */
  .move .u-back{ min-height: 92px; height: auto; }
  .jrow{
    display: grid; grid-template-columns: 178px minmax(0, 1fr);
    gap: 24px;
    padding: 20px 22px 20px 20px;
  }
  .jmeta{
    display: flex; flex-direction: column; align-items: flex-start; gap: 7px;
    padding-right: 18px;
    border-right: 1px dotted var(--walnut-3);
  }
  .jperiod{
    font-family: var(--mono); font-size: 11px; font-weight: 500;
    letter-spacing: .04em; color: var(--walnut);
  }
  .jloc{
    font-family: var(--mono); font-size: 10px; letter-spacing: .06em;
    color: var(--walnut-2); line-height: 1.5;
  }
  /* the "current role" marker: a pulsing pip, as on the old site */
  .jnow{
    display: inline-flex; align-items: center; gap: 6px;
    font-family: var(--mono); font-size: 9.5px; font-weight: 600;
    letter-spacing: .16em; text-transform: uppercase; color: var(--red);
  }
  .jnow::before{
    content: ""; width: 7px; height: 7px; flex: none;
    background: var(--red);
    box-shadow: 0 0 0 0 rgba(164,33,27,.55);
    animation: nowPulse 2.2s ease-out infinite;
  }
  @keyframes nowPulse{
    0%{ box-shadow: 0 0 0 0 rgba(164,33,27,.5); }
    70%{ box-shadow: 0 0 0 7px rgba(164,33,27,0); }
    100%{ box-shadow: 0 0 0 0 rgba(164,33,27,0); }
  }
  .jrole{
    font-family: var(--serif); font-weight: 700; font-size: 1.22rem; line-height: 1.2;
  }
  .jorg{
    margin-top: 2px;
    font-family: var(--mono); font-size: 11px; font-weight: 600;
    letter-spacing: .14em; text-transform: uppercase; color: var(--red);
  }
  .jbullets{
    list-style: none; margin-top: 11px;
    display: flex; flex-direction: column; gap: 7px;
  }
  .jbullets li{
    position: relative; padding-left: 17px;
    font-size: 14.5px; line-height: 1.55; color: var(--walnut);
    max-width: 92ch;
  }
  .jbullets li::before{
    content: "\25B9"; position: absolute; left: 0; top: -1px;
    color: var(--red); font-size: 13px;
  }
  @media (max-width: 700px){
    .jrow{ grid-template-columns: minmax(0, 1fr); gap: 12px; padding: 16px 16px 18px; }
    .jmeta{
      flex-direction: row; align-items: center; flex-wrap: wrap; gap: 6px 12px;
      padding-right: 0; padding-bottom: 11px;
      border-right: 0; border-bottom: 1px dotted var(--walnut-3);
    }
  }

  /* resume + contact cards */
  .adjourn{
    max-width: 680px; margin: 0 auto;
    border: 1px dashed var(--walnut-3);
    background: rgba(var(--linen),.6);
  }
  .adjourn.open{
    border: 1px solid var(--walnut);
    outline: 3px double var(--walnut); outline-offset: 4px;
  }
  .adjourn .u-front{ padding: 34px 24px 30px; text-align: center; }
  .adjourn .u-back{ min-height: 180px; }
  .seal{
    width: 62px; height: 62px; margin: 0 auto 14px;
    border-radius: 50%;
    background: radial-gradient(circle at 35% 30%, #c33b31, var(--red) 60%, #7d1712);
    color: var(--paper);
    display: flex; align-items: center; justify-content: center;
    font-family: var(--serif); font-weight: 900; font-size: 22px;
    box-shadow: 0 3px 8px rgba(36,26,15,.35);
  }
  .adjourn .sealed{
    font-family: var(--mono); font-size: 10px; font-weight: 600;
    letter-spacing: .3em; text-transform: uppercase; color: var(--walnut-2);
  }
  .adjourn p.note{
    font-style: italic; color: var(--walnut); margin: 10px auto 16px;
    max-width: 46ch; font-size: 15.5px;
  }
  /* ---------- the file: contact actions ----------
     Three tiers of the same shape rather than a list: filled résumé button,
     outlined email button the same width beneath it, then the socials as a row
     of three. Same outlined treatment the project sheets use, so it reads as
     part of the set instead of a form. */
  .file-mail{
    display: flex; align-items: center; justify-content: center; gap: 10px;
    width: min(520px, 100%);
    margin: 12px auto 0;
    font-family: var(--mono); font-size: 13px; font-weight: 500;
    letter-spacing: .06em;
    color: var(--ink); text-decoration: none;
    background: rgba(var(--linen),.7);
    border: 1px solid var(--walnut);
    padding: 12px 16px 11px;
    overflow-wrap: anywhere;
    transition: color .16s ease, background .16s ease, border-color .16s ease;
  }
  .file-mail .fm-ico{ font-size: 15px; line-height: 1; flex: none; }
  .file-mail:hover{ color: var(--paper); background: var(--walnut); border-color: var(--walnut); }
  .file-social{
    display: flex; justify-content: center; flex-wrap: wrap; gap: 8px;
    width: min(520px, 100%);
    margin: 8px auto 0;
  }
  .file-social a{
    flex: 1 1 0; min-width: 118px;
    /* inline-flex to sit the mark beside the label; the row was already centred
       by an inherited text-align, and centring here keeps it that way */
    display: inline-flex; align-items: center; justify-content: center; gap: 7px;
    font-family: var(--mono); font-size: 10.5px; font-weight: 600;
    letter-spacing: .12em; text-transform: uppercase;
    color: var(--walnut); text-decoration: none;
    border: 1px solid var(--walnut-3);
    padding: 9px 10px 8px;
    transition: color .16s ease, background .16s ease, border-color .16s ease;
  }
  /* The letter-spacing above adds a trailing gap after the last character, so the
     mark looks shifted left of centre unless that space is paid back. */
  .file-social a .si{
    width: 14px; height: 14px; flex: none;
    margin-left: .12em;
  }
  .file-social a:hover{
    color: var(--paper); background: var(--red); border-color: var(--red);
  }
  @media (max-width: 520px){
    .file-mail{ font-size: 11.5px; padding: 11px 12px 10px; }
    .file-social a{ flex: 1 1 100%; }
  }
  /* ---------- reveal card overlay ---------- */
  .reveal{
    position: fixed; right: 16px; bottom: 16px; z-index: 80;
    width: min(360px, calc(100vw - 32px));
    transform: translateX(130%);
    transition: transform .45s cubic-bezier(.2,.9,.25,1.05);
    pointer-events: none;
  }
  .reveal.show{ transform: none; pointer-events: auto; }
  .reveal-card{
    background: rgba(var(--halo),.98);
    border: 1px solid var(--walnut);
    /* The frame is drawn with box-shadow further down, not with outline. It used
       to be an outline — the same property the focus ring uses — so focusing
       this card (role=button, tabindex=0) erased its own frame to draw the
       ring. #board keeps its outline: nothing can focus it. */
    outline: none;
    box-shadow: 0 18px 40px -14px rgba(36,26,15,.5);
    padding: 16px 18px 14px;
    cursor: pointer;
    position: relative;
  }
  .reveal.fd .reveal-card{ border-color: var(--red); }
  .rv-stamp{
    position: absolute; top: -12px; right: 12px;
    font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: .2em;
    color: var(--red); border: 2px solid var(--red);
    background: var(--paper);
    padding: 2px 8px; transform: rotate(-6deg);
  }
  .rv-kicker{
    font-family: var(--mono); font-size: 10px; font-weight: 600;
    letter-spacing: .18em; text-transform: uppercase; color: var(--walnut-2);
    border-bottom: 1px solid var(--walnut-3); padding-bottom: 7px; margin-bottom: 9px;
    padding-right: 70px;
  }
  .rv-item{
    font-family: var(--serif); font-size: 15px; color: var(--walnut);
    padding: 4px 0; line-height: 1.35;
  }
  .rv-item b{ color: var(--ink); }
  .rv-item::before{ content: "‡ "; color: var(--red); }
  .rv-hint{
    margin-top: 8px; font-family: var(--mono); font-size: 9.5px;
    letter-spacing: .08em; color: var(--walnut-2);
  }

  /* ---------- game-over modal ---------- */
  .modal{
    position: fixed; inset: 0; z-index: 90;
    display: none; align-items: center; justify-content: center;
    background: rgba(36,26,15,.38);
    padding: 20px;
  }
  .modal.show{ display: flex; animation: modalScrim .26s ease both; }
  .modal.closing{ animation: modalScrimOut .22s ease both; }
  @keyframes modalScrim{ from{ background: rgba(36,26,15,0); } to{ background: rgba(36,26,15,.38); } }
  @keyframes modalScrimOut{ from{ background: rgba(36,26,15,.38); } to{ background: rgba(36,26,15,0); } }
  .modal-card{
    width: min(420px, 100%);
    background: var(--paper);
    border: 1px solid var(--walnut);
    outline: 3px double var(--walnut); outline-offset: 4px;
    box-shadow: 0 24px 60px -20px rgba(36,26,15,.6);
    padding: 26px 24px 22px;
    text-align: center;
  }
  /* the result card is dealt onto the table */
  .modal.show .modal-card{ animation: modalCardIn .34s cubic-bezier(.18,.9,.28,1) both; }
  .modal.closing .modal-card{ animation: modalCardOut .22s cubic-bezier(.4,.03,.7,.2) both; }
  @keyframes modalCardIn{
    from{ opacity: 0; transform: translateY(18px) scale(.94); }
    to{ opacity: 1; transform: none; }
  }
  @keyframes modalCardOut{
    from{ opacity: 1; transform: none; }
    to{ opacity: 0; transform: translateY(-12px) scale(.97); }
  }
  /* a win is stamped, not dealt — that single timeline is `winCard`, defined up
     with the execution stage so the impact beat stays in step with the slash */
  /* the verdict lands after the strike, not during it — the cut is the beat */
  .modal.show.is-win .mres{ animation: resIn .5s ease .95s both; }
  .modal.show.is-win h3{ animation: resIn .5s ease 1.05s both; }
  .modal.show.is-win p{ animation: resIn .5s ease 1.15s both; }
  .modal.show.is-win .modal-btns{ animation: resIn .5s ease 1.25s both; }
  @keyframes resIn{
    from{ opacity: 0; transform: translateY(6px); }
    to{ opacity: 1; transform: none; }
  }
  .modal-card h3{
    font-family: var(--serif); font-weight: 900; font-style: italic;
    font-size: 1.7rem; line-height: 1.1;
  }
  .modal-card .mres{
    font-family: var(--mono); font-size: 11px; font-weight: 600;
    letter-spacing: .2em; text-transform: uppercase; color: var(--red);
    margin-bottom: 8px; display: block;
  }
  .modal-card p{ font-style: italic; font-size: 14.5px; color: var(--walnut); margin: 8px 0 16px; }
  .modal-btns{ display: flex; justify-content: center; gap: 10px; flex-wrap: wrap; }

  /* ---------- annotation toast ---------- */
  .toast{
    position: fixed; left: 16px; bottom: 16px; z-index: 70;
    font-family: var(--mono); font-size: 11px; font-weight: 500;
    color: var(--red);
    background: rgba(var(--halo),.96);
    border: 1px solid var(--red);
    box-shadow: 2px 2px 0 rgba(164,33,27,.25);
    padding: 7px 12px;
    opacity: 0; transform: translateY(8px);
    transition: opacity .3s, transform .3s;
    pointer-events: none;
    max-width: 70vw;
  }
  .toast.show{ opacity: 1; transform: none; }

  /* ---------- colophon ---------- */
  footer{
    border-top: 3px double var(--walnut);
    margin-top: 70px;
    padding: 18px 20px 26px;
    text-align: center;
    font-family: var(--mono); font-size: 10.5px; color: var(--walnut-2);
  }
  footer .r{ color: var(--red); }
  footer a{ color: var(--walnut-2); }
  footer a:hover{ color: var(--red); }

  /* ==========================================================================
     INTERACTION
     One feedback language for the whole page, defined once here and shared. It
     sits directly before the reduced-motion block on purpose: every component
     rule is earlier, so these win their ties, and the opt-outs are later, so
     they win theirs.
     ========================================================================== */

  /* ---------- the solid-button idiom ----------
     The component owns its COLOUR states; this owns the physical layer — a hard
     offset shadow, a lift toward the cursor, and a press flat into the page.
     --shadow is the same variable the navbar buttons use, so the narrow-screen
     and reduced-motion levers are identical for all of them.
     .nav-btn / .nav-unlock-btn are deliberately absent: they already have it. */
  .cover-play, .gbtn, .case-links a, .file-mail, .file-social a,
  .nojs-links a, .resume-cta, .skip{
    --shadow: 2px 2px 0 rgba(var(--shade),.20);
    box-shadow: var(--shadow);
    transition: color .18s ease, background .18s ease, border-color .18s ease,
                transform .14s cubic-bezier(.2,.7,.2,1), box-shadow .18s ease;
  }
  /* no transform at rest — a permanent translate(0,0) rasterises the element on
     its own layer and softens small mono type. An absent transform still
     interpolates as translate(0,0), so the movement animates from nothing. */
  .cover-play:hover, .gbtn:hover, .case-links a:hover, .file-mail:hover,
  .file-social a:hover, .nojs-links a:hover, .resume-cta:hover{
    transform: translate(-1px,-1px);
    --shadow: 3px 3px 0 rgba(var(--shade),.26);
  }
  /* never `box-shadow: none` here: none cannot interpolate, so the shadow would
     vanish in one frame instead of collapsing with the press */
  .cover-play:active, .gbtn:active, .case-links a:active, .file-mail:active,
  .file-social a:active, .nojs-links a:active, .resume-cta:active, .skip:active{
    transform: translate(2px,2px);
    --shadow: 0 0 0 rgba(36,26,15,0);
  }
  /* the résumé CTA is a 520px slab; a 2px offset reads as a hairline under it */
  .resume-cta{ --shadow: 4px 4px 0 rgba(var(--shade),.22); }
  .resume-cta:hover{ --shadow: 5px 5px 0 rgba(var(--shade),.28); }
  .resume-cta .rc-go{ transition: transform .3s cubic-bezier(.2,.7,.2,1); }
  .resume-cta:hover .rc-go{ transform: translate(2px,-2px); }
  .nojs-links a:hover{ border-color: var(--walnut); }

  /* ---------- the text-link wipe ----------
     Same gesture as the nav index links: the label darkens and a red rule wipes
     in underneath, left to right. Geometry differs by one value, so it is a
     token. .dopen fires from its CARD, not from itself. */
  .topnav .brand, footer a, .dopen{
    position: relative;
    transition: color .2s ease;
  }
  .topnav .brand::after, footer a::after, .dopen::after{
    content: "";
    position: absolute; left: 0; right: 0; bottom: var(--wipe-bottom, 0);
    height: 2px; background: var(--red);
    transform: scaleX(0); transform-origin: left center;
    transition: transform .26s cubic-bezier(.2,.7,.2,1);
  }
  .topnav .brand:hover::after, .topnav .brand:focus-visible::after,
  footer a:hover::after, footer a:focus-visible::after,
  .dcard.open:hover .dopen::after,
  .dcard.open:focus-visible .dopen::after{ transform: scaleX(1); }
  .topnav .brand{ --wipe-bottom: 4px; }
  .topnav .brand:active{ color: var(--red); }
  .topnav .brand:active .dot{ color: var(--ink); }
  .dopen{ --wipe-bottom: 0; padding-bottom: 5px; }
  /* The footer reset link is the only anchor besides .skip that leaned on the
     UA underline, and footer prose is the same --walnut-2 — so a rule has to be
     PRESENT at rest, not only on hover, or the link is colour-only. The red
     wipes over it. --walnut-3 is legal here: this is a border, not text. */
  footer a{
    --wipe-bottom: -1px;
    display: inline-block;
    text-decoration: none;
    border-bottom: 1px solid var(--walnut-3);
  }
  footer a:hover{ color: var(--ink); }
  footer a:active{ color: var(--red); }
  .skip{ text-decoration: none; }
  /* deliberately no transition on the skip link's left/top: the reveal is a
     -9999px -> 10px jump, and animating that would fly it across the viewport */

  /* ---------- BOARD HIGHLIGHT COMPOSITION ----------
     Three always-present shadow slots, so last / sel / hint / check / hover
     compose instead of overwriting one another, and the list length never
     changes so box-shadow can interpolate.
     PAINT ORDER IS LOAD-BEARING: the first shadow paints on top, and the washes
     are 100vmax fills, so the ring must come FIRST or a checked-and-selected
     king loses the ring it is meant to wear above the wash. */
  .sq{
    --sh-ring:  0 0 0 0 rgba(164,33,27,0);
    --sh-hover: 0 0 0 0 rgba(36,26,15,0);
    --sh-wash:  0 0 0 0 rgba(164,33,27,0);
    box-shadow: inset var(--sh-ring), inset var(--sh-hover), inset var(--sh-wash);
    transition: box-shadow .18s ease;
  }
  .sq.last { --sh-wash: 0 0 0 100vmax rgba(164,33,27,.13); }
  .sq.check{
    --sh-wash: 0 0 0 100vmax rgba(164,33,27,.34);
    /* was infinite; three beats is 3.3s, under the five seconds past which an
       animation needs its own stop control. The wash stays either way. */
    animation: checkPulse 1.1s ease-in-out 3;
  }
  .sq.hint{ --sh-ring: 0 0 0 3px rgba(122,82,48,.9); }
  /* .sel AFTER .hint deliberately: both write --sh-ring at equal specificity,
     and a live selection must outrank a transient hint. Do not reorder. */
  .sq.sel{ --sh-ring: 0 0 0 2px var(--red); }
  /* the keyframe re-states the ring and hover slots by var(): a bare box-shadow
     here would erase them for 1.1s at a time, since animations outrank the
     cascade */
  @keyframes checkPulse{
    50%{ box-shadow: inset var(--sh-ring), inset var(--sh-hover),
                     inset 0 0 0 100vmax rgba(164,33,27,.18); }
  }
  /* 64 buttons that had no hover at all. This writes --sh-hover, which no state
     class touches, so it composes rather than fighting them — sidestepping the
     specificity trap instead of depending on source order. No transform: a
     translated grid cell tears a hole in the board. (hover: hover) is not
     optional — without it a phone leaves the tint stuck on the last square
     tapped. */
  @media (hover: hover) and (pointer: fine){
    .sq:hover { --sh-hover: 0 0 0 100vmax rgba(36,26,15,.07); }
    .sq:active{ --sh-hover: 0 0 0 100vmax rgba(36,26,15,.14); }
  }
  /* one crisp ring on a capture target — an inset shadow, not a border, so
     border-box cannot shrink the 84% box. This used to be declared twice, and
     the two rules merged into a doubled ~6px edge. */
  .sq.dot.take::after{
    width: 84%; border-radius: 50%;
    background: transparent;
    box-shadow: inset 0 0 0 3px rgba(164,33,27,.55);
  }
  /* the global focus ring is red, which measures ~2.2:1 against the check wash
     and is a pixel off .sel's own red ring. Ink, on those two states only. */
  .sq.check:focus-visible,
  .sq.sel:focus-visible{ outline-color: var(--ink); }

  /* ---------- sealed-card frames ----------
     .cap, .dcard, .move and .adjourn all carry .ucard, which at (0,1,0) is the
     lowest specificity in play — so every .open and :hover rule was setting
     values into an element with nothing to transition them. */
  .ucard{
    transition: box-shadow .18s ease, border-color .18s ease,
                background-color .3s ease,
                transform .14s cubic-bezier(.2,.7,.2,1);
  }
  /* border-style dashed -> solid is discrete and will always snap; the flip
     animation covers it for 450ms, which is enough. */

  /* ---------- dialogs ---------- */
  /* The scrim now fades in as well as out, matching the case sheet and modal.
     Deliberately NO fill-mode on the way in: the resting value stays the base
     rgba(36,26,15,.55) declared above, so if the animation is ever skipped the
     backdrop still dims. A `both` fill would pin it to the transparent `from`
     frame and lose the scrim altogether. The closing half keeps `forwards`,
     because there it must hold transparent until the element is hidden. */
  .cover{ animation: coverScrim .28s ease; }
  .cover.closing{ animation: coverScrimOut .28s ease forwards; }
  @keyframes coverScrim   { from{ background: rgba(36,26,15,0); }   to{ background: rgba(36,26,15,.55); } }
  @keyframes coverScrimOut{ from{ background: rgba(36,26,15,.55); } to{ background: rgba(36,26,15,0); } }
  .cover-x{ background: rgba(var(--halo),.92); }   /* card content scrolls under it */

  /* the reveal card is role=button, tabindex=0, cursor:pointer and says "click
     or Esc to dismiss" — and had no state rules at all */
  .reveal-card{
    --shadow: 4px 4px 0 rgba(var(--shade),.22);
    box-shadow: 0 0 0 3px rgba(var(--halo),.98), 0 0 0 4px var(--walnut), var(--shadow);
    transition: transform .14s cubic-bezier(.2,.7,.2,1), box-shadow .18s ease;
  }
  .reveal-card:hover { transform: translate(-1px,-1px); --shadow: 5px 5px 0 rgba(var(--shade),.28); }
  .reveal-card:active{ transform: translate(2px,2px);   --shadow: 0 0 0 rgba(36,26,15,0); }
  .reveal.fd .reveal-card{
    box-shadow: 0 0 0 3px rgba(var(--halo),.98), 0 0 0 4px var(--red), var(--shadow);
  }

  /* The win modal's buttons were invisible but still clickable and tabbable for
     the first ~1.5s — opacity 0 does not stop hit-testing. pointer-events
     animates discretely, so it flips at the halfway mark. */
  .modal.show.is-win .modal-btns{ animation: resInBtns .5s ease 1.25s both; }
  @keyframes resInBtns{
    from{ opacity: 0; transform: translateY(6px); pointer-events: none; }
    to  { opacity: 1; transform: none;            pointer-events: auto; }
  }

  /* ---------- capture-tray chips ----------
     The tray's aria-label invites you to inspect them and only the player's
     chips carry a title, so only those get an affordance. Colour and transform
     only: the tray is a nowrap strip and anything else would give it scroll. */
  .t-pc{ transition: color .16s ease, transform .16s cubic-bezier(.2,.7,.2,1); }
  #tray-player .t-pc{ cursor: help; }
  #tray-bot .t-pc{ cursor: default; }
  #tray-player .t-pc:hover{ color: var(--red); transform: translateY(-2px); }

  /* ---------- move list ----------
     Mark the live ply. :not(.empty) matters — the em-dash placeholder is also
     :last-child. No :hover: the rows have no handler, and a hover state on
     something unclickable is a lie about affordance. */
  .mrow:not(.empty):last-child{ position: relative; }
  .mrow:not(.empty):last-child .mw,
  .mrow:not(.empty):last-child .mb{ color: var(--ink); }
  .mrow:not(.empty):last-child::before{
    content: ""; position: absolute; left: -7px; top: 3px; bottom: 3px;
    width: 2px; background: var(--red);
  }

  /* the "in play" pip: capped like checkPulse, for the same reason */
  .jnow::before{ animation: nowPulse 2.2s ease-out 2; }

  /* the win slice's will-change was unconditional, so it held a raster layer —
     and the 10px mono inside the handshake caption — for as long as the modal
     was open. Scope it to the 1.55s it is actually used. */
  .gs > *{ will-change: auto; }
  .modal.show.is-win .gs-half,
  .modal.show.is-win .gs-piece,
  .modal.show.is-win .gs-slash,
  .modal.show.is-win .gs-burst{ will-change: transform, opacity; }

  @media (max-width: 560px){
    /* this sizing existed already but never applied: it sat before the base
       rule at equal specificity, and a media query adds none. */
    .case-links a{ font-size: 10px; padding: 7px 11px 6px; }
  }
  @media (max-width: 720px){
    /* scroll-margin was sized for the one-row bar; below 720px the bar is two
       rows, so every nav jump used to land the section's rule behind it */
    main section{ scroll-margin-top: 88px; }
  }

  @media (prefers-reduced-motion: reduce){
    .ucard.open .u-front{ animation: none; }
    /* the reseal keeps its stagger but not the flip; app.js also skips the
       cascade entirely when this is set, so this is the belt to that braces */
    .ucard.sealing .u-front{ animation: none; }

    /* ---- the intro ----
       Nothing here fades or travels: the curtain is simply already gone. Note the
       whole thing has to be neutralised in one rule, because with `animation:
       none` the halves would sit at their un-parted position and cover the page
       forever — the reveal IS the animation. */
    .intro{ display: none; }
    /* EXCEPT when it was asked for. The footer's replay link marks the curtain it
       inserts, and a visitor who clicks something labelled "replay intro" has chosen
       this motion — suppressing it there would just be a dead control. The preference
       is about motion nobody asked for. (0,2,0) beats the rule above, so this needs no
       !important. Still safe: app.js removes the node on its own timer either way. */
    .intro.forced{ display: block; }

    /* ---- the set ----
       app.js also refuses to add .setting when this query matches, so this is the
       belt to that braces. Both .pc forms are named because a media query adds no
       specificity and (1,3,0) never beats (1,4,0). */
    #board.setting .sq .pc,
    #board.setting .sq .pc:not(.landed){ animation: none; }
    /* freeze the stamp, don't hide it: the rule here is keep the state, drop the
       motion, and `opacity: 0` inverted that */
    .sq-stamp{ animation: none; }
    .reveal{ transition: none; }
    .toast{ transition: none; }
    /* every motion added for polish is opt-out; state must still be readable */
    .cover-card,
    .cover.closing .cover-card,
    /* the strike is not built at all under reduced motion — only the handshake
       is, and it needs to be visible without the animation that reveals it */
    .gs > *{ animation: none; }
    .case, .case.closing, .case-card, .case.closing .case-card,
    .modal.show, .modal.closing,
    .modal.show .modal-card, .modal.closing .modal-card,
    .modal.show.is-win .modal-card,
    .modal.show.is-win .mres, .modal.show.is-win h3,
    .modal.show.is-win p, .modal.show.is-win .modal-btns,
    .sq.check,
    .sq.dot::after,
    .t-pc,
    .sq .pc.landed{ animation: none; }
    .sq, .sq .pc, .gbtn, .nav-btn, .nav-unlock-btn, .nav-links a,
    .ucard{ transition: none; }
    .sq .pc.moving{ transition: none; }

    /* The "in play" pip on the current roles animated forever. An indefinite
       animation is its own problem — WCAG asks for a way to stop anything that
       runs over five seconds — so this one is worth more than tidiness. */
    .jnow::before{ animation: none; }

    /* the last few transitions that were not opted out */
    .dshot img, .resume-cta, .dopen, .cover{ transition: none; }
    .dcard.open:hover .dshot img,
    .resume-cta:hover{ transform: none; }

    /* the navbar: keep every hover STATE (colour, shadow, the underline being
       present) and drop only the movement — the wipe, the lift, the turning key */
    .nav-links a::after{ transition: none; }
    .nav-unlock-btn .nub-key,
    .nav-unlock-btn:hover .nub-key{ transition: none; transform: none; }
    .nav-unlock-btn:hover, .nav-btn:hover,
    .nav-unlock-btn:active, .nav-btn:active{ transform: none; }

    /* ---- the shared interaction language ----
       Same rule as above: keep the colour and the shadow shift, drop the lift
       and the press. Every entry names its pseudo-class, because a media query
       adds no specificity and (0,1,0) never beats (0,2,0). */
    .cover-play, .gbtn, .case-links a, .file-mail, .file-social a,
    .nojs-links a, .resume-cta, .skip,
    .cover-x, .case-x, .reveal-card, .t-pc,
    .topnav .brand, footer a{ transition: none; }

    .cover-play:hover,    .cover-play:active,
    .gbtn:hover,          .gbtn:active,
    .case-links a:hover,  .case-links a:active,
    .file-mail:hover,     .file-mail:active,
    .file-social a:hover, .file-social a:active,
    .nojs-links a:hover,  .nojs-links a:active,
    .resume-cta:hover,    .resume-cta:active,
    .reveal-card:hover,   .reveal-card:active,
    .dcard.open:hover,    .dcard.open:active,
    .skip:active{ transform: none; }

    /* the red rule still appears on hover, it just does not travel */
    .topnav .brand::after, footer a::after, .dopen::after{ transition: none; }

    .resume-cta .rc-go, .resume-cta:hover .rc-go{ transition: none; transform: none; }
    #tray-player .t-pc:hover{ transform: none; }   /* (1,2,0): a bare .t-pc loses */
    .cover, .cover.closing{ animation: none; }     /* the scrim state still holds */

    /* `.gs > *` above is (0,1,0) and cannot reach these (0,4,0) rules, so the
       win choreography was running at full strength under reduced motion — a
       winner got 1.34s of blank stage before the handshake appeared. */
    .modal.show.is-win .gs-piece,
    .modal.show.is-win .gs-slash,
    .modal.show.is-win .gs-burst,
    .modal.show.is-win .gs-top,
    .modal.show.is-win .gs-bot,
    .modal.show.is-win .gs-gg{ animation: none; }
    .modal.show.is-win .gs-gg{ opacity: 1; transform: translate(-50%,-50%); }

    /* The square hover tint needs no entry: the .sq transition is already killed
       above, so the tint arrives instantly with the state intact. Don't "fix" it. */
  }

  /* ============================================================
     DARK MODE — the Black side of GAMBIT
     Not a reskin: [data-theme="dark"] also decides WHICH SIDE YOU PLAY. app.js
     reads the same attribute, seats you as Black, flips the board and gives
     AL-1600 the white pieces and the first move. The head gate sets the
     attribute before first paint; the navbar knight toggles it.
     Built as OVERRIDES so the light theme stays byte-identical: the palette
     tokens flip here, and the dozen places a literal could not ride a token get
     their own rule. The board pieces are the one thing that must NOT follow the
     theme — a black chessman is black in both worlds — which is why .sq .pc.b
     is pinned where everything semantic rides var(--ink).
     ============================================================ */
  html[data-theme="dark"]{
    color-scheme: dark;
    --paper:   #171009;   /* the field — the curtain's own near-black family */
    --paper-2: #211812;
    --paper-3: #2c2115;
    --walnut:  #b3906b;   /* strong labels/borders */
    --walnut-2:#c9a87d;   /* body copy */
    --walnut-3:#785d3f;   /* rules and dotted borders only — 3.08:1, the same
                             floor the light theme holds them to */
    --ink:     #efe4cb;   /* primary text */
    --red:     #e4674f;   /* the accent, lifted: #a4211b measures ~2.6:1 here */
    --linen:   46,35,23;  /* card fills lift the panel instead of whitening it */
    --halo:    23,16,9;   /* the ring around lifted cards = the field colour */
    --hatch:   226,196,150; /* sealed-face hatching: light dust on dark panels */
    --shade:   240,228,203; /* the print shadows catch light instead of casting it */
  }

  /* ---- the board ----
     Dimmed, not inverted: the same warm wood two stops down, so the white army
     glows and the black army reads by its deep fill and a hairline paper edge.
     These numbers were walked to on Alex's own screen — lighter squares were
     tried and rolled back; the deeper fill below is what carries the army. */
  html[data-theme="dark"] #board{
    background-image: repeating-conic-gradient(#48341e 0 25%, #7b6040 0 50%);
  }
  html[data-theme="dark"] .sq.light{ background: #7b6040; }
  html[data-theme="dark"] .sq.dark{ background: #48341e; }
  /* A LIGHT stroke, deliberately — the lifted squares carry most of the work.
     This landed after trying three treatments on Alex's own screen: a paper
     glow (too soft), then a 1.5px/.65 stroke on these same squares (too much —
     with the fill finally contrasting against the lighter board, the heavy
     edge read as outline rather than silhouette). The subtle edge on lifted
     squares keeps the army solid black with just enough rim to separate it
     from the dark squares. The squares and this stroke are ONE setting: darken
     the board again and the army sinks back in. */
  html[data-theme="dark"] .sq .pc.b{
    /* darker than the pinned #241a0f the light board uses — the fill is what
       says "black"; the half-pixel edge just parts it from the dark squares */
    color: #1a1309;
    -webkit-text-stroke: .5px rgba(244,236,217,.45);
    text-shadow: 0 1px 0 rgba(0,0,0,.4);
  }
  html[data-theme="dark"] .sq .coord{ color: rgba(240,228,203,.42); }
  /* the red washes sit on darker squares now; same gesture, a little more of it */
  html[data-theme="dark"] .sq.last{ --sh-wash: 0 0 0 100vmax rgba(228,103,79,.20); }
  html[data-theme="dark"] .sq.check{ --sh-wash: 0 0 0 100vmax rgba(228,103,79,.42); }
  html[data-theme="dark"] .sq.hint{ --sh-ring: 0 0 0 3px rgba(226,196,150,.9); }

  /* ---- the win slice ----
     .gs-half (the mated king) rides var(--ink) and flips by itself — in dark the
     bot IS White, so the light king falling is correct, not a bug. The mating
     piece is YOURS, and you are Black here. */
  html[data-theme="dark"] .gs-piece{
    color: #241a0f;
    -webkit-text-stroke: 2px rgba(244,236,217,.9);
    filter: drop-shadow(0 4px 6px rgba(0,0,0,.5));
  }
  html[data-theme="dark"] .gs-half{ text-shadow: 0 3px 4px rgba(0,0,0,.45); }

  /* ---- the curtain, dark skin ----
     The drafts file (../website-themes/gambit-intro-grounds.css) said the light
     effects invert with the ground, and this is that promise kept: the ground is
     the INK draft, the blade goes back to light, and the cast swaps — var(--ink)
     already turned the fallen king white, these finish the pair. The toggle
     replays this curtain, so the story reads: the black knight slays the white
     king, and the world comes back dark. */
  html[data-theme="dark"] .intro{
    --gnd-base: #0d0906;
    --gnd: radial-gradient(125% 95% at 50% 48%, #3d2d1d 0%, #1d1509 46%, #0b0805 100%);
    --wash-far: rgba(0,0,0,.38);
    --wash-cut: rgba(0,0,0,0);
    --blade-1: rgba(255,246,224,.5);
    --blade-2: rgba(255,246,224,.22);
    --burst: rgba(255,255,255,.85);
    --shard: rgba(244,236,217,.9);
    --frame-1: rgba(240,230,207,.42);
    --frame-2: rgba(240,230,207,.2);
    --gash: #f6ecd4;
    --ck-edge: rgba(36,26,15,.9);
    --kn-fill: #241a0f;
    --kn-edge: rgba(244,236,217,.9);
    --pc-shadow: drop-shadow(0 0 12px rgba(240,228,203,.38));
  }

  /* ---- the side toggle ----
     A moon that offers the dark world, a sun that offers the way back — the
     glyph names the DESTINATION, same convention the old text label used.
     THE BUTTON IS A SWATCH OF THAT DESTINATION: ink chip with a paper moon in
     the light world, paper chip with an ink sun in the dark one — bg var(--ink)
     and glyph var(--paper) produce both, since the tokens flip together. That
     backing is also what makes the glyph legible: these text-presentation forms
     are hairlines, and the first cut put an ink glyph on the walnut chip every
     .nav-btn wears — near-invisible. A hairline reads when it CONTRASTS with
     its own chip, and maximally so light-on-dark; colour emoji were tried and
     rejected as off-voice for the letterpress page. */
  /* A SQUARE, sized to the height its neighbours actually reach (30.5px measured;
     30 is within the half-pixel the row's centring absorbs), and flex-centred.
     The icons are SVG because the text-presentation moon and sun defeated
     centring TWICE: each carries its ink at a different height in its em box,
     so the nudge that centred one left the other high. A path in a viewBox has
     no metrics — flex centres the box, the box IS the mark. currentColor
     inherits the button's paper, so the chip stays a swatch of the destination
     world with zero theme-specific rules on the icon itself. */
  .side-btn{
    width: 30px; height: 30px; padding: 0;
    display: inline-flex; align-items: center; justify-content: center;
    background: var(--ink); border-color: var(--ink);
  }
  .side-btn:hover{ border-color: var(--red); }
  .side-btn svg{ width: 15px; height: 15px; display: block; }
  .side-btn .sb-sun{ display: none; }
  html[data-theme="dark"] .side-btn .sb-sun{ display: block; }
  html[data-theme="dark"] .side-btn .sb-moon{ display: none; }

  /* ============================================================
     PRINT
     Ctrl+P on a portfolio should produce the portfolio, not a
     screenshot of a game. The board, chrome and dialogs go; every
     sealed card is forced open so the content actually prints; the
     hatched "sealed" faces and paper textures are dropped so it
     does not waste ink.
     ============================================================ */
  /* the printed page's letterhead; invisible on screen (see @media print) */
  .print-head{ display: none; }

  @media print{
    /* print is paper, whichever world the screen was in — re-pin every token
       the dark block flips, so a dark-mode Ctrl+P still prints the letterpress
       page rather than 40 near-black rectangles */
    html[data-theme="dark"]{
      color-scheme: light;
      --paper:#f4ecd9; --paper-2:#ede1c4; --paper-3:#e4d5b2;
      --walnut:#5b3a24; --walnut-2:#7a5230; --walnut-3:#9a7a52;
      --ink:#241a0f; --red:#a4211b;
      --linen:255,251,240; --halo:244,236,217; --hatch:91,58,36; --shade:36,26,15;
    }
    .topnav, .arena, .cover, .case, .modal, .reveal, .toast,
    .skip, .u-back, #fx, .gs, footer{ display: none !important; }

    .print-head{
      display: block;
      padding: 0 0 10px;
      border-bottom: 2px solid #000;
    }
    .ph-name{
      font-family: var(--serif); font-weight: 900; font-size: 26pt;
      line-height: 1.1; color: #000;
    }
    .ph-meta{
      font-family: var(--mono); font-size: 8.5pt; color: #333;
      margin-top: 4px;
    }

    html, body{
      background: #fff !important;
      color: #000;
      overflow: visible !important;
    }
    html.dialog-open{ overflow: visible !important; }

    main{ max-width: none; padding: 0; }
    section{ padding: 18px 0 0; break-inside: avoid; }

    /* everything prints, sealed or not */
    .ucard .u-front{ display: block !important; animation: none !important; }
    .dcard.open .u-front, .dcard .u-front{ display: flex !important; }
    .cap, .move, .dcard, .adjourn{
      background: none !important;
      box-shadow: none !important;
      outline: none !important;
      border-color: #999 !important;
      break-inside: avoid;
    }
    .cap{ min-height: 0; }
    .dshot, .seal{ display: none; }        /* images and the wax disc waste ink */
    .dopen{ display: none; }               /* "read the case sheet" is meaningless on paper */
    /* box-shadow does not inherit, so the offset shadows survive .adjourn being
       flattened below — these three are the only shared-idiom controls that
       still print at all */
    .resume-cta, .file-mail, .file-social a{ box-shadow: none !important; }

    /* show link targets, since a reader cannot click paper */
    .file-mail::after{ content: ""; }
    .file-social a::after{ content: " (" attr(href) ")"; font-size: 8pt; }
    .dlinks a::after, .case-links a::after{ content: " (" attr(href) ")"; font-size: 8pt; }
    .resume-cta{ background: none !important; color: #000 !important; border: 1px solid #000 !important; }
    .resume-cta::after{ content: " — resume.pdf"; font-size: 9pt; }
  }
