/* ═══════════════════════════════════════════════════════════════════════════
   Mode card — the three ways in.

   Load AFTER css/style.css. Every selector here is written to match or beat
   the equivalent in style.css, so source order is what decides it — the same
   contract the responsive block at the end of style.css already relies on.

       <link rel="stylesheet" href="/css/style.css">
       <link rel="stylesheet" href="/css/mode-card.css">

   One component, three accents. Nothing below is per-mode except the value of
   --accent; Hands and Mouse are this card with a different custom property.

   The fill is the point, and how it is built is documented in
   docs/mode-card.md. Short version: CSS cannot threshold at runtime, so the
   density ramp lives in the *paint* (a gradient that fades toward the
   centre-right) and the *mask* supplies hard-edged dither structure that is
   either fully on or fully off. Dots that never soften, over an envelope that
   fades. No per-frame JS, no canvas, three small tiles.

   ── The state switch ──────────────────────────────────────────────────────
   Hover, keyboard focus and the demo's forced state are three *triggers* for
   one *state*. Rather than repeat the declarations in three selector lists —
   which then drift — the triggers set two numbers and every declaration is
   interpolated from them:

       --hot   0..1   ink, border, glow          (also .active, at .72)
       --lift  0|1    the things that move

   That is what lets `:hover` sit inside @media (hover: hover) while
   `:focus-visible` stays outside it: only the trigger is duplicated, never the
   state. Reduced motion zeroes --lift and keeps --hot, so the card still
   changes without anything travelling.
   ═══════════════════════════════════════════════════════════════════════════ */

.mode-card {
  /* ── Public API ───────────────────────────────────────────────────────── */
  --accent: var(--lime, #E1E926);
  /* Existing child rules in style.css read --tint. Aliasing keeps .card-copy
     b em and the rest working untouched. */
  --tint: var(--accent);
  /* Tiles as custom properties so the demo page — and anyone relocating
     assets — can repoint them without touching this file. */
  /* Tile sizes travel with the tiles: the generator writes both, because a
     mask-size that does not match the tile it is given silently rescales the
     pattern — a 72px halftone drawn at 256px is a different texture and nothing
     about it looks like an error. */
  --tile-spray-size: 72px;
  --tile-screen-size: 5px;
  --tile-grain-size: 128px;
  --tile-bloom-size: 72px;
  --tile-spray: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAA7ElEQVR42u3ZQQqAMAwF0dz/0nEjCC5KDSm0+IRsAqZQymj/RD5P3JWvXhZ68aG39fwYLBANvTx9fqhx2YSJDeo6ttnQ225+NIAvFvS2mR+NQBv1jp2PM5MMWrnI0fNBGqT7IT3bq7531HwgLkLaCSpC+rcM8hX7CGn/QSBdh7S7GEiDdCukFUivuc2zGpOL8mJOEC8G0iJXkSsvxovxYrwYSIM0L8aL8WK8mMiVF+PFFC/Gi4E0LyZy5cV4MV7MCVIgLXIVufJivBgvxouBNEjzYrwYL8aLiVx5MV6MF3MXA2mQ5sVErrzY/vMvMJzdwhtVKfoAAAAASUVORK5CYII=');
  --tile-bloom: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAAA+ElEQVR42u3Z0QrCMAxA0fz/T8dHYXZlrREaOYO9FI0i8+p6It9H5OdRudZyflwWRi86eszq89rOD+f8jMEne7f25Mwv1o6cv3sp54++AsfND5Hej3QUrGX3+UJcGOksDGab+SK9GOkoDObOmz1ufhQGbbbWdr4Qb0TaFbQYaQ3yK7YWaf+DRHo90u7FJvdi7uYLt1xF2o6iSJdFmmqINBfjYlyMi3ExLsbFRNp+kEhzMS7GxbgYF+Nitlz9D7LlysW4GBfjYlyMi3ExkeZiXIyLcTEuxsW4mEhzMZHmYlyMi3ExLsbFuBgXc3IxLsbFuBgX42J/oxovyRawDP396MwAAAAASUVORK5CYII=');
  --tile-screen: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAGUlEQVR42mP4//8/AzpG5vxHF0QG+FWiYAB200+xZ4YhYwAAAABJRU5ErkJggg==');
  --tile-grain: url('data:image/png;base64,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');

  /* The arrow is absolutely positioned, so it is outside the text flow and the
     copy column knows nothing about it. Its footprint is declared here and the
     copy keeps clear of it — see .card-meta. */
  /* ── The proportion basis ──────────────────────────────────────────────
     The reference is specified as ratios of the card's HEIGHT, so the card
     publishes its height as a custom property and every size below is a
     fraction of it. The expression is the same one min-height uses, so the two
     cannot drift; at these widths the card is exactly its min-height, since the
     copy is shorter than it. Ratios were measured off
     docs/style-refs/11-mode-card-phone-crop-2.png, normalised to the card's
     BORDER BOX — the lime ring, 550x307 in that crop. Not the 561x322 image
     (which carries outer margin) and not the 525x289 interior: the ring's
     aspect is 1.792 against the live card's 1.796, where the other two are off
     by 1.2% and 3.0%. Two plausible normalisations disagreeing by 11% is what
     made the first pass of these numbers hard to settle.

     Sizes are set, rendered, measured and corrected once rather than divided by
     a cap-to-em constant: that constant is ~0.70 for Space Grotesk and ~0.73
     for JetBrains Mono, and PLAY WITH is the mono one, so deriving from it
     bakes in a font-metric guess. scripts/dev/card-proportions.mjs is the loop.
     -------------------------------------------------------------------- */
  /* NOTE: this is the MIN height, not the rendered height. The ratios below
     hold while the card sits at its minimum, which it does at the reference
     viewport — 31vh of 900 is 279px, and the live card measures 279px, which is
     why the crop and the render reconcile. On a short viewport where content
     pushes the card above 31vh, or a tall one where the clamp caps at 300, the
     basis and the box drift apart. Container queries do not fix this:
     container-type: size would make the card's height independent of its
     content, which is the opposite of what this card needs. */
  --card-h: clamp(200px, 31vh, 300px);

  /* ── …and the unit the proportions actually use ────────────────────────
     --card-h follows viewport HEIGHT; the card's width follows viewport WIDTH,
     at about 31vw for one of three columns. So the card's aspect drifts away
     from the reference's 1.79 as the window narrows — at 1440x900 it is 448 by
     279, aspect 1.61 — and type sized off height alone becomes too wide for the
     card. At 1440 the meta row went to four lines on exactly that.
     So the basis is the smaller of the two: the height, or the height the card
     WOULD have at the reference aspect. 31vw / 1.79 = 17.5vw. At the reference
     viewport the two agree (17.5vw = 280px against a 279px card) and nothing
     changes; below it, the whole scale comes down together. */
  --card-u: min(var(--card-h), 17.5vw);

  --go-size: calc(var(--card-u) * 0.2085);
  --go-right: calc(var(--card-u) * 0.06);
  /* The reference puts the disc's CENTRE 73% down the card, level with the meta
     row, rather than tucked into the corner. With a 0.2085H disc that is a
     bottom inset of 1 - 0.73 - 0.2085/2. */
  --go-bottom: calc(var(--card-u) * 0.166);
  --go-clear: calc(var(--go-size) + var(--space-2, 8px));

  /* ── Motion ────────────────────────────────────────────────────────────
     Exposed as tokens so the dither pane can bind to them. They now time the
     WebGL engine's hover ease and its press ring. --press is a scale factor and
     is zeroed by prefers-reduced-motion the
     same way --lift is, or a scale written outside that system slips through
     the media query. */
  --dither-bloom: 260ms;
  --dither-leave: 200ms;
  --dither-pulse: 320ms;
  --dither-ripple: 450ms;
  --dither-ripple-size: 150%;
  --press: 1;

  /* ── The readability hole ──────────────────────────────────────────────
     An ellipse of the card's own ground, centred on the copy column, dimming
     the field under the text. Shared with the WebGL engine as uniforms so both
     engines put the hole in the same place; if they drift, the fallback stops
     being a fallback and becomes a different card.

     An ellipse rather than a box, because a box has an edge and an edge in a
     dither field reads as a panel sitting on the card.

     Values live in docs/presets/*.json under "hole" and are written here by
     gen-dither; wave-engine.js's DEFAULTS carry the same numbers as uniforms.
     check:dither asserts all three agree. */
  --hole-x: 71.8%;
  --hole-y: 50%;
  --hole-w: 46%;
  --hole-h: 85%;
  --hole-plateau: 0;
  --hole-strength: 0.55;

  /* The centre black overlay. Distinct from the hole above: the hole paints the
     GROUND over the ink so the fill thins toward the copy, this paints BLACK
     over everything so the middle of the card darkens. The engine composites
     the same thing in the shader (uVig); both are centred on the card rather
     than on the copy. */
  --vig-x: 50%;
  --vig-y: 50%;
  --vig-w: 55%;
  --vig-h: 75%;
  --vig-strength: 0.5;

  --hot: 0;
  /* The hot INK layer, separated from --hot. Hover and focus move both; the
     SELECTED state moves only --hot. See .mode-card.active. */
  --ink-hot: 0;
  --lift: 0;

  position: relative;
  isolation: isolate;               /* the ink stack is this card's business */
  overflow: hidden;
  display: grid;
  /* The art column is the art's own rasterised size, not a percentage. These
     illustrations cannot scale — every size is re-rasterised — so a column that
     can fall below the raster just clips it against overflow: hidden. At 40% it
     did: 183px of a 200px box at 1600, and at 1280 the art started 12px outside
     the card. The dithered subjects hid it behind their internal margin; the
     phone artwork, which fills more of its frame, did not. */
  /* 0.42W in the reference, and the art BLEEDS past it to the right — which is
     what "slightly overflowing the column" means. A grid item overflowing its
     track is not clipped; only the card's own overflow: hidden clips, at the
     card edge. So the column is the art's visual width and the raster's
     transparent margin hangs over the copy column, where the phone's own pixels
     stop 16px short of the text. Column at the raster's full 240 left the meta
     row 131px against the 152px it needs, and it wrapped. */
  grid-template-columns: 210px minmax(0, 1fr);
  align-items: center;
  gap: var(--space-4, 16px);
  /* Vertical padding is style.css's, not a larger one: the lobby asserts the
     card row lands in a 0.55-0.93 band and 24px top and bottom pushed it out.
     Right stays wider than left because the arrow lives there. */
  padding: var(--space-4, 16px) var(--space-5, 24px) var(--space-4, 16px) var(--space-4, 16px);
  min-height: var(--card-h);
  border-radius: var(--radius-card, 14px);

  /* Flat declaration first, every time a color-mix follows. An engine without
     color-mix drops the whole declaration as invalid — and on this one that
     would leave a transparent card with white type over live tunnel video. */
  /* Ground, plus an UNMASKED wash of the accent on the same envelope as the
     ink. The reference's voids are dark accent, not black: the dither modulates
     a tinted ground rather than cutting holes to nothing. Without this the
     veins read as high-contrast camouflage instead of as ink on a stained
     card — the texture is identical either way, it is the tone underneath that
     was missing. Kept low; the copy column is past the falloff. */
  background: var(--ink-0, #070907);
  background:
    radial-gradient(118% 132% at -6% 104%,
      color-mix(in srgb, var(--accent) 20%, transparent) 0%,
      color-mix(in srgb, var(--accent) 9%, transparent) 34%,
      transparent 62%),
    color-mix(in srgb, var(--ink-0, #070907) 86%, transparent);
  -webkit-backdrop-filter: blur(var(--blur-card, 8px));
          backdrop-filter: blur(var(--blur-card, 8px));

  border: 2px solid var(--accent);
  border-color: color-mix(in srgb, var(--accent) calc(82% + 18% * var(--hot)), transparent);

  /* Defined once as a property, applied twice — see :focus-visible below. */
  --shadow-card:
    inset 0 0 0 1px color-mix(in srgb, var(--accent) calc(14% + 16% * var(--hot)), transparent),
    0 0 calc(24px + 22px * var(--hot)) calc(-2px - 2px * var(--hot))
      color-mix(in srgb, var(--accent) calc(38% + 28% * var(--hot)), transparent);
  box-shadow: var(--shadow-card);

  transform: translateY(calc(var(--lift) * -3px)) scale(var(--press));

  color: var(--fg-1, #F8F8F8);
  font-family: var(--font-body, ui-sans-serif, system-ui, sans-serif);
  text-align: left;
  cursor: pointer;

  transition:
    border-color var(--dur-base, 220ms) var(--ease-out, cubic-bezier(.16,1,.3,1)),
    box-shadow var(--dur-base, 220ms) var(--ease-out, cubic-bezier(.16,1,.3,1)),
    transform var(--dur-fast, 120ms) var(--ease-out, cubic-bezier(.16,1,.3,1));
}

/* Content sits above the ink stack. style.css already sets this; repeated so
   the component stands up on its own. */
.mode-card > * { position: relative; z-index: 1; }

/* ── The triggers ──────────────────────────────────────────────────────────
   Order matters and is deliberate: .active first, so that focusing or hovering
   an already-selected card takes it all the way to 1 rather than being pulled
   back down to .72.
   ------------------------------------------------------------------------ */

/* Selected. setMode() puts .active on the card whose source is live. It reads
   as hover held, but it does not move — a persistent state should not look like
   a gesture in progress, so it takes --hot and not --lift.

   And it does NOT take --ink-hot, which is the fix for a real defect rather
   than a refinement. Mouse is the fallback input, so setMode() marks #card-mouse
   .active before the user has chosen anything — and while .active drove the ink,
   that card rendered its hot envelope at 0.72 on the lobby's first paint. Its
   fill measured 15.2% coverage in the copy column against 0% on the other two,
   which reads as a coarser, denser dither on one card out of three rather than
   as a selection. The three ink stacks were always identical: same tiles, same
   sizes, same envelope, same opacities, no per-accent override anywhere. The
   difference was entirely this state.

   A selected card should look selected — border, glow, arrow — not look like a
   different texture. */
.mode-card.active { --hot: .72; }

/* Pointer hover, guarded. On a touch screen :hover latches after a tap and the
   card stays lifted with its arrow filled — on the lobby of a game whose
   headline mode is PLAY WITH PHONE. Only the trigger is inside the guard, so
   keyboard focus below is unaffected. */
@media (hover: hover) {
  .mode-card:hover:not(:disabled) { --hot: 1; --ink-hot: 1; --lift: 1; }
}

/* Keyboard focus, and the demo's forced state. Outside the hover guard, by
   design — a keyboard user on a touch device still gets the full state. */
.mode-card:focus-visible,
.mode-card[data-force-hover] { --hot: 1; --ink-hot: 1; --lift: 1; }

.mode-card:disabled { --hot: 0; --ink-hot: 0; --lift: 0; cursor: not-allowed; }

/* ── Focus ring ────────────────────────────────────────────────────────────
   On `outline`, not `box-shadow`. box-shadow is already carrying the inset
   hairline and the glow, and .active redeclares it — so a ring encoded there
   is overwritten on the selected card, which is the card Tab lands on first.
   outline is contested by nothing, and forced-colors honours it natively.
   At a 14px radius, 2px offset by 3px reads as a ring rather than a second
   border.
   ------------------------------------------------------------------------ */
.mode-card:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  /* Re-applied at (0,2,0) so the component does not depend on style.css's old
     `.mode-card:focus-visible` block being deleted. That rule is the same
     specificity and would otherwise win on box-shadow, giving keyboard focus a
     doubled ring and a different glow from hover — measured, not assumed. It is
     the same custom property, so there is still one definition of the glow. */
  box-shadow: var(--shadow-card);
}

/* ── The ink ───────────────────────────────────────────────────────────────
     .card-ink          the sprayed accent, masked by the 256px riso tile
     .card-ink::before  a regular 8px halftone screen, masked and faint
     .card-ink::after   the hot layer, cross-faded on opacity so it runs on
                        the compositor
   ------------------------------------------------------------------------ */

/* ── The ink ───────────────────────────────────────────────────────────────
   Restructured so the DOTS can drift without dragging the envelope with them.

   .card-ink        the envelope, as a MASK. Nothing paints here; it is a
                    window, and it is the only layer that knows where the ink
                    is dense. It never translates.
   ::before         the ink: solid accent, masked by the tile, oversized by one
                    pitch and translated. Because the tile repeats, translating
                    by exactly one pitch is visually identical to shifting
                    mask-position by one pitch — and transform is a compositor
                    property where mask-position is not.
   ::after          the bloom: the SAME screen at +0.10 coverage, cross-faded on
                    hover. Honest description: at this pitch that reads as the
                    dot field getting denser, not as dots swelling.

   The earlier structure put the envelope and the tile on ONE element, which is
   why the drift could not be done as a transform — translating it would have
   slid the gradient too. Splitting them is what makes the motion free.
   ------------------------------------------------------------------------ */
.mode-card .card-ink {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;                 /* clips the oversized children */

  /* Wahyu's envelope, from the pane — the same geometry and stops as before,
     expressed as alpha because it is a mask now rather than paint. The stop-0
     4% white brightening is gone with the colour; the ink is flat accent. */
  -webkit-mask-image: radial-gradient(112% 109% at -10% 146%,
      #000 0%, rgba(0,0,0,.72) 17%, rgba(0,0,0,.30) 37%, transparent 70%);
          mask-image: radial-gradient(112% 109% at -10% 146%,
      #000 0%, rgba(0,0,0,.72) 17%, rgba(0,0,0,.30) 37%, transparent 70%);

  /* Envelope bloom, as a transform rather than a second wider gradient: scaling
     the window from the accent corner IS the envelope growing out of it, and it
     costs nothing. transform-origin matches the gradient's own centre. */
  transform-origin: -10% 146%;
  transform: scale(calc(1 + var(--ink-hot) * 0.06));
  transition: transform var(--dither-bloom) var(--ease-out, cubic-bezier(.16,1,.3,1));
}

.mode-card .card-ink::before,
.mode-card .card-ink::after {
  content: '';
  position: absolute;
  /* One tile of overhang in every direction, so a full pitch of travel never
     exposes an edge. */
  inset: calc(var(--tile-spray-size) * -1);
  background: var(--accent);
  -webkit-mask-repeat: repeat; mask-repeat: repeat;
  will-change: auto;
}
.mode-card .card-ink::before {
  -webkit-mask-image: var(--tile-spray);
          mask-image: var(--tile-spray);
  -webkit-mask-size: var(--tile-spray-size) var(--tile-spray-size);
          mask-size: var(--tile-spray-size) var(--tile-spray-size);
}
.mode-card .card-ink::after { content: none; }   /* the engine paints the swell */

/* The CSS band-wave that lived here is deleted. It is superseded by the WebGL
   engine, which Wahyu picked; his preset sets its amplitude to zero, so it was
   already off by value before it was removed by instruction.

   What remains of that work is load-bearing and stays: the ink is an envelope
   MASK with the tile as paint on its pseudo-elements, which is exactly what
   lets the engine hide the whole stack with one selector. See 5037466.

   --dither-drift goes with it. --dither-bloom, --dither-leave, --dither-pulse
   and --dither-ripple stay: they now time the ENGINE's hover ease and its press
   ring. */



/* The drift. One pitch up and to the right — out of the accent corner — then it
   repeats, and because the travel is exactly one tile the loop has no seam. */

/* The uniform drift is gone: the wave replaces it. Drifting the whole field AND
   rolling a band over it reads as two motions fighting, and the band is the one
   that carries the idea. */

/* ── The print screen ──────────────────────────────────────────────────────
   Moved off .card-ink's pseudo-elements, which the ink and the bloom now use.
   A regular grid beneath the irregular ink is the difference between ink on
   paper and a grainy photograph. Deliberately low: at half strength the grid
   wins in the dense corner and the fill grows a visible square patch.
   ------------------------------------------------------------------------ */
.mode-card::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  opacity: .26;
  background:
    radial-gradient(140% 155% at -4% 104%,
      color-mix(in srgb, var(--accent) 62%, transparent) 0%,
      color-mix(in srgb, var(--accent) 38%, transparent) 42%,
      transparent 78%);
  -webkit-mask-image: var(--tile-screen);
          mask-image: var(--tile-screen);
  -webkit-mask-size: var(--tile-screen-size) var(--tile-screen-size);
          mask-size: var(--tile-screen-size) var(--tile-screen-size);
  -webkit-mask-repeat: repeat; mask-repeat: repeat;
}

/* ── The grain ─────────────────────────────────────────────────────────────
   Paper tooth over the fill and the illustration. Painted, not masked: it is
   the surface the ink sits on, not part of the ink.

   The tile is OPAQUE and jittered around 128, because overlay is neutral only
   at 0.5. A white-with-alpha tile — which this was — can only lighten:
   overlay(0.027, white) = 0.054, so it did nothing at all on the near-black
   ground while driving the bright accent and the near-white type toward pure
   white. Nothing in the shadows, a bleach in the highlights, which is the exact
   opposite of tooth.

   It runs over the type too. Protecting the copy from it was solving a problem
   that is not there: at 0.16 opacity a source jittered 108-148 moves near-white
   type by about 0.3% luminance, which is below the threshold of anything, and
   the measured type standard deviation is unchanged with the layer on.
   ------------------------------------------------------------------------ */
.mode-card::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  /* Lifts a little on hover — the "sparkle" — but does NOT jitter. A 1px
     positional jitter on a layer sitting over 13px body copy is the noise
     flicker the brief itself warns against, and mask/background-position is a
     repaint besides. The opacity read survives; the flicker risk does not. */
  /* Keyed on --ink-hot, NOT --hot: .active drives --hot, so keying the grain
     there would make the SELECTED card's fill differ from the other two — the
     exact defect check:ink exists to catch, and it caught this one. Anything
     that paints the fill follows --ink-hot; only emphasis follows --hot. */
  opacity: calc(.16 + .05 * var(--ink-hot));
  background-image: var(--tile-grain);
  background-size: var(--tile-grain-size) var(--tile-grain-size);
  background-position: 0 0;
  background-repeat: repeat;
  mix-blend-mode: overlay;
}

/* Device pixel ratio: the tiles are inlined below as data: URIs, at 1x only.
   2x sources exist (dither-*@2x.png, same generator) and measure 0.192 vs
   0.215 on dither softness — real but small. Inlining both sets would put
   ~55KB of base64 on the critical path to fix that, which is the worse trade:
   an unfilled card on first paint is a defect, a marginally softer dither is
   not. If the 2x swap is wanted later it needs <link rel=preload> in
   index.html, which makes it w7:p1's change.
   ------------------------------------------------------------------------ */

/* ── Type scale, from the reference ─────────────────────────────────────────
   Measured off 11-mode-card-phone-crop-2.png. The crop's figures are given as
   cap heights; the values here are FONT SIZES, which is what CSS sets — cap
   height is about 0.70 of font size in these faces, and the two reconcile:
   a 21px measured cap on "PLAY WITH" is a ~30px font, 0.090 of a 322px card.

     PLAY WITH   0.1007H  mono, regular, letter-spaced, white
     PHONE       0.1499H  display, heavy, accent

   A note on how these were settled, because two of us measured the same crop
   and got answers 6.5% apart. Band height off a compressed JPEG depends
   entirely on where the threshold sits: PLAY WITH reads 21px at a cut of 195
   and 23px at a cut of 110, and at H=307 two pixels is 6.5% — the whole of the
   disagreement. Neither reading was measuring type size; both were measuring a
   threshold choice on a soft edge. The defensible cut is 50% between the text
   peak and the LOCAL ground under that line, since the ground is not the same
   brightness under the headline as under the meta row. Measured that way, on
   both images with the same function, the crop gives 23px and the two readings
   reconcile.
     description 0.051H   body, muted, two lines
     1-4 PLAYERS 0.053H   mono caps, WHITE — not the muted grey it was
     (PARTY PLAY) 0.049H  mono caps, ACCENT, own line

   The two title lines are one block with line-height 1.0: measured
   baseline-to-baseline is 46px against a 46px PHONE, so they set solid.
   -------------------------------------------------------------------------- */
.card-copy b {
  display: flex;
  flex-direction: column;
  font-family: var(--font-mono, ui-monospace, monospace);
  font-size: calc(var(--card-u) * 0.1007);
  font-weight: 400;
  line-height: 1.0;
  letter-spacing: 0.10em;
  text-transform: uppercase;
  color: var(--fg-1, #F8F8F8);
}
.card-copy b em {
  font-family: var(--font-display, ui-sans-serif, system-ui, sans-serif);
  font-size: calc(var(--card-u) * 0.1499);
  font-weight: 700;
  font-style: normal;
  letter-spacing: -0.01em;
  line-height: 1.0;
  color: var(--accent);
}
.card-copy small {
  display: block;
  margin-top: calc(var(--card-u) * 0.065);
  font-family: var(--font-body, ui-sans-serif, system-ui, sans-serif);
  font-size: calc(var(--card-u) * 0.0588);
  line-height: 1.15;
  /* The crop breaks this after "into"; live ran to ~0.48W and broke a word
     later. Expressed against the height basis rather than in its own unit, so
     the measure travels with the rest of the scale: the card is about 1.79 wide
     for 1 high, so a fraction of W is 1.79x that fraction of H.
     0.38W = 0.68H was measured off the crop and does NOT produce the reference
     break — "Turn your phone into a" needs 179px and 0.68H gives 190px, so the
     "a" still fits. The break window is 166px (without "a") to 179px (with it),
     which is 0.595H to 0.642H; 0.618 sits in the middle of it.
     That window is only 13px wide, so this is the one value here that a font
     fallback could move. If the description ever reads wrong, check this first. */
  max-width: calc(var(--card-u) * 0.618);
  /* Two lines is the reference's shape; a third would push the meta row down
     into the arrow. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
}

/* No divider. The crop has none — the meta row simply sits below the
   description, and the rule was carried over from the v2 reference. */
.card-meta {
  display: flex;
  align-items: flex-start;
  gap: 0.4em;
  margin-top: calc(var(--card-u) * 0.12);
  padding-top: 0;
  border-top: 0;
  font-family: var(--font-mono, ui-monospace, monospace);
  font-size: calc(var(--card-u) * 0.0512);
  /* Tighter than --tracking-label. The reference sets this row far closer than
     0.14em, and at 0.14em "1 - 4 PLAYERS" needs 174px against the 165px the
     row has. */
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.card-meta em {
  display: block;
  margin-top: calc(var(--card-u) * 0.012);
  font-style: normal;
  font-size: calc(var(--card-u) * 0.049);
  color: var(--accent);
}

/* The people glyph.
   ONE figure, two crops. The pair is the whole 25-unit sheet; the single player
   is the SAME sheet seen through a viewBox cropped to the front figure
   ('9 0 16 16'). So there is a single path for the figure and the two glyphs
   cannot drift — head diameter, shoulder width, height and baseline are the
   same marks, not two drawings that agree.

   The previous single glyph cropped the pair sheet with mask-position and a
   narrower box, which is why it read too tall and narrow: the front figure ends
   at 24.7 of 25, so aligning the sheet's right edge left a sliver of padding and
   the visible figure was squeezed into what remained.

   The back figure's shoulder stops at x=8 rather than running under the front
   one, so the gap between them is geometry in the path rather than a second
   layer — ::before and ::after are spent, which is what made the original read
   as a circle on a slab.

   Not the scaling trap from 69c8d9a: that was transform: scale() on px borders
   and box-shadow spreads, where a 1px part lands on a half pixel. A unitless
   vector path has no px to halve.
   -------------------------------------------------------------------------- */
.card-meta .g-person,
.card-meta .g-people {
  height: calc(var(--card-u) * 0.070);
  /* Optically aligned to the PLAYERS cap rather than its line box: the cap
     starts below the box top, so matching the box sits the glyph high. */
  margin-top: calc(var(--card-u) * 0.006);
  flex: none;
  background: var(--accent);
  -webkit-mask-size: contain; mask-size: contain;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
}
/* Widths are the two viewBoxes' aspect ratios, so both render the figure at the
   same scale: 25/16 for the pair, 16/16 for one. */
.card-meta .g-people {
  width: calc(var(--card-u) * 0.070 * 25 / 16);
  -webkit-mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 25 16' fill='%23fff'><circle cx='6.6' cy='4.6' r='3.2'/><path d='M0.6 16v-1.1a6.4 6.4 0 0 1 7.4-6.3V16z'/><circle cx='17' cy='4.2' r='3.8'/><path d='M9.3 16v-1.3a7.7 7.7 0 0 1 15.4 0V16z'/></svg>");
          mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 25 16' fill='%23fff'><circle cx='6.6' cy='4.6' r='3.2'/><path d='M0.6 16v-1.1a6.4 6.4 0 0 1 7.4-6.3V16z'/><circle cx='17' cy='4.2' r='3.8'/><path d='M9.3 16v-1.3a7.7 7.7 0 0 1 15.4 0V16z'/></svg>");
}
.card-meta .g-person {
  width: calc(var(--card-u) * 0.070);
  -webkit-mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='9 0 16 16' fill='%23fff'><circle cx='17' cy='4.2' r='3.8'/><path d='M9.3 16v-1.3a7.7 7.7 0 0 1 15.4 0V16z'/></svg>");
          mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='9 0 16 16' fill='%23fff'><circle cx='17' cy='4.2' r='3.8'/><path d='M9.3 16v-1.3a7.7 7.7 0 0 1 15.4 0V16z'/></svg>");
}
.card-meta .g-person::before, .card-meta .g-person::after,
.card-meta .g-people::before, .card-meta .g-people::after { content: none; }
.card-meta .g-people::before, .card-meta .g-people::after { content: none; }

/* ── Copy ──────────────────────────────────────────────────────────────────
   The body copy brightens with the card. Two reasons: it reads as the card
   waking up, and the type gains contrast at the exact moment the ground
   brightens, which is the moment it needs it.
   ------------------------------------------------------------------------ */
.card-copy small {
  color: var(--fg-2, rgba(248, 248, 248, .68));
  color: color-mix(in srgb, var(--fg-1, #F8F8F8) calc(68% + 32% * var(--hot)), transparent);
  transition: color var(--dur-base, 220ms) var(--ease-out, cubic-bezier(.16,1,.3,1));
}
/* White in the reference, not the muted grey the v2 card used. */
.card-meta { color: var(--fg-1, #F8F8F8); }
/* "(PARTY PLAY)" is set in the type-scale block above: accent, on its own
   line, at 0.049H. It used to be 60% white — raised from --text-faint's 42%,
   which measured 3.16:1 and failed 1.4.3 for text this size. The accent is
   brighter than either, so that concern is retired rather than reintroduced;
   the contrast table in docs/mode-card.md is re-measured for this scale. */

/* Keep the copy out from under the arrow.
   margin, not padding: the thin rule is .card-meta's border-top, and a border
   spans the padding box — a margin shortens the rule as well as the text. That
   is what the reference shows, the divider stopping before the arrow, so one
   declaration buys both the collision fix and the correct drawing. */
.card-meta { margin-right: var(--go-clear); }

/* ── Illustration ──────────────────────────────────────────────────────────
   It lifts. It does NOT scale: these are 1-bit dithers rasterised at exactly
   the size they are shown at, and scaling resamples the checker back into the
   grey it was encoding. A translate moves the raster; a scale destroys it.
   (style.css currently scales — see docs/mode-card.md.)
   No will-change: a transitioned transform is promoted for the duration of the
   animation anyway, and three permanently-promoted layers behind three
   backdrop-filters is a cost paid on every frame of a game that is also running
   p5 and a hand model.
   ------------------------------------------------------------------------ */
.card-art {
  transform: translateY(calc(var(--lift) * -4px));
  transition: transform var(--dur-base, 220ms) var(--ease-out, cubic-bezier(.16,1,.3,1));
}

/* ── Arrow ─────────────────────────────────────────────────────────────────
   Solid accent at rest: it is the card's primary affordance and 100% of people
   see the resting state. Hover differentiates by getting brighter, not fuller —
   plus the glow and the 4px nudge in the direction it points.
   ------------------------------------------------------------------------ */
.card-go {
  position: absolute;
  right: var(--go-right);
  bottom: var(--go-bottom);
  width: var(--go-size);
  height: var(--go-size);
  /* A rounded square, not a circle. 0.137 of the side, MEASURED off
     docs/style-refs/15-arrow-button-rounded-square.png rather than taken from
     the brief's 0.22 — the reference corners are noticeably tighter.

     How, since a naive read gets this wrong: the top row of the fill is pure
     antialiasing and gives 0.111. Solving r from the horizontal inset at depths
     2 to 6 below the top converges instead — 14.29, 13.48, 13.66, 13.48, 12.90,
     mean 13.56px on a 99px button. Two sessions measured it independently and
     landed on 0.136 and 0.137.

     The size stays at the brief's 0.2085H, from crop 11 and the composition,
     rather than reference 15's 0.2598H. The two references disagree by 22% in
     side, and an isolated component study drifts larger than the same component
     in composition — nothing in the frame competes with it. Taking the radius
     from the study and the size from the composition gives each source the
     thing it is better placed to know. Wahyu's call; see docs/mode-card.md. */
  border-radius: calc(var(--go-size) * 0.137);
  background: var(--accent);
  background: color-mix(in srgb, var(--accent) calc(100% - 8% * var(--hot)), #fff);
  color: var(--ink-0, #070907);
  transform: translateX(calc(var(--lift) * 4px));
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--accent) calc(34% + 26% * var(--hot)), transparent),
    0 0 calc(22px * var(--hot)) -2px color-mix(in srgb, var(--accent) calc(80% * var(--hot)), transparent);
  transition:
    background-color var(--dur-base, 220ms) var(--ease-out, cubic-bezier(.16,1,.3,1)),
    box-shadow var(--dur-base, 220ms) var(--ease-out, cubic-bezier(.16,1,.3,1)),
    transform var(--dur-base, 220ms) var(--ease-out, cubic-bezier(.16,1,.3,1));
}

/* ── The arrow ──────────────────────────────────────────────────────────────
   docs/style-refs/assets/arrow-icon.svg, verbatim: the same path, the same
   stroke-width 4 in a 32 viewBox, and no stroke-linecap or stroke-linejoin,
   so the caps stay butt and the joins miter exactly as the file has them.

   As a mask with background: currentColor rather than an <img> (which the brief
   rules out) or an injected <svg> element. The mask gets the same currentColor
   behaviour with no DOM change, and it is the technique the people glyph
   already uses, so the card's two icons stay one thing.

   On currentColor vs the file's #070A08: .card-go sets color: var(--ink-0),
   which is #070907 — one and two values apart per channel, invisible at that
   darkness, but genuinely not the same colour. currentColor is what was asked
   for so the token governs. Noted so nobody later "corrects" the token to match
   the asset.

   The stroke scales with the box and is deliberately not snapped: at 0.50 of a
   0.2085H button it renders 2.6px at a 200px card and 3.9px at a 300px one, so
   it is never near sub-pixel, and scaling keeps the weight geometrically right
   at every size. vector-effect: non-scaling-stroke would pin it to one user
   unit and be wrong at every size but one.

   This replaces the clip-path polygon from c0e9081. That argument — one shape
   rather than two that must meet at fractional sizes — still holds here: a
   single stroked path is also one shape.
   -------------------------------------------------------------------------- */
.card-go::before,
.card-go::after { content: none; }
.card-go {
  display: grid;
  place-items: center;
}
.card-go::before {
  content: '';
  display: block;
  width: calc(var(--go-size) * 0.50);
  height: calc(var(--go-size) * 0.50);
  border: 0;
  border-radius: 0;
  transform: none;
  inset: auto;
  clip-path: none;
  background: currentColor;
  -webkit-mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'><path d='M5.33331 16H26.6666M17.3333 25.3334L26.6666 16L17.3333 6.66669' stroke='%23fff' stroke-width='4'/></svg>");
          mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'><path d='M5.33331 16H26.6666M17.3333 25.3334L26.6666 16L17.3333 6.66669' stroke='%23fff' stroke-width='4'/></svg>");
  -webkit-mask-size: contain; mask-size: contain;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
}

.mode-card:active:not(:disabled) { --press: .985; --lift: 0.33; }

/* Disabled is deliberately NOT styled here. Nothing in sketch.js disables a
   mode card, and the real "this mode is unavailable" message already has a
   home in #mode-error. A second channel that nothing writes to is worse than
   no channel. If a card ever does need disabling, the state comes back with
   the markup that needs it — dimmed part by part, never with a blanket
   opacity on the card, because that also dims the line explaining why.
   ------------------------------------------------------------------------ */

/* ── The phone illustration is artwork, not a dither ─────────────────────────
   `.art` sets image-rendering: pixelated, which is essential for the 1-bit
   subjects — resampling a dither averages the checker back into the grey it was
   encoding. The phone art is a smooth lit render with its glow in the alpha, so
   the same rule is exactly wrong for it: at any scale factor that is not 1:1
   (browser zoom, a fractional DPR) nearest-neighbour turns a soft bloom into
   stair-stepping. The sizes are exact so this usually changes nothing; it is
   here for the cases where it does.

   Applies to .art-mouse for the same reason — it is also a provided asset used
   as-is rather than a gen-art dither. Its halftone IS baked into the pixels, so
   there is an argument for pixelated instead; at the sizes these render (1:1,
   or the 2x raster at 2dppx) neither setting does anything, and the difference
   only appears at a fractional DPR, where softening a photographic asset is the
   lesser of the two artefacts. .art-hand keeps pixelated: it is a real 1-bit
   dither from gen-art.

   Applies to the panel heroes as well as the cards, since both draw these
   from the same files. It belongs in style.css beside the rest of `.art` and
   should move there when someone consolidates — it lives here because that file
   is not mine to edit.
   ------------------------------------------------------------------------ */
.art-phone,
.art-hand,
.art-mouse { image-rendering: auto; }

/* ── The readability hole ───────────────────────────────────────────────────
   Between the ink stack and the copy: z-index 1 like the card's children, and
   earlier than them in DOM order, so it paints under the text and over the
   field. mode-card.js inserts it directly after .card-ink for that reason.

   It paints the GROUND, not black — a hole darker than the card reads as a
   shadow under the text rather than as less ink.

   Centred on the copy block as measured from the DOM, identical on all three
   cards: x 0.487-0.948, y 0.149-0.851, centre 0.718/0.500 — the title is the
   widest element and runs to 0.948.

   It THINS the field, it does not remove it. At strength .55 the darkest point
   still leaves 45% of the tone, so the dots under the copy get sparser and
   never stop: a hole that reaches zero is a black slab with a rim, which is
   what the first two passes were. The floor is not a separate number — an
   amount that never exceeds .55 floors the tone at .45 on its own, and a second
   spelling of it would only be something to drift against.

   The radii are wide (.46 x .85 against a .38 x .70 ellipse that was being
   asked to do the same job) so the whole ramp has room. That is the trade the
   --hole-plateau of the previous pass was making badly: holding full strength
   out to .65 of the radius and then falling to zero over the remaining third
   put the entire gradient in a band about .15 W wide, and a gradient that steep
   in a dither field is an edge. Plateau 0 spends the falloff over the whole
   ellipse instead.

   The rim now sits at x .258, inside the illustration column's .33 line, and
   clears it on softness rather than on geometry: at x .33 the amount is .067 of
   .55, so 3.7% damping against a 10% budget. Worth knowing before changing the
   falloff — a curve with no zero, exp(-d²k) among them, would still be damping
   at the card's left edge.

   The stops are 1 - smoothstep(plateau, 1, r), sampled every eighth, which is
   the curve the shader applies to its tone. Sampling the same function is the
   only way the two engines put the rim in the same place; a hand-picked ramp
   drifts. Nine stops rather than two because a linear ramp meeting a dither
   field leaves a visible ring at the join. They are literals because CSS has no
   smoothstep; gen-dither writes both the amounts and the positions from
   --hole-plateau, so the ramp follows the plateau instead of having to be
   re-derived by hand when it moves.

   With the WebGL engine running this is hidden — the shader damps the TONE
   before thresholding, which removes dots rather than greying them, and leaving
   this on top would apply the hole twice. wave-engine.js hides it alongside the
   three other CSS ink layers.
   -------------------------------------------------------------------------- */
/* The centre black overlay, above the ink and below the copy. Its stops are the
   same smoothstep sampling as the hole's, so the two ramps have the same shape
   and only their colour and centre differ. */
.card-vignette {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: radial-gradient(var(--vig-w) var(--vig-h) at var(--vig-x) var(--vig-y),
    rgba(0, 0, 0, calc(var(--vig-strength) * 1))        0%,
    rgba(0, 0, 0, calc(var(--vig-strength) * 0.957031)) 12.5%,
    rgba(0, 0, 0, calc(var(--vig-strength) * 0.84375))  25%,
    rgba(0, 0, 0, calc(var(--vig-strength) * 0.683594)) 37.5%,
    rgba(0, 0, 0, calc(var(--vig-strength) * 0.5))      50%,
    rgba(0, 0, 0, calc(var(--vig-strength) * 0.316406)) 62.5%,
    rgba(0, 0, 0, calc(var(--vig-strength) * 0.15625))  75%,
    rgba(0, 0, 0, calc(var(--vig-strength) * 0.042969)) 87.5%,
    rgba(0, 0, 0, 0) 100%);
}

.card-hole {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: radial-gradient(var(--hole-w) var(--hole-h) at var(--hole-x) var(--hole-y),
    color-mix(in srgb, var(--ink-0, #070907) calc(var(--hole-strength) * 100%), transparent) 0%,
    color-mix(in srgb, var(--ink-0, #070907) calc(var(--hole-strength) * 95.7031%), transparent) 12.5%,
    color-mix(in srgb, var(--ink-0, #070907) calc(var(--hole-strength) * 84.375%), transparent) 25%,
    color-mix(in srgb, var(--ink-0, #070907) calc(var(--hole-strength) * 68.3594%), transparent) 37.5%,
    color-mix(in srgb, var(--ink-0, #070907) calc(var(--hole-strength) * 50%), transparent) 50%,
    color-mix(in srgb, var(--ink-0, #070907) calc(var(--hole-strength) * 31.6406%), transparent) 62.5%,
    color-mix(in srgb, var(--ink-0, #070907) calc(var(--hole-strength) * 15.625%), transparent) 75%,
    color-mix(in srgb, var(--ink-0, #070907) calc(var(--hole-strength) * 4.2969%), transparent) 87.5%,
    transparent 100%);
}

/* ── Press: pulse and ripple ────────────────────────────────────────────────
   One element does both. It is the additive flash (white, fading from .35) and
   the ripple (scaling out from the press point), masked by the BLOOM tile so
   what expands is ink rather than a plain disc.

   Note what that means, because it is a choice: the mask scales with the
   element, so the dots inflate as the ring travels. That reads as ink spreading
   into paper rather than as a ring of larger dots at constant pitch. It is the
   better read for this card, but it is not what "a ring of larger dots" would
   look like.

   It does NOT delay navigation. The class goes on at click, the stage swaps
   immediately, and the pulse plays out on the outgoing card — a parting
   flourish rather than a toll on every entry to the game.

   Keyboard activation has no press point, so the origin falls back to the
   centre of the arrow button: the motion then emanates from the affordance that
   says "go".
   -------------------------------------------------------------------------- */
.card-ripple {
  position: absolute;
  left: var(--rx, 50%);
  top: var(--ry, 50%);
  width: var(--dither-ripple-size);
  height: var(--dither-ripple-size);
  margin-left: calc(var(--dither-ripple-size) / -2);
  margin-top: calc(var(--dither-ripple-size) / -2);
  z-index: 1;
  pointer-events: none;
  border-radius: 50%;
  background: #fff;
  /* A RING, not a disc: the dots are intersected with a soft annulus, so what
     expands is a single band travelling outward — the radial twin of the hover
     wave — rather than a filled flash. */
  -webkit-mask-image: var(--tile-bloom),
      radial-gradient(circle, transparent 0 46%, #000 66%, transparent 86%);
          mask-image: var(--tile-bloom),
      radial-gradient(circle, transparent 0 46%, #000 66%, transparent 86%);
  -webkit-mask-size: var(--tile-bloom-size) var(--tile-bloom-size), auto;
          mask-size: var(--tile-bloom-size) var(--tile-bloom-size), auto;
  -webkit-mask-repeat: repeat, no-repeat;
          mask-repeat: repeat, no-repeat;
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
  opacity: 0;
  transform: scale(.15);
}
@keyframes card-ripple {
  from { opacity: .35; transform: scale(.15); }
  to   { opacity: 0;   transform: scale(1); }
}
.mode-card.is-pressed .card-ripple {
  animation: card-ripple var(--dither-ripple) var(--ease-out, cubic-bezier(.16,1,.3,1));
}

/* ── Accent variants ───────────────────────────────────────────────────────
   The whole of the per-mode difference. Both the data-mode hook and the id are
   matched, so the card is themed however the markup identifies itself.
   ------------------------------------------------------------------------ */
.mode-card[data-mode="phone"], #card-phone { --accent: var(--lime, #E1E926); }
.mode-card[data-mode="hand"],
.mode-card[data-mode="hands"],
#card-hand  { --accent: var(--tint-hand, #3B8BFF); }
.mode-card[data-mode="mouse"], #card-mouse { --accent: var(--tint-mouse, #FF7A45); }

/* ── Reduced motion ────────────────────────────────────────────────────────
   --lift goes to 0, so nothing travels. --hot is untouched, so the ink, the
   border and the glow still change: removing the feedback entirely would leave
   a keyboard user with reduced motion enabled unable to see which card has
   focus. Movement goes; information does not.
   ------------------------------------------------------------------------ */
@media (prefers-reduced-motion: reduce) {
  .mode-card:hover:not(:disabled),
  .mode-card:focus-visible,
  .mode-card[data-force-hover] { --lift: 0; }
  /* The ink still blooms and the glow still changes — the state is information.
     What goes is everything that travels: the drift, the press scale, the
     ripple, and the envelope's scale-up. */
  .mode-card:active:not(:disabled) { --press: 1; --lift: 0; }
  .mode-card .card-ink { transition: none; transform: none; }
  .mode-card .card-ink::before,
  .mode-card .card-ink::after { animation: none !important; }
  .mode-card.is-pressed .card-ripple { animation: none; }
  .mode-card,
  .card-art,
  .card-go,
  .card-copy small,
  .card-meta,
  .mode-card .card-ink::after { transition-duration: 1ms; }
  .mode-card:active:not(:disabled) { transform: none; }
}

/* The art is 0.85H in the reference. 240px is 0.86H of a 279px card, so that
   is the 240 raster — NOT art-200 scaled up, which would resample a fixed-size
   illustration. The markup says art-200; this promotes it where there is room.
   -------------------------------------------------------------------------- */
@media (min-width: 1600px) {
  .card-art.art-200 { width: 240px; height: 240px; background-size: 240px 240px; }
  #card-phone .card-art { background-image: url('/assets/art/phone-240.png'); }
  #card-hand  .card-art { background-image: url('/assets/art/hand-240.png'); }
  #card-mouse .card-art { background-image: url('/assets/art/mouse-240.png'); }
}
@media (min-width: 1600px) and (min-resolution: 2dppx) {
  #card-phone .card-art { background-image: url('/assets/art/phone-240@2x.png'); }
  #card-hand  .card-art { background-image: url('/assets/art/hand-240@2x.png'); }
  #card-mouse .card-art { background-image: url('/assets/art/mouse-240@2x.png'); }
}

/* Bleed right, not both ways: centring a 240 box in a 210 track would also
   push it 15px into the card's 16px left padding. */
.card-art { justify-self: start; }

/* Corner brackets were added here to match the Hands card and have been
   removed again: the crop does not have them on Phone, and Hands only carries
   them because reference 01 draws them as part of that illustration — they are
   rasterised into hand-*.png by gen-art's hand(), not CSS. So the frame is a
   property of the hands artwork rather than of the card, and adding it to the
   other two was making the card say something the reference does not.
   -------------------------------------------------------------------------- */

/* ── Mid widths: the 200px art stops fitting before the layout stacks ────────
   Between the stacking breakpoint and about 1280px the cards are narrow enough
   that a 200px illustration plus readable copy does not fit. At 1024 the card
   is 309px: a 200px art column leaves 49px of copy, and the 40% column it
   replaced left 107px for a 200px raster — i.e. it clipped a third of the
   illustration off each side. That range has been broken in both directions;
   it just was not visible while the art had margin to lose.

   So the art drops to its 120px rasterisation here, the same swap style.css
   already makes below 900px, just at the width where it actually becomes
   necessary. This belongs beside that block in style.css and should move there
   when someone consolidates — it is here because that file is not mine.
   ------------------------------------------------------------------------ */
/* Below the reference viewport the cards narrow faster than the type does:
   the art column is a fixed raster and does not shrink with the card, so the
   copy loses width roughly pixel-for-pixel with the card while the type comes
   down only with --card-u. At 1440 that put the meta row on four lines. The
   120px raster is the fix and it costs no type size — 207px of meta row against
   the 142px it needs. An intermediate 200px tier was tried and does not earn
   its place: it leaves 127px, which still wraps. */
@media (min-width: 901px) and (max-width: 1599px) {
  .mode-card { grid-template-columns: 120px minmax(0, 1fr); }
  .card-art.art-240, .card-art.art-200 { width: 120px; height: 120px; background-size: 120px 120px; }
  #card-phone .card-art { background-image: url('/assets/art/phone-120.png'); }
  #card-hand  .card-art { background-image: url('/assets/art/hand-120.png'); }
  #card-mouse .card-art { background-image: url('/assets/art/mouse-120.png'); }
}
@media (min-width: 901px) and (max-width: 1599px) and (min-resolution: 2dppx) {
  #card-phone .card-art { background-image: url('/assets/art/phone-120@2x.png'); }
  #card-hand  .card-art { background-image: url('/assets/art/hand-120@2x.png'); }
  #card-mouse .card-art { background-image: url('/assets/art/mouse-120@2x.png'); }
}

/* ── Narrow ────────────────────────────────────────────────────────────────
   Stacked, three cards at full height run past a laptop viewport. Matches the
   breakpoint already in style.css. The tiles do not change size: a dither is
   defined per pixel, so the ink gets denser relative to the card rather than
   being scaled, which is the correct behaviour and not a compromise.
   ------------------------------------------------------------------------ */
@media (max-width: 900px) {
  .mode-card {
    min-height: 120px;
    /* THREE columns here, not two: the arrow gets one of its own. See below. */
    grid-template-columns: 120px minmax(0, 1fr) var(--go-size);
    /* Tighter than the wide card, and the tightness is load-bearing rather
       than taste. The copy column has one threshold at this width: the meta
       row needs 134px to keep "1 - 4 PLAYERS" on one line, and the
       description needs about 119px to hold two lines instead of three. Below
       134 both fail at once and the card grows ~55px. The budget is
       120 (art) + 134 (copy) + 36 (arrow) = 290 inside a 331px card, which
       leaves 41px for two gaps and two paddings. 8 and 12 spend 40 of it. */
    /* Row gap keeps the 8px rhythm; the COLUMN gap goes to 4, because the
       column axis is the one under pressure and the art already carries its
       own internal margin inside its 120px box. */
    gap: var(--space-2, 8px) var(--space-1, 4px);
    padding: var(--space-3, 12px) var(--space-3, 12px);
    border-radius: var(--radius-card-sm, 12px);
    /* The reference proportion is for the full-height card. At 120px tall a
       56px disc would be half the card, so the narrow layout keeps its own. */
    --go-size: 36px;
  }
  /* The meta row's own glyph gap, spent from the same budget. --space-1 rather
     than an off-scale 6px: it is on-token AND drops the row's one-line
     requirement from 134px to 130px, so the cleaner value is also the wider
     margin. */
  .card-meta { gap: var(--space-1, 4px); }

  /* ── Why the arrow stops being absolute here ─────────────────────────────
     Above 900px the arrow is absolutely positioned and the copy keeps clear of
     it with `margin-right: var(--go-clear)`. That is a reservation expressed as
     a margin on a text column, which converts HORIZONTAL pressure into VERTICAL
     growth: narrow the column and the copy wraps, and the card grows. At 1600px
     it is invisible because the column has width to spare. At 390px it has none,
     so every pixel of clearance became a wrapped line — the meta row on the
     Phone card reached 4.9 lines in a 119px column and the card grew from 151.9
     to 192.6px, putting the third card 26.6px under the footer.

     The bug was never the constant. It was that an absolutely positioned arrow
     and a flowing text column were coupled through a margin, and that coupling
     is width-dependent in the one direction there is no room for. So at this
     breakpoint the arrow stops being reserved for and starts being laid out: it
     takes a grid column, the copy gets the whole remainder once instead of the
     remainder minus a reservation, and the card's height is driven purely by
     its copy. 178px of column here against 119px before.

     It is also the right idiom. A stacked card is a list row, and a list row
     puts its chevron at the trailing edge, vertically centred — not tucked into
     a corner, which is a shrunken copy of the desktop composition.

     `relative`, NOT `static`: the chevron is two absolutely positioned
     pseudo-elements on .card-go, and an unpositioned parent would resolve them
     against .mode-card and fling the arrow's glyph into the middle of the card.
     ---------------------------------------------------------------------- */
  .card-go {
    position: relative;
    right: auto;
    bottom: auto;
    align-self: center;
    justify-self: end;
  }
  /* Nothing overlaps any more, so nothing is reserved. */
  .card-meta,
  .card-why,
  .card-copy small { margin-right: 0; }

  /* ── The ratios do NOT come down here ───────────────────────────────────
     --card-h is clamp(200px, 31vh, 300px) and is not reset by this block, so
     every ratio above would compute off 261.6px at 390x844 — in a card that
     renders 152px tall. That is ~183px of copy plus 24px of padding in a 152px
     box: about 55px of overflow, swallowed by overflow: hidden under
     grid-auto-rows: 1fr. The meta row would simply vanish and nothing would
     look broken.

     Overriding --card-h alone does not rescue it either: 0.0631 x 120 is a 7px
     description. There is no single narrow basis that serves all of these,
     because a 120px list row is not a 279px card scaled down — the same
     argument already made for the arrow disc, applied to the type.
     -------------------------------------------------------------------- */
  .card-copy b { font-size: 14px; letter-spacing: 0.08em; }
  .card-copy b em { font-size: 21px; }
  .card-copy small {
    margin-top: 5px;
    font-size: var(--text-xs, 12px);
    -webkit-line-clamp: 2;
  }
  .card-meta {
    margin-top: 7px;
    gap: var(--space-2, 8px);
    font-size: 11px;
  }
  .card-meta em { margin-top: 2px; font-size: var(--text-2xs, 10px); }
  .card-meta .g-person,
  .card-meta .g-people { --g: 1px; margin-top: 1px; }
}

/* Forced colours: the ink is decoration and disappears; the border carries the
   identity, and the browser's own palette carries the state. The focus ring is
   an outline, so it survives here without being restated. */
@media (forced-colors: active) {
  .mode-card .card-ink,
  .mode-card::after { display: none; }
  .mode-card { border-color: CanvasText; }
  .mode-card:hover:not(:disabled),
  .mode-card:focus-visible,
  .mode-card.active { border-color: Highlight; }
}
