/* Generated by build.sh — do not edit. Edit the source files in src/assets/css/ instead. */

/* ===== fonts.css ===== */
/* OpenThinkr webfonts — Space Grotesk 500/700 (display), Inter 400/500/600 (body).
   The Google Fonts stylesheet is linked from the document head with preconnect,
   not @imported here, so it does not chain behind this file. */

/* ===== colors.css ===== */
:root{
  /* ---------- Neutrals: 11-step ramp, slightly blue-tinted ---------- */
  --n-0:#FFFFFF;
  --n-50:#F7F8FB;
  --n-100:#EFF1F6;
  --n-200:#E2E6EF;
  --n-300:#CBD1DE;
  --n-400:#A3ABBD;
  --n-500:#78819A;
  --n-600:#565F78;
  --n-700:#3B4358;
  --n-800:#212734;
  --n-900:#0B0D12;

  /* ---------- Brand accents (base = the published brand hue) ---------- */
  /* Blue — primary action, links, focus */
  --blue-50:#EAF1FE; --blue-100:#D3E1FC; --blue-300:#7CA9F4;
  --blue-500:#1F6FEB; --blue-600:#1A5CC4; --blue-700:#154A9E; --blue-900:#0D2C5E;
  /* Coral — Brain Games, alerts, destructive */
  --red-50:#FEECEB; --red-100:#FCD8D5; --red-300:#F79891;
  --red-500:#F0483E; --red-600:#D93529; --red-700:#C22B22; --red-900:#5E1611;
  /* Amber — Languages, highlights, score bars */
  --amber-50:#FFF6E3; --amber-100:#FFEBC2; --amber-300:#FFD37A;
  --amber-500:#FFBF3C; --amber-600:#C98B00; --amber-800:#8A5B00; --amber-900:#4A3000;
  /* Emerald — Career & Jobs, success, correct */
  --emerald-50:#E7F6EE; --emerald-100:#C9EBD9; --emerald-300:#6CC79A;
  --emerald-500:#1FA463; --emerald-600:#188A53; --emerald-700:#157A48; --emerald-900:#0A3B23;
  /* Violet — Personality */
  --violet-50:#F1EDFE; --violet-100:#E2DAFD; --violet-300:#AE99F6;
  --violet-500:#7B5CF0; --violet-600:#6C4BE6; --violet-700:#6642DC; --violet-900:#2E1D70;
  /* Teal — Knowledge & Trivia */
  --teal-50:#E4F6F8; --teal-100:#C2EAEE; --teal-300:#5FC6CE;
  --teal-500:#12A5B0; --teal-600:#0C8A94; --teal-700:#0A6E76; --teal-900:#053B40;

  /* ---------- Semantic surfaces & text (light) ---------- */
  --surface-page:#F7F8FB;         /* near-white, never pure white */
  --surface-card:var(--n-0);
  --surface-raised:var(--n-0);
  --surface-sunken:var(--n-100);
  --surface-inverse:var(--n-900);
  --surface-overlay:rgba(11,13,18,.55);

  --text-primary:var(--n-900);
  --text-secondary:var(--n-600);
  --text-tertiary:var(--n-500);
  --text-inverse:var(--n-0);
  --text-link:var(--blue-500);
  --text-link-hover:var(--blue-700);
  --text-on-accent:var(--n-0);

  --border-subtle:var(--n-200);
  --border-default:var(--n-300);
  --border-strong:var(--n-400);

  /* Interactive — primary */
  --action-primary-bg:var(--blue-500);
  --action-primary-bg-hover:var(--blue-600);
  --action-primary-bg-active:var(--blue-700);
  --action-primary-fg:var(--n-0);
  /* Interactive — secondary (outlined) */
  --action-secondary-bg:var(--n-0);
  --action-secondary-bg-hover:var(--blue-50);
  --action-secondary-bg-active:var(--blue-100);
  --action-secondary-fg:var(--blue-700);
  --action-secondary-border:var(--n-300);
  /* Interactive — ghost */
  --action-ghost-bg:transparent;
  --action-ghost-bg-hover:var(--n-100);
  --action-ghost-bg-active:var(--n-200);
  --action-ghost-fg:var(--n-700);
  /* Disabled + focus */
  --action-disabled-bg:var(--n-200);
  --action-disabled-fg:var(--n-400);
  --focus-ring:var(--blue-500);
  --focus-ring-offset:var(--surface-page);

  /* Feedback */
  --success-fg:var(--emerald-700); --success-bg:var(--emerald-50); --success-border:var(--emerald-300); --success-solid:var(--emerald-500);
  --danger-fg:var(--red-700);      --danger-bg:var(--red-50);      --danger-border:var(--red-300);      --danger-solid:var(--red-500);
  --warning-fg:var(--amber-800);   --warning-bg:var(--amber-50);   --warning-border:var(--amber-300);   --warning-solid:var(--amber-500);
  --info-fg:var(--blue-700);       --info-bg:var(--blue-50);       --info-border:var(--blue-300);       --info-solid:var(--blue-500);

  /* ---------- Category accents: one hue per category, held everywhere ---------- */
  --cat-iq:var(--blue-500);          --cat-iq-fg:var(--blue-700);          --cat-iq-tint:var(--blue-50);
  --cat-personality:var(--violet-500); --cat-personality-fg:var(--violet-700); --cat-personality-tint:var(--violet-50);
  --cat-career:var(--emerald-500);   --cat-career-fg:var(--emerald-700);   --cat-career-tint:var(--emerald-50);
  --cat-language:var(--amber-500);   --cat-language-fg:var(--amber-800);    --cat-language-tint:var(--amber-50);
  --cat-games:var(--red-500);        --cat-games-fg:var(--red-700);        --cat-games-tint:var(--red-50);
  --cat-trivia:var(--teal-500);      --cat-trivia-fg:var(--teal-700);      --cat-trivia-tint:var(--teal-50);

  /* Generic accent channel — set by a category scope, read by components */
  --accent:var(--blue-500);
  --accent-fg:var(--blue-700);
  --accent-tint:var(--blue-50);
  /* Deepened accent for solid fills that carry white text (AA at every size) */
  --accent-deep:var(--blue-600);
  /* Text/glyph colour that sits on --accent-deep */
  --accent-on:#FFFFFF;

  /* Ad slots */
  --ad-bg:var(--n-100);
  --ad-border:var(--n-200);
  --ad-label:var(--n-400);
}

/* Category scopes — put data-category on any container (card, page header, result) */
[data-category="iq"]{--accent:var(--cat-iq);--accent-fg:var(--cat-iq-fg);--accent-tint:var(--cat-iq-tint);--accent-deep:var(--blue-600)}
[data-category="personality"]{--accent:var(--cat-personality);--accent-fg:var(--cat-personality-fg);--accent-tint:var(--cat-personality-tint);--accent-deep:var(--violet-600)}
[data-category="career"]{--accent:var(--cat-career);--accent-fg:var(--cat-career-fg);--accent-tint:var(--cat-career-tint);--accent-deep:var(--emerald-700)}
[data-category="language"]{--accent:var(--cat-language);--accent-fg:var(--cat-language-fg);--accent-tint:var(--cat-language-tint);--accent-deep:var(--amber-500);--accent-on:var(--n-900)}
[data-category="games"]{--accent:var(--cat-games);--accent-fg:var(--cat-games-fg);--accent-tint:var(--cat-games-tint);--accent-deep:var(--red-700)}
[data-category="trivia"]{--accent:var(--cat-trivia);--accent-fg:var(--cat-trivia-fg);--accent-tint:var(--cat-trivia-tint);--accent-deep:var(--teal-700)}

/* ---------- Dark mode ---------- */
[data-theme="dark"]{
  --surface-page:var(--n-900);
  --surface-card:#12161F;
  --surface-raised:#1A1F2B;
  --surface-sunken:#080A0E;
  --surface-inverse:var(--n-0);
  --surface-overlay:rgba(4,6,10,.7);

  --text-primary:#EDF0F6;
  --text-secondary:#A9B2C4;
  --text-tertiary:#7F8899;
  --text-inverse:var(--n-900);
  --text-link:#7CA9F4;
  --text-link-hover:#A9C7F8;
  --text-on-accent:var(--n-900);

  --border-subtle:#232935;
  --border-default:#303845;
  --border-strong:#454E5F;

  --action-primary-bg:#3B82F6;
  --action-primary-bg-hover:#5A99F8;
  --action-primary-bg-active:#1F6FEB;
  --action-primary-fg:#06101F;
  --action-secondary-bg:#161B25;
  --action-secondary-bg-hover:#1D2534;
  --action-secondary-bg-active:#243046;
  --action-secondary-fg:#A9C7F8;
  --action-secondary-border:#303845;
  --action-ghost-bg:transparent;
  --action-ghost-bg-hover:#1A1F2B;
  --action-ghost-bg-active:#232935;
  --action-ghost-fg:#C4CBD8;
  --action-disabled-bg:#1D222D;
  --action-disabled-fg:#5A6376;
  --focus-ring:#7CA9F4;
  --focus-ring-offset:var(--n-900);

  --success-fg:#5FD69B; --success-bg:#0C2A1C; --success-border:#1F6B48;
  --danger-fg:#FF8C84;  --danger-bg:#33110E;  --danger-border:#7A2A22;
  --warning-fg:#FFD37A; --warning-bg:#332508; --warning-border:#7A5A12;
  --info-fg:#8CB8F7;    --info-bg:#0E1F3A;    --info-border:#245094;
  --success-solid:#26C079; --danger-solid:#FF6257; --warning-solid:#FFC94F; --info-solid:#3B82F6;

  /* Category accents lift for dark surfaces */
  --cat-iq:#5A99F8;          --cat-iq-fg:#A9C7F8;          --cat-iq-tint:#101B2E;
  --cat-personality:#9C82F6; --cat-personality-fg:#C2B2FA; --cat-personality-tint:#1A1435;
  --cat-career:#35C280;      --cat-career-fg:#7FDCAE;      --cat-career-tint:#0C2A1C;
  --cat-language:#FFCC63;    --cat-language-fg:#FFDC96;    --cat-language-tint:#2E2208;
  --cat-games:#FF6E64;       --cat-games-fg:#FFA39C;       --cat-games-tint:#2E1210;
  --cat-trivia:#2FC3CE;      --cat-trivia-fg:#7BDCE4;      --cat-trivia-tint:#0A2A2E;
  --accent:var(--cat-iq); --accent-fg:var(--cat-iq-fg); --accent-tint:var(--cat-iq-tint);

  --ad-bg:#12161F; --ad-border:#232935; --ad-label:#5A6376;
}

/* ===== typography.css ===== */
:root{
  /* Families — two only */
  --font-display:"Space Grotesk","Helvetica Neue",Arial,sans-serif;
  --font-body:"Inter","Helvetica Neue",Arial,sans-serif;
  --font-numeric:"Inter","Helvetica Neue",Arial,sans-serif;

  --fw-body:400;/* @kind font */ --fw-medium:500;/* @kind font */ --fw-semibold:600;/* @kind font */
  --fw-display-medium:500;/* @kind font */ --fw-display-bold:700;/* @kind font */

  /* Type scale — size / line-height / tracking.
     Display steps use --font-display; text steps use --font-body. */
  --fs-display-1:64px;/* @kind font */  --lh-display-1:1.04;/* @kind font */ --ls-display-1:-.02em;/* @kind font */
  --fs-display-2:52px;/* @kind font */  --lh-display-2:1.07;/* @kind font */ --ls-display-2:-.02em;/* @kind font */
  --fs-h1:40px;/* @kind font */         --lh-h1:1.12;/* @kind font */        --ls-h1:-.018em;/* @kind font */
  --fs-h2:32px;/* @kind font */         --lh-h2:1.18;/* @kind font */        --ls-h2:-.015em;/* @kind font */
  --fs-h3:24px;/* @kind font */         --lh-h3:1.25;/* @kind font */        --ls-h3:-.01em;/* @kind font */
  --fs-h4:20px;/* @kind font */         --lh-h4:1.32;/* @kind font */        --ls-h4:-.005em;/* @kind font */
  --fs-body-lg:18px;/* @kind font */    --lh-body-lg:1.6;/* @kind font */    --ls-body-lg:0;/* @kind font */
  --fs-body:16px;/* @kind font */       --lh-body:1.6;/* @kind font */       --ls-body:0;/* @kind font */
  --fs-body-sm:14px;/* @kind font */    --lh-body-sm:1.55;/* @kind font */   --ls-body-sm:0;/* @kind font */
  --fs-caption:12px;/* @kind font */    --lh-caption:1.45;/* @kind font */   --ls-caption:.005em;/* @kind font */
  --fs-overline:12px;/* @kind font */   --lh-overline:1.1;/* @kind font */   --ls-overline:.08em;/* @kind font */

  /* Question text — the most-read type on the site */
  --fs-question:32px;/* @kind font */   --lh-question:1.3;/* @kind font */   --ls-question:-.01em;/* @kind font */
  /* Numerals: scores, timers, counters */
  --fs-score:72px;/* @kind font */      --lh-score:1;/* @kind font */        --ls-score:-.02em;/* @kind font */
}
@media (max-width:768px){
  :root{
    --fs-display-1:40px;/* @kind font */ --lh-display-1:1.08;/* @kind font */
    --fs-display-2:34px;/* @kind font */
    --fs-h1:30px;/* @kind font */ --fs-h2:26px;/* @kind font */ --fs-h3:21px;/* @kind font */ --fs-h4:18px;/* @kind font */
    --fs-body-lg:17px;/* @kind font */
    --fs-question:24px;/* @kind font */ --lh-question:1.35;/* @kind font */
    --fs-score:56px;/* @kind font */
  }
}

/* Reusable type classes — components may use these or the raw tokens */
.ot-display-1{font:var(--fw-display-bold) var(--fs-display-1)/var(--lh-display-1) var(--font-display);letter-spacing:var(--ls-display-1)}
.ot-display-2{font:var(--fw-display-bold) var(--fs-display-2)/var(--lh-display-2) var(--font-display);letter-spacing:var(--ls-display-2)}
.ot-h1{font:var(--fw-display-bold) var(--fs-h1)/var(--lh-h1) var(--font-display);letter-spacing:var(--ls-h1)}
.ot-h2{font:var(--fw-display-bold) var(--fs-h2)/var(--lh-h2) var(--font-display);letter-spacing:var(--ls-h2)}
.ot-h3{font:var(--fw-display-medium) var(--fs-h3)/var(--lh-h3) var(--font-display);letter-spacing:var(--ls-h3)}
.ot-h4{font:var(--fw-display-medium) var(--fs-h4)/var(--lh-h4) var(--font-display);letter-spacing:var(--ls-h4)}
.ot-question{font:var(--fw-display-medium) var(--fs-question)/var(--lh-question) var(--font-display);letter-spacing:var(--ls-question)}
.ot-body-lg{font:var(--fw-body) var(--fs-body-lg)/var(--lh-body-lg) var(--font-body)}
.ot-body{font:var(--fw-body) var(--fs-body)/var(--lh-body) var(--font-body)}
.ot-body-sm{font:var(--fw-body) var(--fs-body-sm)/var(--lh-body-sm) var(--font-body)}
.ot-caption{font:var(--fw-body) var(--fs-caption)/var(--lh-caption) var(--font-body);letter-spacing:var(--ls-caption)}
.ot-overline{font:var(--fw-semibold) var(--fs-overline)/var(--lh-overline) var(--font-body);letter-spacing:var(--ls-overline);text-transform:uppercase}
.ot-num{font-family:var(--font-numeric);font-variant-numeric:tabular-nums;font-feature-settings:"tnum" 1}
.ot-score{font:var(--fw-semibold) var(--fs-score)/var(--lh-score) var(--font-numeric);letter-spacing:var(--ls-score);font-variant-numeric:tabular-nums}

/* ===== spacing.css ===== */
:root{
  /* 8px base scale (2px and 4px available for optical fixes) */
  --space-0:0;
  --space-025:2px;
  --space-05:4px;
  --space-1:8px;
  --space-2:16px;
  --space-3:24px;
  --space-4:32px;
  --space-5:40px;
  --space-6:48px;
  --space-8:64px;
  --space-10:80px;
  --space-12:96px;
  --space-16:128px;

  /* Layout */
  --container-max:1200px;
  --container-narrow:720px;   /* question + result reading column */
  --gutter:24px;
  --gutter-mobile:16px;
  --nav-height:64px;
  --grid-gap:20px;

  /* Reserved ad slot dimensions (IAB) — containers exist now, ads land later */
  --ad-leaderboard-w:728px;  --ad-leaderboard-h:90px;
  --ad-leaderboard-mobile-w:320px; --ad-leaderboard-mobile-h:100px;
  --ad-incontent-w:336px;    --ad-incontent-h:280px;
  --ad-sidebar-w:300px;      --ad-sidebar-h:600px;

  /* Minimum tap target */
  --tap-min:48px;/* @kind spacing */
}
@media (max-width:768px){:root{--gutter:16px;--grid-gap:12px}}

/* ===== radius.css ===== */
:root{
  --radius-xs:4px;
  --radius-sm:8px;
  --radius-md:12px;   /* buttons, inputs, chips */
  --radius-lg:16px;   /* cards, answer options */
  --radius-xl:24px;   /* panels, hero blocks, modals */
  --radius-pill:999px;
  --border-width:1px;/* @kind radius */
  --border-width-strong:2px;/* @kind radius */
  --focus-width:3px;/* @kind radius */
  --focus-offset:2px;/* @kind radius */
}

/* ===== elevation.css ===== */
:root{
  /* Elevation is deliberately restrained: borders do the work, shadows only lift.
     e0 flat · e1 card hover · e2 sticky nav / dropdown · e3 modal */
  --elev-0:none;
  --elev-1:0 1px 2px rgba(11,13,18,.04), 0 2px 8px rgba(11,13,18,.06);
  --elev-2:0 2px 4px rgba(11,13,18,.05), 0 8px 24px rgba(11,13,18,.08);
  --elev-3:0 8px 16px rgba(11,13,18,.08), 0 24px 48px rgba(11,13,18,.14);
  --elev-focus:0 0 0 var(--focus-width) color-mix(in srgb, var(--focus-ring) 45%, transparent);
  --elev-inset:inset 0 1px 0 rgba(255,255,255,.6);
}
[data-theme="dark"]{
  --elev-1:0 1px 2px rgba(0,0,0,.4), 0 2px 8px rgba(0,0,0,.3);
  --elev-2:0 2px 4px rgba(0,0,0,.45), 0 8px 24px rgba(0,0,0,.4);
  --elev-3:0 8px 16px rgba(0,0,0,.5), 0 24px 48px rgba(0,0,0,.55);
  --elev-inset:inset 0 1px 0 rgba(255,255,255,.05);
}

/* ===== motion.css ===== */
:root{
  --dur-instant:80ms;/* @kind other */
  --dur-fast:140ms;/* @kind other */
  --dur-base:200ms;/* @kind other */
  --dur-slow:320ms;/* @kind other */
  --dur-reveal:600ms;/* @kind other */      /* score dial / result reveal */
  --ease-out:cubic-bezier(.22,.7,.3,1);/* @kind other */
  --ease-in-out:cubic-bezier(.5,0,.3,1);/* @kind other */
  --ease-spring:cubic-bezier(.34,1.3,.5,1);/* @kind other */
  --transition-ui:background-color var(--dur-fast) var(--ease-out),border-color var(--dur-fast) var(--ease-out),color var(--dur-fast) var(--ease-out),box-shadow var(--dur-fast) var(--ease-out),transform var(--dur-fast) var(--ease-out);/* @kind other */
  --press-scale:.985;/* @kind other */
}
@media (prefers-reduced-motion:reduce){
  :root{--dur-instant:0ms;/* @kind other */--dur-fast:0ms;/* @kind other */--dur-base:0ms;/* @kind other */--dur-slow:0ms;/* @kind other */--dur-reveal:0ms;/* @kind other */--press-scale:1;/* @kind other */}
}

/* ===== base.css ===== */
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--surface-page);color:var(--text-primary);font:var(--fw-body) var(--fs-body)/var(--lh-body) var(--font-body);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
h1,h2,h3,h4{margin:0;font-family:var(--font-display);color:var(--text-primary);text-wrap:balance}
p{margin:0;text-wrap:pretty}
a{color:var(--text-link);text-decoration:none}
a:hover{color:var(--text-link-hover);text-decoration:underline;text-underline-offset:2px}
button,input,select,textarea{font:inherit;color:inherit}
img,svg{display:block;max-width:100%}
:focus-visible{outline:var(--focus-width) solid var(--focus-ring);outline-offset:var(--focus-offset);border-radius:var(--radius-xs)}
[hidden]{display:none!important}

/* Layout helpers */
.ot-container{width:100%;max-width:var(--container-max);margin-inline:auto;padding-inline:var(--gutter)}
.ot-narrow{width:100%;max-width:var(--container-narrow);margin-inline:auto;padding-inline:var(--gutter)}

/* Icon glyphs — Lucide (outline, 24px grid) tinted with currentColor via CSS mask.
   No inline SVG, no icon font: one CDN request per glyph, colourable, crisp. */
:root{--icon-src:"../icons/";/* @kind other */}
.ot-icon{display:inline-block;width:24px;height:24px;flex:none;background-color:currentColor;-webkit-mask:var(--i) no-repeat center/contain;mask:var(--i) no-repeat center/contain}

/* On-accent type safety net — a solid accent fill must always pair with --accent-on
   (white on the five dark hues, near-black on amber). Higher specificity than the
   components' own injected rules so a light fill can never render white type. */
[data-category].otcard--solid .otcard__title,[data-category].otcard--solid .otcard__cat{color:var(--accent-on)}
[data-category].otcard--solid .otcard__desc,[data-category].otcard--solid .otcard__meta{color:color-mix(in srgb,var(--accent-on) 85%,transparent)}
[data-category].otcard--solid .otcard__meta{border-top-color:color-mix(in srgb,var(--accent-on) 28%,transparent)}
[data-category].otcard--solid .otcard__glyph{background:color-mix(in srgb,var(--accent-on) 18%,transparent);color:var(--accent-on)}
[data-category].otcard--solid .otbadge{background:color-mix(in srgb,var(--accent-on) 20%,transparent);border-color:color-mix(in srgb,var(--accent-on) 40%,transparent);color:var(--accent-on)}
.otdial--inv .otdial__fill{stroke:var(--accent-on)!important}
.otdial--inv .otdial__track{stroke:color-mix(in srgb,var(--accent-on) 28%,transparent)!important}
.otdial--inv .otdial__value{color:var(--accent-on)!important}
.otdial--inv .otdial__label{color:color-mix(in srgb,var(--accent-on) 80%,transparent)!important}

/* Reserved ad containers — fixed dimensions, zero layout shift when filled */
.ot-ad{display:grid;place-items:center;background:var(--ad-bg);border:1px dashed var(--ad-border);border-radius:var(--radius-md);color:var(--ad-label);font:var(--fw-semibold) var(--fs-overline)/1 var(--font-body);letter-spacing:var(--ls-overline);text-transform:uppercase}

/* ===== site.css ===== */
/* ==========================================================================
   OpenThinkr — site layer
   Homepage composition built on the design system tokens. No hardcoded
   palette, type or spacing values: everything reads from tokens/*.css.
   ========================================================================== */

/* --------------------------------------------------------------------------
   Icons — Lucide glyphs served locally, tinted with currentColor via CSS mask.
   One class per glyph so no URL ever appears inline in the markup.
   -------------------------------------------------------------------------- */
.i-layout-grid  { --i: url("../icons/layout-grid.svg") }
.i-chevron-down { --i: url("../icons/chevron-down.svg") }
.i-languages    { --i: url("../icons/languages.svg") }
.i-moon         { --i: url("../icons/moon.svg") }
.i-sun          { --i: url("../icons/sun.svg") }
.i-arrow-right  { --i: url("../icons/arrow-right.svg") }
.i-brain        { --i: url("../icons/brain.svg") }
.i-user-round   { --i: url("../icons/user-round.svg") }
.i-briefcase    { --i: url("../icons/briefcase.svg") }
.i-gamepad-2    { --i: url("../icons/gamepad-2.svg") }
.i-lightbulb    { --i: url("../icons/lightbulb.svg") }
.i-zap          { --i: url("../icons/zap.svg") }
.i-ruler        { --i: url("../icons/ruler.svg") }
.i-share-2      { --i: url("../icons/share-2.svg") }
.i-clock        { --i: url("../icons/clock.svg") }
.i-list         { --i: url("../icons/list.svg") }
.i-timer        { --i: url("../icons/timer.svg") }
.i-plus         { --i: url("../icons/plus.svg") }
.i-minus        { --i: url("../icons/minus.svg") }

.ot-icon--14 { width: 14px; height: 14px }
.ot-icon--16 { width: 16px; height: 16px }
.ot-icon--20 { width: 20px; height: 20px }

/* --------------------------------------------------------------------------
   Buttons
   -------------------------------------------------------------------------- */
.otb {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  font-family: var(--font-body);
  font-weight: var(--fw-semibold);
  border: var(--border-width) solid transparent;
  border-radius: var(--radius-md);
  cursor: pointer;
  text-decoration: none;
  transition: var(--transition-ui);
  padding-block: 10px;
}
.otb:hover { text-decoration: none }
.otb:active:not(:disabled) { transform: scale(var(--press-scale)) }
.otb:disabled {
  background: var(--action-disabled-bg);
  border-color: transparent;
  color: var(--action-disabled-fg);
  cursor: not-allowed;
}

.otb--md { min-height: 44px; padding-inline: var(--space-3); font-size: var(--fs-body) }
.otb--lg { min-height: 56px; padding-inline: var(--space-4); font-size: var(--fs-body-lg) }

.otb--primary { background: var(--action-primary-bg); color: var(--action-primary-fg) }
.otb--primary:hover { background: var(--action-primary-bg-hover); color: var(--action-primary-fg) }
.otb--primary:active { background: var(--action-primary-bg-active) }

.otb--secondary {
  background: var(--action-secondary-bg);
  color: var(--action-secondary-fg);
  border-color: var(--action-secondary-border);
}
.otb--secondary:hover { background: var(--action-secondary-bg-hover); border-color: var(--blue-300); color: var(--action-secondary-fg) }

.otb--accent { background: var(--accent-deep); color: var(--accent-on) }
.otb--accent:hover { filter: brightness(.93); color: var(--accent-on) }

/* --------------------------------------------------------------------------
   Header
   -------------------------------------------------------------------------- */
.site-header {
  position: sticky;
  top: 0;
  z-index: 40;
  background: color-mix(in srgb, var(--surface-card) 90%, transparent);
  backdrop-filter: saturate(1.4) blur(10px);
  border-bottom: 1px solid var(--border-subtle);
}
.site-header__inner {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--nav-height);
  max-width: var(--container-max);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.logo {
  display: inline-flex;
  align-items: center;
  gap: .42em;
  color: var(--text-primary);
  text-decoration: none;
  line-height: 1;
}
.logo:hover { color: var(--text-primary); text-decoration: none }
.logo__mark { width: 30px; height: 30px; flex: none }
.logo__type {
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h4);
  letter-spacing: -.03em;
  white-space: nowrap;
}
.logo__accent { color: var(--blue-500) }

.site-nav {
  display: flex;
  align-items: center;
  gap: 2px;
  margin-left: auto;
}
.site-nav__link {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 8px 12px;
  border: 0;
  border-radius: var(--radius-md);
  background: transparent;
  color: var(--text-secondary);
  font-family: var(--font-body);
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-medium);
  white-space: nowrap;
  text-decoration: none;
  transition: var(--transition-ui);
}
.site-nav__link:hover { background: var(--action-ghost-bg-hover); color: var(--text-primary); text-decoration: none }
.site-nav__trigger { color: var(--text-primary); cursor: pointer }
.site-nav__count { color: var(--text-tertiary); font-variant-numeric: tabular-nums }
.site-nav__chevron { transition: transform var(--dur-fast) var(--ease-out) }
.site-nav__trigger[aria-expanded="true"] { background: var(--action-ghost-bg-hover) }
.site-nav__trigger[aria-expanded="true"] .site-nav__chevron { transform: rotate(180deg) }

/* --------------------------------------------------------------------------
   Mega menu — the "browse all tests" dropdown, ported from the homepage
   template. Anchored to the header so it spans the container, not the trigger.
   -------------------------------------------------------------------------- */
.site-nav__group { padding-bottom: 12px; margin-bottom: -12px }

.mega {
  position: absolute;
  top: 100%;
  right: var(--gutter);
  z-index: 50;
  width: min(calc(100vw - 2 * var(--gutter)), 780px);
  max-height: calc(100vh - var(--nav-height) - var(--space-4));
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: var(--space-3);
  background: var(--surface-card);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-xl);
  box-shadow: var(--elev-2);
}
.mega[hidden] { display: none }

.mega__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  align-items: start;          /* columns size to their own content, not the row */
  gap: var(--space-3);
}
.mega__col { display: flex; flex-direction: column }

.mega__head {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 8px 8px;
  border-bottom: 2px solid var(--accent);
  color: var(--text-primary);
  text-decoration: none;
}
.mega__head:hover { color: var(--text-primary); text-decoration: none }
.mega__col--soon .mega__head { border-bottom-color: var(--border-default) }

.mega__glyph {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  flex: none;
  border-radius: var(--radius-sm);
  background: var(--accent-tint);
  color: var(--accent-fg);
}
.mega__col--soon .mega__glyph { background: var(--surface-sunken); color: var(--text-tertiary) }

.mega__cat {
  font-family: var(--font-display);
  font-weight: var(--fw-display-medium);
  font-size: var(--fs-body);
}
.mega__count {
  margin-left: auto;
  font-size: var(--fs-caption);
  color: var(--text-tertiary);
  font-variant-numeric: tabular-nums;
}

.mega__item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  min-height: 34px;
  margin-top: 2px;
  padding: 0 8px;
  border-radius: var(--radius-sm);
  color: var(--text-secondary);
  font-size: var(--fs-body-sm);
  text-decoration: none;
  transition: var(--transition-ui);
}
a.mega__item:hover { background: var(--accent-tint); color: var(--accent-fg); text-decoration: none }
.mega__item--soon { color: var(--text-tertiary); font-style: normal }
.mega__meta {
  flex: none;
  white-space: nowrap;
  color: var(--text-tertiary);
  font-size: var(--fs-caption);
  font-variant-numeric: tabular-nums;
}

.mega__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  flex-wrap: wrap;
  margin-top: var(--space-3);
  padding-top: var(--space-3);
  border-top: 1px solid var(--border-subtle);
}
.mega__note { font-size: var(--fs-body-sm); color: var(--text-secondary) }
.mega__foot-links { display: flex; align-items: center; gap: var(--space-3) }
.mega__foot-link {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-medium);
}
/* About us and Blog only surface here once they leave the top bar */
.mega__foot-link--small { display: none }

.icon-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 36px;
  height: 36px;
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
  background: var(--surface-card);
  color: var(--text-secondary);
  cursor: pointer;
  transition: var(--transition-ui);
}
.icon-button:hover { border-color: var(--blue-500); color: var(--text-primary) }

/* The six-hue palette spine under the header */
.palette-spine {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  height: 8px;
}
.palette-spine span { background: var(--accent) }

/* --------------------------------------------------------------------------
   Reserved ad slots — containers only, no scripts. Present from day one so
   switching ads on later cannot cause a layout shift or a redesign.
   -------------------------------------------------------------------------- */
.ad-rail { display: grid; place-items: center; padding: var(--space-3) var(--gutter) 0 }
.ad-rail--footer { padding: var(--space-4) var(--gutter) 0 }
.ot-ad--leaderboard {
  width: min(100%, var(--ad-leaderboard-w));
  height: var(--ad-leaderboard-h);
}
@media (max-width: 768px) {
  .ot-ad--leaderboard {
    width: min(100%, var(--ad-leaderboard-mobile-w));
    height: var(--ad-leaderboard-mobile-h);
  }
}

/* --------------------------------------------------------------------------
   Shared section furniture
   -------------------------------------------------------------------------- */
.section {
  max-width: var(--container-max);
  margin-inline: auto;
  padding: 0 var(--gutter) var(--space-8);
  /* anchor jumps must clear the sticky header */
  scroll-margin-top: calc(var(--nav-height) + var(--space-3));
}
.section__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2);
  margin-bottom: var(--space-3);
  flex-wrap: wrap;
}
.section__title {
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h2);
  line-height: var(--lh-h2);
  letter-spacing: var(--ls-h2);
}
/* Explainer prose on the test hub. Deliberately NOT `.prose`: that class sets
   its own container, gutter and a `.prose h2` rule at --fs-h3, which would
   render these headings SMALLER than the plain `.section__title` headings they
   sit between on the same page. This gives the paragraph rhythm and the reading
   measure and nothing else, so the h2s stay peers of their neighbours. */
.explainer > * + * {
  margin-top: var(--space-3);
}
.explainer p,
.explainer ul {
  max-width: var(--container-narrow);
  color: var(--text-secondary);
}
.explainer ul {
  margin-left: var(--space-4);
  list-style: disc;
}
.explainer li + li {
  margin-top: var(--space-1);
}

.section__aside { font-size: var(--fs-body-sm); color: var(--text-tertiary) }

.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 5px 12px;
  border-radius: var(--radius-pill);
  background: var(--accent-tint);
  color: var(--accent-fg);
  font-size: var(--fs-caption);
  font-weight: var(--fw-medium);
  letter-spacing: .04em;
  text-transform: uppercase;
}

/* --------------------------------------------------------------------------
   Hero
   -------------------------------------------------------------------------- */
.hero {
  max-width: var(--container-max);
  margin-inline: auto;
  padding: var(--space-6) var(--gutter) var(--space-8);
}
.hero__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
  gap: var(--space-6);
  align-items: center;
}
.hero__copy {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-3);
}
.hero__title {
  max-width: 16ch;
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-display-1);
  line-height: var(--lh-display-1);
  letter-spacing: var(--ls-display-1);
}
.hero__support {
  max-width: 46ch;
  font-size: var(--fs-body-lg);
  line-height: var(--lh-body-lg);
  color: var(--text-secondary);
}
.hero__actions { display: flex; flex-wrap: wrap; gap: var(--space-2) }
.hero__note { font-size: var(--fs-body-sm); color: var(--text-tertiary) }

/* Featured card — the one live test, standing in for the sample-question panel */
.feature-card {
  position: relative;
  width: 100%;
  max-width: 520px;
  justify-self: end;
  padding: var(--space-4);
  background: var(--surface-card);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-xl);
  box-shadow: var(--elev-2);
}
.feature-card__meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  font-size: var(--fs-caption);
  color: var(--text-tertiary);
  font-variant-numeric: tabular-nums;
}
.feature-card__title {
  margin-top: var(--space-2);
  font-family: var(--font-display);
  font-weight: var(--fw-display-medium);
  font-size: var(--fs-h3);
  line-height: var(--lh-h3);
}
.feature-card__desc {
  margin-top: 8px;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
}
/* Three facts about the test, stacked as label-over-value rows. Rows rather than
   a 3-up grid: the values are sentences of uneven length, and in columns the
   short one leaves a ragged gap while the long one wraps to three lines. */
.feature-card__facts {
  margin-top: var(--space-3);
  border-top: 1px solid var(--border-subtle);
}
.fact {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
  padding: 10px 0;
  border-bottom: 1px solid var(--border-subtle);
}
/* The foot draws its own top border. Without this the last row's border and the
   foot's stack into a double rule with a band of dead space between them. */
.fact:last-child { border-bottom: 0 }
.fact__label {
  flex: none;
  font-size: var(--fs-caption);
  /* ⛔ NOT --text-tertiary. At caption size on the white card it measures 3.89:1,
     under the 4.5:1 AA floor for text below 18px. --text-secondary is 6.36:1. */
  color: var(--text-secondary);
}
.fact__value {
  margin: 0;
  text-align: right;
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-semibold);
  color: var(--accent-fg);
  font-variant-numeric: tabular-nums;
}
.feature-card__alt {
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-semibold);
}

.feature-card__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  margin-top: var(--space-3);
  padding-top: var(--space-3);
  border-top: 1px solid var(--border-subtle);
  font-size: var(--fs-body-sm);
  color: var(--text-tertiary);
}

.live-dot {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: var(--accent-fg);
  font-weight: var(--fw-medium);
}
.live-dot::before {
  content: "";
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--accent);
}

/* --------------------------------------------------------------------------
   Promise strip — what the site guarantees. Facts only, no vanity metrics.
   -------------------------------------------------------------------------- */
.promise {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 1px;
  background: var(--border-subtle);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-xl);
  overflow: hidden;
}
.promise__item { background: var(--surface-card); padding: var(--space-4) var(--space-3) }
.promise__value {
  font-family: var(--font-body);
  font-size: var(--fs-h2);
  font-weight: var(--fw-semibold);
  font-variant-numeric: tabular-nums;
  letter-spacing: -.01em;
}
.promise__label { margin-top: 4px; font-size: var(--fs-body-sm); color: var(--text-secondary) }

/* --------------------------------------------------------------------------
   Badge — ported from the design system's Badge component
   -------------------------------------------------------------------------- */
.otbadge {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 2px 8px;
  font-family: var(--font-body);
  font-size: var(--fs-caption);
  font-weight: var(--fw-semibold);
  border: var(--border-width) solid transparent;
  border-radius: var(--radius-pill);
  white-space: nowrap;
}
.otbadge--square { border-radius: var(--radius-xs) }
.otbadge.is-neutral {
  background: var(--surface-sunken);
  color: var(--text-secondary);
  border-color: var(--border-subtle);
}
.otbadge.is-accent {
  background: var(--accent-tint);
  color: var(--accent-fg);
  border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}

/* --------------------------------------------------------------------------
   Test card — ported from the design system's TestCard component.
   `--solid` is the full-bleed accent treatment used on the homepage grid.
   -------------------------------------------------------------------------- */
.otcard {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  height: 100%;
  padding: var(--space-3);
  background: var(--surface-card);
  border: var(--border-width) solid var(--border-subtle);
  border-radius: var(--radius-lg);
  overflow: hidden;
  color: inherit;
  text-decoration: none;
  transition: var(--transition-ui);
}
.otcard::before {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  height: 3px;
  background: var(--accent);
}
.otcard:hover {
  color: inherit;
  text-decoration: none;
  border-color: color-mix(in srgb, var(--accent) 45%, transparent);
  box-shadow: var(--elev-1);
  transform: translateY(-2px);
}
.otcard:active { transform: translateY(0) scale(var(--press-scale)) }

.otcard__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  margin-top: var(--space-05);
}
.otcard__cat {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: var(--accent-fg);
  font-size: var(--fs-caption);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-caption);
}
.otcard__glyph {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: var(--radius-md);
  background: var(--accent-tint);
  color: var(--accent-fg);
}
.otcard__title {
  font: var(--fw-display-bold) var(--fs-h4)/var(--lh-h4) var(--font-display);
  letter-spacing: var(--ls-h4);
  color: var(--text-primary);
}
.otcard__desc {
  margin-top: var(--space-05);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
}
.otcard__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  margin-top: auto;
  padding-top: var(--space-1);
  border-top: var(--border-width) solid var(--border-subtle);
  color: var(--text-tertiary);
  font-size: var(--fs-caption);
}
.otcard__meta span { display: inline-flex; align-items: center; gap: 4px }
.otcard__meta b { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums }
.otcard__meta .otcard__end { margin-left: auto }

/* Solid variant */
.otcard--solid { background: var(--accent-deep); border-color: var(--accent-deep) }
.otcard--solid::before { background: color-mix(in srgb, var(--accent-on) 35%, transparent) }
.otcard--solid:hover { border-color: var(--accent-deep); filter: brightness(1.08); box-shadow: var(--elev-1) }
.otcard--solid .otcard__cat,
.otcard--solid .otcard__title { color: var(--accent-on) }
.otcard--solid .otcard__desc { color: color-mix(in srgb, var(--accent-on) 85%, transparent) }
.otcard--solid .otcard__glyph {
  background: color-mix(in srgb, var(--accent-on) 18%, transparent);
  color: var(--accent-on);
}
.otcard--solid .otcard__meta {
  color: color-mix(in srgb, var(--accent-on) 85%, transparent);
  border-top-color: color-mix(in srgb, var(--accent-on) 28%, transparent);
}
.otcard--solid .otbadge {
  background: color-mix(in srgb, var(--accent-on) 20%, transparent);
  border-color: color-mix(in srgb, var(--accent-on) 40%, transparent);
  color: var(--accent-on);
}

/* Not-yet-built cards: full accent fill so the palette stays intact, but no
   link affordance. The "Coming soon" badge carries the meaning, not a dim. */
.otcard--soon { cursor: default }
.otcard--soon:hover { transform: none; filter: none; box-shadow: none }

.test-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--grid-gap);
}
@media (max-width: 1000px) { .test-grid { grid-template-columns: repeat(2, 1fr) } }
@media (max-width: 640px)  { .test-grid { grid-template-columns: 1fr } }

/* --------------------------------------------------------------------------
   Category grid
   -------------------------------------------------------------------------- */
/* Explicit column counts, not auto-fill: six categories must land on even rows
   (6 / 3 / 2), never five-plus-one orphan. */
.category-grid {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: var(--grid-gap);
}
@media (max-width: 1100px) { .category-grid { grid-template-columns: repeat(3, 1fr) } }
@media (max-width: 620px)  { .category-grid { grid-template-columns: repeat(2, 1fr) } }
.category-tile {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-3);
  background: var(--surface-card);
  border: 1px solid var(--border-subtle);
  border-top: 3px solid var(--accent);
  border-radius: var(--radius-lg);
  color: var(--text-primary);
  text-decoration: none;
  transition: var(--transition-ui);
}
.category-tile:hover {
  color: var(--text-primary);
  text-decoration: none;
  border-color: var(--accent);
  box-shadow: var(--elev-1);
  transform: translateY(-2px);
}
.category-tile__glyph {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: var(--radius-md);
  background: var(--accent-tint);
  color: var(--accent-fg);
}
.category-tile__name {
  font-family: var(--font-display);
  font-weight: var(--fw-display-medium);
  font-size: var(--fs-h4);
}
.category-tile__meta {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: auto;          /* pins the meta line to the bottom of every tile */
  padding-top: var(--space-1);
  font-size: var(--fs-body-sm);
  color: var(--text-secondary);
}

/* Coming-soon tiles keep full accent saturation — the palette is how the site
   says which category you are in, so dimming five of six breaks the spine.
   The "0 tests" count carries the meaning. They are simply not links. */
.category-tile--soon { cursor: default }
.category-tile--soon:hover {
  border-color: var(--border-subtle);
  border-top-color: var(--accent);
  box-shadow: none;
  transform: none;
}

/* --------------------------------------------------------------------------
   Why blocks
   -------------------------------------------------------------------------- */
.why-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: var(--grid-gap);
}
.why-card {
  padding: var(--space-3);
  background: var(--surface-card);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
}
.why-card__glyph {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: var(--radius-md);
  background: var(--accent-tint);
  color: var(--accent-fg);
}
.why-card__title {
  margin-top: var(--space-2);
  font-family: var(--font-display);
  font-weight: var(--fw-display-medium);
  font-size: var(--fs-h4);
  line-height: var(--lh-h4);
}
.why-card__body {
  margin-top: 6px;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
}

/* --------------------------------------------------------------------------
   FAQ
   -------------------------------------------------------------------------- */
.faq {
  display: grid;
  grid-template-columns: minmax(240px, 300px) 1fr;
  gap: var(--space-5);
  align-items: start;
}
.faq__intro { margin-top: var(--space-2); font-size: var(--fs-body); line-height: var(--lh-body); color: var(--text-secondary) }
.faq__list {
  background: var(--surface-card);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
  overflow: hidden;
}
.faq__item + .faq__item { border-top: 1px solid var(--border-subtle) }
.faq__q {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  width: 100%;
  min-height: var(--tap-min);
  padding: var(--space-2) var(--space-3);
  border: 0;
  background: transparent;
  color: var(--text-primary);
  font-family: var(--font-display);
  font-weight: var(--fw-display-medium);
  font-size: var(--fs-body-lg);
  text-align: left;
  cursor: pointer;
  transition: var(--transition-ui);
}
.faq__q:hover { background: var(--surface-sunken) }
.faq__q .ot-icon { color: var(--text-tertiary) }
.faq__a {
  padding: 0 var(--space-3) var(--space-3);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--text-secondary);
  max-width: 68ch;
}
.faq__item[data-open="false"] .faq__a { display: none }
.faq__item[data-open="false"] .i-minus,
.faq__item[data-open="true"] .i-plus { display: none }

@media (max-width: 860px) {
  .faq { grid-template-columns: 1fr; gap: var(--space-3) }
}

/* --------------------------------------------------------------------------
   Footer
   -------------------------------------------------------------------------- */
.site-footer {
  background: var(--surface-card);
  border-top: 1px solid var(--border-subtle);
  padding-block: var(--space-6) var(--space-4);
}
.site-footer__inner {
  max-width: var(--container-max);
  margin-inline: auto;
  padding-inline: var(--gutter);
}
/* Four columns: brand, categories, popular tests, site links. The brand column
   is widest so the blurb keeps a readable measure; the tests column gets a
   little extra because a test name is a full phrase, not a single word. */
.site-footer__top {
  display: grid;
  grid-template-columns: 1.6fr 1fr 1.1fr 0.8fr;
  gap: var(--space-6);
  align-items: start;
}
.site-footer__brand {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
}
.site-footer__blurb {
  margin-top: var(--space-2);
  max-width: 38ch;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
}
.site-footer__heading {
  display: block;
  color: var(--text-tertiary);
}
.site-footer__list {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  margin-top: var(--space-2);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
}
.site-footer__list a {
  color: var(--text-secondary);
}
.site-footer__list a:hover {
  color: var(--text-link-hover);
}
/* Categories with no test yet. Text, never a link, so nothing in the footer
   promises a page that does not exist. */
.site-footer__soon {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  color: var(--text-tertiary);
}
.site-footer__tag {
  font-size: var(--fs-caption);
  color: var(--text-tertiary);
  opacity: 0.75;
}
.site-footer__bar {
  margin-top: var(--space-4);
  padding-top: var(--space-3);
  border-top: 1px solid var(--border-subtle);
}
.site-footer__note {
  max-width: 80ch;
  font-size: var(--fs-caption);
  line-height: var(--lh-body-sm);
  color: var(--text-tertiary);
}
.site-footer__legal {
  margin-top: var(--space-2);
  font-size: var(--fs-caption);
  color: var(--text-tertiary);
}

@media (max-width: 900px) {
  .site-footer__top {
    grid-template-columns: 1fr 1fr;
    gap: var(--space-5);
  }
  .site-footer__brand { grid-column: 1 / -1 }
}
@media (max-width: 480px) {
  .site-footer__top { grid-template-columns: 1fr }
}

/* --------------------------------------------------------------------------
   Small screens
   -------------------------------------------------------------------------- */
/* Below 900px the top-bar links move into the dropdown's footer so the header
   stays on one line. Nothing becomes unreachable. */
@media (max-width: 900px) {
  .site-nav__link--secondary { display: none }
  .mega__foot-link--small { display: inline-flex }
  .mega__note { display: none }
  .mega__foot { justify-content: flex-end }
}
@media (max-width: 860px) { .mega__grid { grid-template-columns: repeat(2, 1fr) } }
@media (max-width: 560px) {
  .mega__grid { grid-template-columns: 1fr }
  .mega { width: calc(100vw - 2 * var(--gutter)) }
  .site-nav__trigger .site-nav__count { display: none }
}
@media (max-width: 620px) {
  .feature-card { justify-self: stretch; padding: var(--space-3) }
  /* Label and value stop sharing a line once the value would wrap to two. */
  .fact { display: block }
  .fact__value { text-align: left; margin-top: 2px }
  /* Side by side both CTAs wrap to two lines at 390px. Stacked, the primary
     action goes full width and reads first. */
  .feature-card__foot { flex-direction: column-reverse; align-items: stretch; gap: var(--space-3) }
  .feature-card__foot .otb { justify-content: center }
  .feature-card__alt { text-align: center }
  .hero__actions .otb { width: 100% }
}

/* Skip link */
.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  padding: var(--space-2) var(--space-3);
  background: var(--surface-card);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
}
.skip-link:focus { left: var(--gutter); top: var(--space-1) }

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

/* ---------------------------------------------------------------------------
   Content pages
   The title block and body copy for text pages: about, disclaimer, privacy,
   terms. Narrow measure, because these are read rather than scanned.
   --------------------------------------------------------------------------- */

.page-head {
  max-width: var(--container-max);
  margin-inline: auto;
  padding: var(--space-8) var(--gutter) var(--space-5);
}
.page-head__inner {
  max-width: var(--container-narrow);
}
.page-head__title {
  font-family: var(--font-display);
  font-size: var(--fs-h1);
  line-height: var(--lh-h1);
  letter-spacing: var(--ls-h1);
  font-weight: var(--fw-display-bold);
  color: var(--text-primary);
  margin: var(--space-2) 0 0;
}
.page-head__lede {
  font-size: var(--fs-body-lg);
  line-height: var(--lh-body-lg);
  color: var(--text-secondary);
  margin: var(--space-3) 0 0;
}

.prose {
  max-width: var(--container-max);
  margin-inline: auto;
  padding: 0 var(--gutter) var(--space-10);
  color: var(--text-secondary);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}
/* Constrain the measure on each block rather than on the container, so the
   copy stays left-aligned with the page title instead of centring under it. */
.prose > * {
  max-width: var(--container-narrow);
}
.prose > * + * {
  margin-top: var(--space-3);
}
.prose h2 {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  line-height: var(--lh-h3);
  letter-spacing: var(--ls-h3);
  font-weight: var(--fw-display-medium);
  color: var(--text-primary);
  margin-top: var(--space-6);
}
.prose h2:first-child {
  margin-top: 0;
}
.prose strong {
  color: var(--text-primary);
  font-weight: var(--fw-semibold);
}
.prose a {
  color: var(--text-link);
  text-underline-offset: 0.2em;
}
.prose a:hover {
  color: var(--text-link-hover);
}
.prose ul {
  margin-left: var(--space-4);
  list-style: disc;
}
.prose li + li {
  margin-top: var(--space-1);
}
/* Inline literals in the legal pages: storage keys, hostnames, values. */
.prose code {
  padding: 0.1em 0.35em;
  border-radius: var(--radius-sm);
  background: var(--surface-card);
  font-family: var(--font-numeric);
  font-size: 0.9em;
  color: var(--text-primary);
}
/* "Last updated" line that closes each legal page. */
.prose__meta {
  margin-top: var(--space-6);
  padding-top: var(--space-3);
  border-top: var(--border-width) solid var(--border-default);
  font-size: var(--fs-body-sm);
  color: var(--text-tertiary);
}


/* ===== games.css ===== */
/* --------------------------------------------------------------------------
   Games layer — the test hub, the game pages, and the play surface itself.
   Every value here comes from the token files. No new colours, type sizes or
   spacing steps are invented in this file.
   -------------------------------------------------------------------------- */

/* --------------------------------------------------------------------------
   Page frame — main column plus the reserved sidebar ad rail
   -------------------------------------------------------------------------- */
.test-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 300px;
  gap: var(--space-6);
  align-items: start;
  max-width: var(--container-max);
  margin-inline: auto;
  padding: var(--space-4) var(--gutter) var(--space-8);
}
.test-main {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-5);
}
/* Reserved ad rail. Empty by design until roughly 10k monthly pageviews. */
.test-rail {
  position: sticky;
  top: calc(var(--nav-height) + var(--space-3));
}
.ot-ad--rail { min-height: 600px }
@media (max-width: 1080px) {
  .test-layout { grid-template-columns: minmax(0, 1fr) }
  .test-rail { display: none }
}

/* --------------------------------------------------------------------------
   Hub hero band. Ported from the design system's pymetrics-practice-test
   template, "heroLight" variant. Full width above the two-column layout, so
   the ad rail starts at the games section the way the template has it.
   -------------------------------------------------------------------------- */
.test-hero {
  border-bottom: var(--border-width) solid var(--border-subtle);
}
.test-hero__inner {
  max-width: var(--container-max);
  margin-inline: auto;
  padding: var(--space-4) var(--gutter) var(--space-8);
}
.test-hero__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 420px);
  align-items: center;
  gap: var(--space-5);
  margin-top: var(--space-6);
}
.test-hero__copy {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-3);
}
/* Plain overline, not the pill `.eyebrow` used on the home page. The design
   system's hero uses quiet tertiary text here so the headline carries the page. */
.test-hero__eyebrow { color: var(--text-tertiary) }
.test-hero__title {
  margin: 0;
  max-width: 24ch;
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-display-1);
  line-height: var(--lh-display-1);
  letter-spacing: var(--ls-display-1);
  text-wrap: balance;
}
/* The highlight is amber in both themes, so it always needs the dark ink. */
.test-hero__mark {
  padding: 0 var(--space-1);
  background: var(--amber-500);
  color: var(--n-900);
}
.test-hero__lede {
  margin: 0;
  max-width: 52ch;
  font-size: var(--fs-body-lg);
  line-height: var(--lh-body-lg);
  color: var(--text-secondary);
  text-wrap: pretty;
}
.test-hero__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-top: var(--space-2);
}
.test-hero__note {
  margin: var(--space-3) 0 0;
  max-width: 70ch;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
}

/* Decorative only, and hidden from assistive tech in the markup. */
.test-hero__shapes {
  position: relative;
  align-self: stretch;
  min-height: 280px;
  overflow: hidden;
}
.test-hero__ring,
.test-hero__triangle,
.test-hero__dot { position: absolute }
.test-hero__ring {
  top: var(--space-1);
  right: -72px;
  width: 236px;
  height: 236px;
  border: 12px solid var(--violet-500);
  border-radius: 50%;
}
.test-hero__triangle {
  bottom: 0;
  left: var(--space-1);
  width: 0;
  height: 0;
  border-left: 56px solid transparent;
  border-right: 56px solid transparent;
  border-bottom: 96px solid var(--amber-500);
}
.test-hero__dot {
  top: 96px;
  left: 104px;
  width: 76px;
  height: 76px;
  border-radius: 50%;
  background: var(--teal-500);
}

/* Stat strip. Every number in it is generated from the test data, never typed,
   so it cannot drift from what is actually built. */
.stat-strip {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
  gap: var(--border-width);
  max-width: 820px;
  margin-top: var(--space-5);
  background: var(--border-subtle);
  border: var(--border-width) solid var(--border-subtle);
  border-radius: var(--radius-sm);
  overflow: hidden;
}
.stat {
  display: flex;
  flex-direction: column;
  gap: var(--space-025);
  padding: var(--space-2);
  background: var(--surface-card);
}
.stat__value {
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h3);
  line-height: var(--lh-h3);
  letter-spacing: var(--ls-h3);
  font-variant-numeric: tabular-nums;
}
.stat__label {
  font-size: var(--fs-body-sm);
  color: var(--text-secondary);
}

@media (max-width: 900px) {
  .test-hero__grid { grid-template-columns: minmax(0, 1fr) }
  .test-hero__shapes { display: none }
}

/* --------------------------------------------------------------------------
   Breadcrumb
   -------------------------------------------------------------------------- */
.crumbs {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-1);
  font-size: var(--fs-body-sm);
  color: var(--text-tertiary);
}
.crumbs a { color: inherit }
.crumbs a:hover { color: var(--text-link-hover) }
.crumbs [aria-current] { color: var(--text-secondary) }

/* --------------------------------------------------------------------------
   Page header for a test or a game
   -------------------------------------------------------------------------- */
.test-head {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}
.test-head__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}
.test-head__num {
  display: grid;
  place-items: center;
  flex: none;
  width: 40px;
  height: 40px;
  border-radius: var(--radius-sm);
  background: var(--accent-tint);
  color: var(--accent-fg);
  font-size: var(--fs-body);
  font-weight: var(--fw-semibold);
  font-variant-numeric: tabular-nums;
}
.test-head__title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h1);
  line-height: var(--lh-h1);
  letter-spacing: var(--ls-h1);
}
.test-head__meta {
  margin-left: auto;
  font-size: var(--fs-body-sm);
  color: var(--text-tertiary);
  white-space: nowrap;
}
.test-head__lede {
  margin: 0;
  max-width: 64ch;
  font-size: var(--fs-body-lg);
  line-height: var(--lh-body-lg);
  color: var(--text-secondary);
  text-wrap: pretty;
}
.traits {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1);
}
.trait {
  padding: 5px 12px;
  border: var(--border-width) solid var(--border-subtle);
  border-radius: var(--radius-pill);
  font-size: var(--fs-caption);
  color: var(--text-secondary);
}
@media (max-width: 560px) {
  .test-head__meta { margin-left: 0; width: 100% }
}

/* --------------------------------------------------------------------------
   Unofficial-practice notice. Appears on the hub and on every game page,
   not only on /disclaimer.
   -------------------------------------------------------------------------- */
.notice {
  padding: var(--space-3) var(--space-4);
  background: var(--warning-bg);
  border: var(--border-width) solid var(--warning-border);
  border-radius: var(--radius-lg);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--warning-fg);
}
.notice strong { font-weight: var(--fw-semibold) }

/* --------------------------------------------------------------------------
   Instructions card
   -------------------------------------------------------------------------- */
.instructions {
  padding: var(--space-4);
  background: var(--surface-card);
  border: var(--border-width) solid var(--border-subtle);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius-lg);
}
.instructions__title {
  margin: 0 0 var(--space-2);
  font-family: var(--font-display);
  font-weight: var(--fw-display-medium);
  font-size: var(--fs-h4);
  line-height: var(--lh-h4);
}
.instructions ol {
  margin: 0;
  padding-left: 20px;
  display: flex;
  flex-direction: column;
  gap: var(--space-05);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--text-secondary);
}

/* --------------------------------------------------------------------------
   Play surface
   -------------------------------------------------------------------------- */
.gm {
  padding: var(--space-4);
  background: var(--surface-card);
  border: var(--border-width) solid var(--border-subtle);
  border-radius: var(--radius-xl);
}
.gm-idle {
  display: grid;
  place-items: center;
  min-height: 340px;
  border: var(--border-width) dashed var(--border-default);
  border-radius: var(--radius-lg);
  background: var(--surface-page);
}
.gm-panel {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

.gm-scoreboard {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
  padding-bottom: var(--space-3);
  border-bottom: var(--border-width) solid var(--border-subtle);
}
.gm-stat {
  display: flex;
  flex-direction: column;
  gap: var(--space-025);
}
.gm-stat--divider {
  padding-left: var(--space-3);
  border-left: var(--border-width) solid var(--border-subtle);
}
/* ⚠️ --text-secondary, NOT --text-tertiary. Tertiary (#78819A) measures 3.89:1
   on the card surface in the LIGHT theme and this label is 12px, so it failed
   WCAG AA on every one of the six games. The dark theme reads 5.07:1 and hid it
   completely, which is why it survived so long — money-exchange-1 found it in
   its own game and flagged it here as a site-wide fix it would not make
   unilaterally. Fixed with money-exchange-2, which is the seventh game that
   would have shipped with it. Shared chrome: this changes all of them. */
.gm-stat__label { color: var(--text-secondary) }
.gm-stat__value {
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h4);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

.gm-stage {
  display: grid;
  place-items: center;
  min-height: 340px;
  border: var(--border-width) solid var(--border-subtle);
  border-radius: var(--radius-lg);
  background: var(--surface-page);
  overflow: hidden;
}

.gm-controls {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}
.gm-action { flex: 1 1 200px }
.gm-controls--input { align-items: stretch }
.gm-input {
  flex: 1 1 200px;
  min-height: 56px;
  padding-inline: var(--space-3);
  border: var(--border-width) solid var(--border-default);
  border-radius: var(--radius-md);
  background: var(--surface-card);
  color: var(--text-primary);
  font-family: var(--font-numeric);
  font-size: var(--fs-h4);
  letter-spacing: var(--ls-h4);
}
.gm-input:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}
.gm-input:disabled {
  background: var(--surface-sunken);
  color: var(--text-tertiary);
}

/* Same fix, same reason as .gm-stat__label above. This one carries the rules of
   the game and the no-correct-answer statement, so it is the last text on the
   page that should be hard to read. */
.gm-hint {
  margin: 0;
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--text-secondary);
}
.gm-announce {
  margin: 0;
  min-height: var(--space-3);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
}

/* --------------------------------------------------------------------------
   Balloon
   -------------------------------------------------------------------------- */
/* The balloon grows upward from a fixed base, so it needs more headroom than the
   shared centred stage gives it. At full inflation it is 325px tall; centred in a
   340px stage it overflowed the top by 91px and `overflow: hidden` sliced it off.
   That was true of the old three-colour version too (~63px), just less visibly.
   Scoped to this game so digits and arrows keep their centring. */
.gm-stage--balloon {
  min-height: 400px;
  align-items: end;
  padding-block-end: var(--space-5);
}

.gm-balloon {
  --gm-inflate: 0;
  width: 96px;
  /* The artwork's own ratio (481x640). Any other value letterboxes inside the
     box or distorts the balloon, since the art is `contain`-fitted. */
  aspect-ratio: 481 / 640;
  background: var(--balloon-art) center / contain no-repeat;
  transform: scale(calc(1 + var(--gm-inflate) * 1.6));
  transform-origin: bottom center;
  transition: transform var(--dur-fast) var(--ease-spring), opacity var(--dur-fast) var(--ease-out);
}
/* Six colours, six files. Size is driven only by --gm-inflate, which uses one
   shared divisor, so no colour inflates faster than another and the artwork
   leaks nothing about that colour's odds. Paths are mirrored in balloon.js,
   which preloads them; change one and change the other. */
.gm-balloon[data-colour="pink"]   { --balloon-art: url("/assets/img/balloon-pink.png") }
.gm-balloon[data-colour="blue"]   { --balloon-art: url("/assets/img/balloon-blue.png") }
.gm-balloon[data-colour="red"]    { --balloon-art: url("/assets/img/balloon-red.png") }
.gm-balloon[data-colour="yellow"] { --balloon-art: url("/assets/img/balloon-yellow.png") }
.gm-balloon[data-colour="green"]  { --balloon-art: url("/assets/img/balloon-green.png") }
.gm-balloon[data-colour="orange"] { --balloon-art: url("/assets/img/balloon-orange.png") }
.gm-balloon.is-burst {
  opacity: 0;
  transform: scale(1.9);
}

/* Same rule the hub previews follow: the base style is the resting frame, so
   with motion off the balloon still shows its true inflation and a burst still
   clears it. Only the tweening goes. */
@media (prefers-reduced-motion: reduce) {
  .gm-balloon { transition: none }
}

/* --------------------------------------------------------------------------
   Digits
   -------------------------------------------------------------------------- */
.gm-digit {
  min-height: 1.2em;
  font-family: var(--font-numeric);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-display-2);
  line-height: var(--lh-display-2);
  font-variant-numeric: tabular-nums;
  color: var(--text-primary);
  opacity: 0;
  transition: opacity var(--dur-instant) var(--ease-out);
}
.gm-digit.is-on { opacity: 1 }

/* --------------------------------------------------------------------------
   Arrows
   -------------------------------------------------------------------------- */
.gm-arrows {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-4);
  border-radius: var(--radius-lg);
  transition: background-color var(--dur-fast) var(--ease-out);
}
.gm-arrows.is-right { background: var(--success-bg) }
.gm-arrows.is-wrong { background: var(--danger-bg) }

.gm-arrow {
  width: 40px;
  height: 40px;
  background: var(--arrow-fill, var(--text-primary));
  clip-path: polygon(0 35%, 55% 35%, 55% 12%, 100% 50%, 55% 88%, 55% 65%, 0 65%);
}
.gm-arrow[data-dir="left"] { transform: scaleX(-1) }
.gm-arrows[data-colour="blue"] .gm-arrow { --arrow-fill: var(--blue-500) }
.gm-arrows[data-colour="red"] .gm-arrow { --arrow-fill: var(--red-500) }

@media (max-width: 560px) {
  .gm-arrow { width: 28px; height: 28px }
  .gm-arrows { gap: var(--space-1) }
}

/* --------------------------------------------------------------------------
   Keypress

   The button is also the cue. Putting the GO/STOP signal anywhere else would
   add eye travel between the thing that says stop and the thing being hit, and
   that travel lands inside a reaction measure. See keypress-game-logic.md §5.
   -------------------------------------------------------------------------- */
/* --tap-size drives the button, the ring reach and the stage height together, so
   the three cannot drift apart. --burst-max is consumed by the @keyframes below
   as well as by this min-height calculation.

   The stage inherits `overflow: hidden` from .gm-stage, so anything wider than it
   gets sliced, and the decorations were being cut off at the old 366px height.

   The reach values are lower than the first attempt (1.28/1.55) for a reason worth
   keeping. --ease-out front-loads progress, so scale hits ~1.51 while opacity is
   already down to 0.04: the animation was spending its last 45% of wall-clock
   growing while effectively invisible. Those wasted frames were most of what the
   stage would have had to grow to fit. Pulling the reach back to what is actually
   visible fixes the clipping for +87px of stage instead of +163px.

   Deriving min-height from the same variables the @keyframes consume means a
   future change to either reach cannot silently reintroduce the clipping. */
.gm-stage--keypress {
  --tap-size: min(300px, 70vw);
  --ring-max: 1.25;
  --burst-max: 1.35;
  padding: var(--space-4);
  min-height: calc(var(--tap-size) * var(--burst-max) + var(--space-4) * 2);
}

/* Wrapper exists so the rings can be siblings of the button rather than children
   of it: they radiate past its edge, and nothing decorative sits inside the hit
   target. Sized by the button, which is its only in-flow child. */
.gm-tap-wrap {
  position: relative;
  display: grid;
  place-items: center;
}

.gm-tap {
  position: relative;
  z-index: 1;
  display: grid;
  place-content: center;
  gap: var(--space-1);
  width: var(--tap-size, min(300px, 70vw));
  aspect-ratio: 1;
  padding: 0;
  border: var(--border-width) solid transparent;
  border-radius: 50%;
  color: var(--text-inverse);
  cursor: pointer;

  /* Non-negotiable for a rapid-tap target. Without touch-action a fast tapper
     triggers double-tap-to-zoom and the run is over; without user-select the
     taps start selecting the label; without the tap highlight cleared, a grey
     flash at 6 Hz reads as a broken button. */
  touch-action: manipulation;
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;

  /* Asymmetric on purpose: the press goes down instantly (the 0s below) and
     eases back up over --dur-fast. That is how a physical button behaves, and it
     is the single biggest reason the old version felt mushy — it eased BOTH
     directions over 80ms while taps arrived every 160ms, so the scale never
     seated and never returned. Background stays fast and separate: it carries the
     GO/STOP cue in a reaction measure, so it must never be eased slowly. */
  transition:
    background-color var(--dur-instant) var(--ease-out),
    transform var(--dur-fast) var(--ease-spring);
}

.gm-tap__cue {
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-display-2);
  line-height: 1;
  letter-spacing: 0.02em;
}
.gm-tap__sub {
  font-size: var(--fs-body-sm);
  opacity: 0.85;
}

/* Colour never carries the state on its own — the cue element always spells it
   out. Red/green is the commonest colour-vision deficiency and this whole game
   rests on that one distinction, so here it is correctness, not compliance. */
.gm-tap[data-state="ready"] {
  background: var(--surface-sunken);
  border-color: var(--border-default);
  color: var(--text-secondary);
}
/* Foreground is pinned to the dark neutral rather than --text-inverse, which
   flips to white in the light theme. White on the vivid green measured 3.21:1
   and on the vivid red 3.68:1 — the large cue scrapes past the 3:1 large-text
   bar, but `.gm-tap__sub` is body-sm and needs 4.5:1, so it failed. The dark
   neutral clears AA against both fills in both themes and keeps the colours
   vivid, which a glanceable go/no-go signal actually needs. */
.gm-tap[data-state="go"],
.gm-tap[data-state="stop"] { color: var(--n-900) }
.gm-tap[data-state="go"]   { background: var(--success-solid) }
.gm-tap[data-state="stop"] { background: var(--danger-solid) }
.gm-tap[data-state="done"] {
  background: var(--surface-sunken);
  border-color: var(--border-default);
  color: var(--text-secondary);
  cursor: default;
}

/* --press-scale is the design system's token (.985) and is deliberately shallower
   than the 0.97 it replaces. This button is the target of a speed measure, so it
   should stay put under the finger; the rings carry the visual weight. The 0s
   duration on transform is what makes the press instant. */
.gm-tap.is-pressed {
  transform: scale(var(--press-scale));
  transition-duration: var(--dur-instant), 0s;
}
.gm-tap.is-error { box-shadow: 0 0 0 6px var(--danger-border) }

/* One ring per tap, from a pool of 4 restarted in rotation. Transform and opacity
   only, so each animation stays on the compositor. */
.gm-tap__ring,
.gm-tap__burst {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
}
.gm-tap__ring  { border: 3px solid var(--ring-color, var(--n-400)) }
.gm-tap__burst { border: 6px solid var(--ring-color, var(--n-400)) }

/* No will-change here on purpose. `is-live` is only removed when a ring is
   recycled, so declaring it would pin a composited layer open on all four rings
   for the rest of the run — the exact thing the narrow-use guidance warns about.
   A running transform/opacity animation already gets its own layer without it. */
.gm-tap__ring.is-live  { animation: gm-tap-ring 320ms var(--ease-out) forwards }
.gm-tap__burst.is-live { animation: gm-tap-burst 520ms var(--ease-out) forwards }

@keyframes gm-tap-ring {
  from { transform: scale(1);                  opacity: 0.45 }
  to   { transform: scale(var(--ring-max, 1.28)); opacity: 0 }
}
/* Fires on every GO <-> STOP change, never on a tap. Bigger and slower so the two
   events never read as the same thing. */
@keyframes gm-tap-burst {
  from { transform: scale(0.98);                 opacity: 0.6 }
  to   { transform: scale(var(--burst-max, 1.55)); opacity: 0 }
}

.gm-tap-wrap[data-state="go"]   { --ring-color: var(--success-solid) }
.gm-tap-wrap[data-state="stop"] { --ring-color: var(--danger-solid) }

/* Needs its own rule rather than inheriting the global :focus-visible, which
   sets border-radius: var(--radius-xs) and would square off a circular target. */
.gm-tap:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
  border-radius: 50%;
}

@media (max-width: 560px) {
  .gm-stage--keypress { --tap-size: min(260px, 76vw) }
  .gm-tap__cue { font-size: var(--fs-h1) }
}

/* Reduced motion still needs tap feedback — the press is informational, not
   decoration, and simply deleting it leaves these users with no confirmation that
   a tap landed at all. So the motion goes and a non-moving substitute replaces
   it: an instant inset ring, on and off as a step change rather than an
   animation. The GO/STOP colour state is untouched either way.
   `is-pressed` and `is-error` can never co-occur — the first is only set on a GO
   press, the second only on a STOP press — so their box-shadows cannot collide. */
@media (prefers-reduced-motion: reduce) {
  .gm-tap { transition: background-color var(--dur-instant) var(--ease-out) }
  .gm-tap.is-pressed {
    transform: none;
    box-shadow: inset 0 0 0 8px var(--n-900);
  }
  .gm-tap__ring,
  .gm-tap__burst { display: none }
}

/* --------------------------------------------------------------------------
   Cards

   Zero image assets. The card backs are a gradient pattern and the faces are
   type on a surface, so nothing here waits on a network request mid-run — which
   matters at sixty draws where a late-loading asset would land inside a
   deliberation measure. See cards-game-logic.md §6.
   -------------------------------------------------------------------------- */
.gm-stage--cards {
  padding: var(--space-4);
  min-height: 340px;
}

/* Four across on desktop, 2x2 on a phone. `auto-fit` is deliberately NOT used:
   the four decks are a fixed set and a row of three with one orphan below reads
   as though a deck is different from the others. */
.gm-decks {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--space-3);
  width: 100%;
  max-width: 560px;
}

.gm-deck {
  --deck-hue: var(--blue-500);
  display: grid;
  justify-items: center;
  gap: var(--space-2);
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}
.gm-deck[data-colour="blue"]   { --deck-hue: var(--blue-500) }
.gm-deck[data-colour="amber"]  { --deck-hue: var(--amber-500) }
.gm-deck[data-colour="violet"] { --deck-hue: var(--violet-500) }
.gm-deck[data-colour="teal"]   { --deck-hue: var(--teal-500) }

/* The pile is the sizing element for the whole deck — the face is absolutely
   positioned over it, so if this collapses the face has nothing to cover.
   ⛔ It is also the ONLY positioned element in a deck, which makes it the
   containing block the face's `inset: 0` resolves against. The face must be a
   CHILD of this, never a sibling. See the note in cards.js. */
.gm-deck__pile {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 5 / 7;
  /* Without a perspective the rotateY below collapses to a flat horizontal
     squash rather than reading as a card turning over. */
  perspective: 600px;
}

/* Three offset backs, so a deck reads as a stack rather than a single card.
   Purely decorative and aria-hidden in the markup: the count does not track the
   cards remaining in that deck, because that number is information the player is
   not supposed to have. */
.gm-deck__back {
  position: absolute;
  inset: 0;
  border-radius: var(--radius-md);
  border: var(--border-width) solid var(--deck-hue);
  background:
    repeating-linear-gradient(
      45deg,
      var(--deck-hue) 0 6px,
      transparent 6px 12px
    ),
    var(--surface-card);
  transition: transform var(--dur-fast) var(--ease-out);
}
.gm-deck__back:nth-child(1) { transform: translate(4px, 4px) }
.gm-deck__back:nth-child(2) { transform: translate(2px, 2px) }

.gm-deck:hover:not(:disabled) .gm-deck__back:nth-child(3) {
  transform: translateY(-4px);
}

/* The drawn card. Sits over the top back and flips into place. Opacity and
   transform only — nothing here moves layout, so sixty draws cost sixty
   composites and no reflows.

   ⛔ SELECTED AS A DIRECT CHILD OF THE PILE ON PURPOSE. It shipped as a sibling
   of the pile once, which left `inset: 0` resolving against the initial
   containing block and painted a page-sized card over the whole document on the
   first draw. The child combinator means a repeat of that mistake gets NO
   absolute positioning at all — a small span in normal flow, obviously wrong on
   sight, instead of a full-page takeover. Fail small, not catastrophic. */
.gm-deck__pile > .gm-deck__face {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: grid;
  place-content: center;
  gap: var(--space-025);
  border-radius: var(--radius-md);
  border: var(--border-width) solid var(--border-default);
  background: var(--surface-card);
  opacity: 0;
  transform: rotateY(90deg);
  pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out);
}
.gm-deck.is-drawn .gm-deck__pile > .gm-deck__face {
  opacity: 1;
  transform: rotateY(0deg);
}

.gm-deck__gain,
.gm-deck__loss {
  font-family: var(--font-numeric);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-body);
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
  text-align: center;
}
.gm-deck__gain { color: var(--success-fg) }
.gm-deck__loss { color: var(--danger-fg) }

.gm-deck__name {
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--text-secondary);
}

.gm-deck:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
  border-radius: var(--radius-md);
}

.gm-deck:disabled { cursor: default }

/* Dimming is tied to the run being OVER, not to the buttons being disabled. The
   350ms input lock after every draw disables all four, and styling `:disabled`
   would strobe the whole table sixty times in a run. */
.gm-decks.is-over .gm-deck:not(.is-drawn) { opacity: 0.6 }

/* The sentence under the table. The card face carries the numbers; this carries
   what they mean, because "+$100 −$250" on a 100px card is not a sentence and a
   screen reader should not have to assemble one from two spans. */
.gm-readout {
  margin: 0;
  min-height: calc(var(--lh-body) * 1em);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--text-secondary);
}
.gm-readout[data-tone="gain"] { color: var(--success-fg) }
.gm-readout[data-tone="loss"] { color: var(--danger-fg) }

@media (max-width: 560px) {
  .gm-decks {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    max-width: 320px;
  }
}

/* The flip is confirmation that a draw happened, so it cannot simply be deleted.
   The card still appears instantly; only the rotation goes. */
@media (prefers-reduced-motion: reduce) {
  /* ⚠️ Matched through the pile like the base rules above. A bare
     `.gm-deck__face` here is one specificity point SHORT of the
     `.gm-deck__pile > .gm-deck__face` that sets `rotateY(90deg)`, so the resting
     card would stay edge-on and the flip this block exists to remove would come
     back as a snap. */
  .gm-deck__pile > .gm-deck__face,
  .gm-deck__back { transition: none }
  .gm-deck__pile > .gm-deck__face { transform: none }
  .gm-deck:hover:not(:disabled) .gm-deck__back:nth-child(3) { transform: none }
}

/* --------------------------------------------------------------------------
   Money Exchange — two sides, one lane, money crossing it
   -------------------------------------------------------------------------- */
.gm-stage--exchange {
  padding: var(--space-4);
  min-height: 260px;
}

/* Two panels and the lane between them. The lane is a fixed track rather than a
   flexible column so the chip's travel distance does not change with the
   viewport — the transform below is a percentage of the lane, and a lane that
   collapsed at narrow widths would make the money appear to teleport. */
.gm-ex {
  display: grid;
  grid-template-columns: 1fr minmax(88px, 140px) 1fr;
  align-items: center;
  gap: var(--space-2);
  width: 100%;
  max-width: 620px;
  /* .gm-stage is `place-items: center`, which makes its children shrink to fit
     rather than fill. On a phone that left the two panels 211px wide inside a
     390px viewport with dead space either side, because a single stacked column
     has almost no intrinsic width. Stretch first, then cap and re-centre. */
  justify-self: stretch;
  margin-inline: auto;
}
.gm-ex__side {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-05);
  padding: var(--space-3) var(--space-2);
  min-height: 148px;
  justify-content: center;
  text-align: center;
  background: var(--surface-card);
  border: var(--border-width) solid var(--border-subtle);
  border-radius: var(--radius-lg);
}
/* The player's side carries the accent bar, the partner's does not. One glance
   should say which panel is yours without reading the label. */
.gm-ex__side--you { border-top: 3px solid var(--accent) }
.gm-ex__side--them { border-top: 3px solid var(--border-default) }

/* --text-secondary, not --text-tertiary, on every small label in this game.
   Tertiary measures 3.89:1 on the card surface in the LIGHT theme, which fails
   AA for anything under 18.66px bold — and every label here is 12px. The dark
   theme hides it at 5.07:1, which is exactly the trap session 11 shipped. */
.gm-ex__who { color: var(--text-secondary) }
.gm-ex__amount {
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h2);
  line-height: 1;
  font-variant-numeric: tabular-nums;
  /* The number changes on nearly every beat of a round. Reserving the line box
     stops the panel jumping as "$0" becomes "$12.75". */
  min-height: 1em;
}
.gm-ex__note {
  min-height: calc(var(--lh-caption) * 1em);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--text-secondary);
}

.gm-ex__lane {
  position: relative;
  display: grid;
  place-items: center;
  height: 148px;
}
/* The ×3 sits on the lane permanently, not only during a transfer. It is the
   rule of the game, and a player deciding how much to send needs it visible at
   the moment of the decision rather than after it. */
.gm-ex__x3 {
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h4);
  color: var(--text-secondary);
}
.gm-ex__lane::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  border-top: 2px dashed var(--border-default);
}

/* The chip only exists while money is in flight. Opacity and transform only —
   both compositor properties, and nothing here can shift the layout of the two
   panels it travels between. */
.gm-ex__chip {
  position: absolute;
  top: calc(50% - 44px);
  padding: var(--space-05) var(--space-2);
  border-radius: var(--radius-pill);
  background: var(--accent-tint);
  color: var(--accent-fg);
  border: var(--border-width) solid var(--accent);
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-body);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  opacity: 0;
  transform: translateX(-120%);
  transition: transform 700ms var(--ease-out), opacity 200ms linear;
  pointer-events: none;
}
.gm-ex__chip.is-live { opacity: 1 }
.gm-ex__chip.is-out { transform: translateX(120%) }
/* The return travels the other way and is styled as the partner's money, not
   yours, so the two legs of the exchange never look like the same event. */
.gm-ex__chip.is-return {
  background: var(--surface-sunken);
  color: var(--text-primary);
  border-color: var(--border-default);
  transform: translateX(-120%);
}
.gm-ex__chip.is-tripled { box-shadow: 0 0 0 4px var(--accent-tint) }

.gm-ex.is-over { opacity: 0.7 }

/* ---- Money Exchange 2 only ---- */

/* The lane with no ×3 on it. ME1 pins the multiplier to its lane permanently
   because it is that game's rule; here nothing is multiplied and the total on
   the table never changes, so the badge is absent BY DESIGN. A player arriving
   from the ME1 page carries the tripling rule with them, and a stray ×3 would
   quietly invert every decision they make. This class exists so that omission
   is written down somewhere rather than looking like something that fell off. */
.gm-ex__lane--flat { height: 148px }

/* Who is holding the extra $5. The amounts already say it, but the panels are
   otherwise identical and the whole game turns on which side has the money —
   this is a second, non-numeric cue for a player scanning rather than reading.
   A ring, not a colour swap: the accent bar on `--you` already means "your
   side", and recolouring it would collide. */
.gm-ex__side.is-holder {
  border-color: var(--border-strong);
  box-shadow: 0 0 0 2px var(--accent-tint);
}
.gm-ex__side.is-holder .gm-ex__note { color: var(--text-primary) }

/* Three labels under the bidirectional track instead of two. The middle one
   takes the slack, so "nothing" sits over the centre stop of the range — that
   label IS the zero tick, which is why the track itself needs no marker. */
.gm-range__mid {
  flex: 1;
  text-align: center;
}

/* Taking is not styled as an error, because it is not one. It is a legitimate
   move the real game offers, roughly a third of real dictators keep everything,
   and colouring it red would smuggle a correct answer into a game that does not
   have one. It gets a different SURFACE so the button visibly changes character,
   and the word "Take" on the face is what actually carries the meaning. */
.gm-send--take {
  background: var(--surface-sunken);
  border-color: var(--border-strong);
  color: var(--text-primary);
}

/* ---- the send control ---- */

.gm-controls--send {
  flex-direction: column;
  align-items: stretch;
}
/* Sized to its own label, not stretched to the panel. The send is one decision
   per round, not a button you hammer, so it does not want balloon's full-width
   treatment — and it must never carry `.gm-action`, whose 200px flex-basis
   becomes a height in this column. */
.gm-send {
  align-self: flex-start;
  min-width: 168px;
}
.gm-range__wrap {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}
.gm-range__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2);
}
.gm-range__label {
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-medium);
  color: var(--text-secondary);
}
.gm-range__value {
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h3);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.gm-range {
  width: 100%;
  /* 48px keeps the whole strip inside the touch-target minimum, which a bare
     range input is not — the thumb is, the track is not, and a mis-tap on the
     track jumps the value. */
  height: 48px;
  accent-color: var(--accent);
  cursor: pointer;
}
.gm-range:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
  border-radius: var(--radius-sm);
}
.gm-range:disabled { cursor: default; opacity: 0.6 }
.gm-range__scale {
  display: flex;
  justify-content: space-between;
  font-size: var(--fs-caption);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}
/* The consequence of the current slider position, spelled out. A player who
   never reads the hint still cannot miss the tripling. */
.gm-range__echo {
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--accent-fg);
  font-weight: var(--fw-medium);
}

/* ---- the fairness rating ---- */

.gm-rate {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}
.gm-rate__q {
  margin: 0;
  font-size: var(--fs-body);
  font-weight: var(--fw-medium);
}
/* Ten buttons, never a second slider. The send and the rating are different
   kinds of decision and must not share a control shape. */
.gm-rate__row {
  display: grid;
  grid-template-columns: repeat(10, minmax(0, 1fr));
  gap: var(--space-05);
}
/* Money Exchange 2 rates 0 to 10, which is eleven buttons, not ten. Two sources
   give an explicit scale for that game and both start at zero — and the 0 floor
   earns its place there, because round 2 can end with a partner holding nothing
   and "1, slightly unfair" cannot say that. */
.gm-rate__row--11 { grid-template-columns: repeat(11, minmax(0, 1fr)) }
.gm-rate__btn {
  min-height: 48px;
  border: var(--border-width) solid var(--border-default);
  border-radius: var(--radius-md);
  background: var(--surface-card);
  color: var(--text-primary);
  font-family: var(--font-numeric);
  font-size: var(--fs-body);
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: var(--transition-ui);
}
.gm-rate__btn:hover:not(:disabled) {
  background: var(--accent-tint);
  border-color: var(--accent);
}
.gm-rate__btn:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}
/* The chosen rating stays legible after the row is disabled — the selection is
   feedback, and dimming it with the rest of the row would erase it. */
.gm-rate__btn.is-chosen {
  background: var(--accent-deep);
  border-color: var(--accent-deep);
  color: var(--accent-on);
  opacity: 1;
}
.gm-rate__btn:disabled:not(.is-chosen) { opacity: 0.5; cursor: default }
.gm-rate__ends {
  display: flex;
  justify-content: space-between;
  gap: var(--space-2);
  font-size: var(--fs-caption);
  color: var(--text-secondary);
}

@media (max-width: 560px) {
  /* The page container, .gm and the stage each contribute padding, which stacks
     to 113px of a 390px viewport before the panels get any. The stage gives its
     share back on a phone. */
  .gm-stage--exchange { padding: var(--space-2) }
  .gm-ex {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-1);
  }
  /* The lane rotates to vertical rather than disappearing. The tripling still
     has to be visible on a phone, and it is the one rule of this game. */
  .gm-ex__lane { height: 72px }
  .gm-ex__lane::before {
    left: 50%;
    right: auto;
    top: 0;
    bottom: 0;
    border-top: 0;
    border-left: 2px dashed var(--border-default);
  }
  .gm-ex__chip { top: calc(50% - 16px) }
  .gm-ex__chip { transform: translateY(-90px) }
  .gm-ex__chip.is-out { transform: translateY(90px) }
  .gm-ex__chip.is-return { transform: translateY(-90px) }
  .gm-ex__side { min-height: 108px }
  .gm-rate__row { grid-template-columns: repeat(5, minmax(0, 1fr)) }
  /* Eleven at 390px is 6 + 5 across two rows. Five columns would leave a single
     orphaned button on a third row; six keeps the 48px touch target and the
     grid reads as one block. */
  .gm-rate__row--11 { grid-template-columns: repeat(6, minmax(0, 1fr)) }
  /* Full width on a phone, where a thumb-sized target beats a tidy one. */
  .gm-send { align-self: stretch }
}

/* The travel is decoration; the tripling on the chip is information. So the
   movement goes and the chip still appears, changes value and disappears on the
   same beats — the JS shortens its timers under this query too. */
@media (prefers-reduced-motion: reduce) {
  .gm-ex__chip {
    transition: opacity 80ms linear;
    transform: none;
  }
  .gm-ex__chip.is-out,
  .gm-ex__chip.is-return { transform: none }
}

/* --------------------------------------------------------------------------
   Easy or Hard — one stage, three faces
   -------------------------------------------------------------------------- */

/* Choice, task and outcome swap inside a single stage. The floor is fixed
   because they swap about eleven times in a two-minute run, and a stage that
   resized on every swap would move the press button out from under a thumb
   between trials.

   ⭐ 460px is measured, not chosen. The first build used 340 on the reasoning
   that the choice view was the tallest of the three; it is not — the task view
   measures 458 on desktop and 460 at 390px, so every single choice-to-task
   swap jumped the panel by about 120px and took the press button with it. Both
   widths land within 2px of each other, so one floor covers both. If any of
   the three views grows, re-measure rather than guess. */
.gm-stage--effort {
  padding: var(--space-4);
  min-height: 460px;
}

/* ---- the choice ---- */

.gm-eh {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  width: 100%;
  max-width: 560px;
  justify-self: stretch;
  margin-inline: auto;
}

/* The probability as a sentence, and the biggest text on the screen after the
   two amounts. It applies to whichever option is picked — a player who reads it
   as belonging to the hard card has misread the whole game, so it sits above
   both cards and spans them rather than living beside either one. */
.gm-eh__odds {
  margin: 0;
  text-align: center;
  font-size: var(--fs-h4);
  line-height: var(--lh-h4);
  font-weight: var(--fw-medium);
  color: var(--text-primary);
}

.gm-eh__drain {
  height: 6px;
  border-radius: var(--radius-pill);
  background: var(--surface-sunken);
  overflow: hidden;
}
/* scaleX, not width: this is repainted every frame for five seconds and width
   would lay out the panel sixty times a second. */
.gm-eh__drain-fill {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: var(--accent);
  transform: scaleX(var(--drain, 1));
  transform-origin: left center;
}
.gm-eh__drain-label {
  margin: 0;
  text-align: center;
  font-family: var(--font-numeric);
  font-size: var(--fs-caption);
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
}

.gm-eh__cards {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-2);
}

.gm-eh__card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-05);
  min-height: 148px;
  padding: var(--space-3) var(--space-2);
  background: var(--surface-card);
  border: var(--border-width) solid var(--border-default);
  border-radius: var(--radius-lg);
  color: var(--text-primary);
  text-align: center;
  cursor: pointer;
  transition: var(--transition-ui);
}
/* Both cards carry the same weight. The hard one is not styled as the exciting
   choice and the easy one is not styled as the safe one — there is no correct
   answer here and the visual design must not imply one. The only difference is
   the top rule, which says which is which without ranking them. */
.gm-eh__card[data-kind="easy"] { border-top: 3px solid var(--border-strong) }
.gm-eh__card[data-kind="hard"] { border-top: 3px solid var(--accent) }

.gm-eh__card:hover:not(:disabled) {
  background: var(--accent-tint);
  border-color: var(--accent);
}
.gm-eh__card:active:not(:disabled) { transform: scale(var(--press-scale)) }
.gm-eh__card:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}
.gm-eh__card:disabled { cursor: default; opacity: 0.55 }

.gm-eh__card-title { color: var(--text-secondary) }
/* Money is the biggest thing on the card because money is what the decision is
   between. The press counts underneath are the cost, and they read second. */
.gm-eh__card-money {
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h1);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.gm-eh__card-terms {
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
}

/* ---- the task ---- */

.gm-eh-task {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-1);
  width: 100%;
  max-width: 420px;
  margin-inline: auto;
}
.gm-eh-task__title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h3);
  line-height: 1;
}
.gm-eh-task__terms {
  margin: 0;
  font-size: var(--fs-body-sm);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

/* The dial sits centred and the countdown sits in the corner beside it, which
   is the arrangement the real screen uses. Relative so the ring can anchor. */
.gm-eh-task__stage {
  --dial-size: 168px;
  position: relative;
  display: grid;
  place-items: center;
  width: 100%;
  padding-block: var(--space-2);
}

/* A circle that fills, not a bar. Straight from the gameplay screen, and it is
   the only thing on the task view with any visual weight. */
.gm-eh-dial {
  position: relative;
  display: grid;
  place-items: center;
  width: var(--dial-size);
  height: var(--dial-size);
  border: 3px solid var(--border-strong);
  border-radius: 50%;
  overflow: hidden;
  /* Deliberately NOT transitioned. This border is the go signal for a timed
     window, and easing it in adds its own duration to the moment a player
     realises they can start — on a three-second easy task that is real. The
     burst carries the motion; the cue itself is instant. Keypress keeps its
     colour swap instant for the same reason. */
}

/* One pulse on the ready-to-live flip. Outside the dial, because the dial clips
   its own overflow for the fill and a burst that cannot cross the edge reads as
   the circle twitching rather than as a starting gun. */
.gm-eh-dial__burst {
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--dial-size);
  height: var(--dial-size);
  margin: calc(var(--dial-size) / -2) 0 0 calc(var(--dial-size) / -2);
  border: 6px solid var(--accent);
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
}
.gm-eh-dial__burst.is-live { animation: gm-eh-burst 520ms var(--ease-out) forwards }

@keyframes gm-eh-burst {
  from { opacity: 0.9; transform: scale(1) }
  to   { opacity: 0;   transform: scale(1.45) }
}
/* scale, not width/height. Sixty steps in twelve seconds has to stay on the
   compositor, and a transform on a positioned child cannot reflow the stage. */
.gm-eh-dial__fill {
  position: absolute;
  inset: 4px;
  border-radius: 50%;
  background: var(--accent);
  transform: scale(var(--fill, 0));
  transition: transform 90ms linear;
}
/* The circle is the feel, the number is the fact. At sixty presses you need
   both. It sits UNDER the circle rather than inside it: the fill grows through
   the middle, and no single colour stays legible against both an empty ring and
   a full accent disc. Blend modes were tried and are not worth the fragility. */
.gm-eh-dial__count {
  margin: 0;
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h3);
  line-height: 1;
  font-variant-numeric: tabular-nums;
  color: var(--text-primary);
}

/* One decimal is not decoration: at five presses a second, whole seconds are
   too coarse to pace against. Bottom left, as the real screen has it. */
.gm-eh-ring {
  position: absolute;
  left: 0;
  bottom: 0;
  display: grid;
  place-items: center;
  width: 64px;
  height: 64px;
  border: 3px solid var(--amber-500);
  border-radius: 50%;
  font-family: var(--font-numeric);
  font-size: var(--fs-body-sm);
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
  background: var(--surface-card);
}

.gm-eh-press {
  width: 100%;
  min-height: 64px;
  border: var(--border-width) solid var(--accent-deep);
  border-radius: var(--radius-lg);
  background: var(--accent-deep);
  color: var(--accent-on);
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h4);
  cursor: pointer;
  touch-action: manipulation;
  user-select: none;
  transition: transform var(--dur-fast) var(--ease-out);
}
.gm-eh-press.is-pressed { transform: scale(var(--press-scale)) }

/* ---- ready versus live -----------------------------------------------------
 *
 * The task view is already on screen through the one-second ready beat, so
 * these two states are the only thing separating "wait" from "go". They were
 * missing in the first build and it was the one problem a player actually hit:
 * a disabled button looks near enough to an enabled one that you cannot tell
 * the game has started, and on a three-second easy task that costs a third of
 * the window.
 *
 * Three cues, not one, because no single one survives every case: the word
 * carries it for someone reading, the fill and border carry it for someone
 * glancing, and the dashed-to-solid dial carries it if colour is unavailable.
 * Never colour alone. */
.gm-eh-task[data-state="ready"] .gm-eh-press {
  background: var(--surface-sunken);
  border-color: var(--border-default);
  color: var(--text-secondary);
}
.gm-eh-task[data-state="ready"] .gm-eh-press:disabled { opacity: 1 }

/* Dashed while it is not yet counting anything. The shape change is the cue
   that does not depend on seeing colour. */
.gm-eh-task[data-state="ready"] .gm-eh-dial {
  border-style: dashed;
  border-color: var(--border-default);
}
.gm-eh-task[data-state="live"] .gm-eh-dial {
  border-style: solid;
  border-color: var(--accent);
}
.gm-eh-task[data-state="ready"] .gm-eh-ring { border-color: var(--border-default) }
.gm-eh-task[data-state="ready"] .gm-eh-dial__count { color: var(--text-secondary) }
.gm-eh-press:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}
.gm-eh-press:disabled { opacity: 0.5; cursor: default }

/* ---- the outcome ---- */

.gm-eh-out {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-1);
  text-align: center;
  max-width: 42ch;
  margin-inline: auto;
}
.gm-eh-out__head {
  margin: 0;
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h2);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.gm-eh-out__sub {
  margin: 0;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--text-secondary);
}
.gm-eh-out[data-tone="win"] .gm-eh-out__head { color: var(--success-fg) }
/* A completed task that did not pay is NOT an error and is not coloured like
   one. The player did everything the game asked; the roll went the other way,
   and that is the single most important thing this screen teaches. Red here
   would tell them they got something wrong. */
.gm-eh-out[data-tone="nopay"] .gm-eh-out__head { color: var(--text-primary) }
.gm-eh-out[data-tone="miss"] .gm-eh-out__head { color: var(--danger-fg) }

@media (max-width: 560px) {
  .gm-stage--effort { padding: var(--space-2) }
  /* Stacked, and hard stays second so reading order still matches cost order. */
  .gm-eh__cards { grid-template-columns: minmax(0, 1fr) }
  .gm-eh__card { min-height: 112px }
  .gm-eh__card-money { font-size: var(--fs-h2) }
  .gm-eh-task__stage { --dial-size: 136px }
  /* Below the dial rather than beside it — at 320px the corner overlaps. */
  .gm-eh-ring {
    position: static;
    width: 56px;
    height: 56px;
    margin-top: var(--space-1);
  }
  .gm-eh-task__stage { gap: var(--space-1) }
}

/* The fill and the drain carry information, so they still update — only the
   easing goes. Nothing here is legible only through movement.

   The start-of-task burst goes entirely, and that is safe precisely because it
   was never the only signal: the word on the button, the button's own fill and
   the dashed-to-solid dial all still flip. A player on reduced motion loses the
   flourish and keeps every cue that carries meaning. */
@media (prefers-reduced-motion: reduce) {
  .gm-eh-dial__fill { transition: none }
  .gm-eh-press { transition: none }
  .gm-eh-dial__burst.is-live { animation: none }
}

/* --------------------------------------------------------------------------
   Lengths
   -------------------------------------------------------------------------- */
/* Tall enough for the face plus the two keys plus the hint, so nothing shifts
   between the fixation cross and the face. Measured, not reasoned: easy-or-hard
   shipped a 120px jump eleven times a run from a min-height taken on a guess. */
.gm-stage--lengths {
  padding: var(--space-4);
  min-height: 440px;
}

.gm-len {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
  width: 100%;
  max-width: 420px;
  justify-self: stretch;
  margin-inline: auto;
}

/* Fixed height so the cross, the face and the reward line all occupy exactly the
   same box. If this collapsed between states the layout would shift on every
   trial — and a shift is a timing tell, which on this screen is feedback. */
.gm-len__stimulus {
  position: relative;
  display: grid;
  place-items: center;
  width: 240px;
  height: 240px;
}

/* ⛔ THE THREE STIMULUS LAYERS ARE SHOWN AND HIDDEN FROM HERE, NEVER FROM THE
   `hidden` PROPERTY IN JS. This shipped broken once and the bug is invisible in
   review: `hidden` is defined on HTMLElement and NOT on SVGElement, so
   `svg.hidden = true` silently creates a meaningless expando, sets no attribute,
   and the face stays on screen. It was visible during the fixation cross, during
   the reward message and after the run ended — which also destroyed the
   400ms beat where the face is supposed to APPEAR before the mouth flashes.
   data-view is already the single source of truth for the trial phase, so the
   whole stimulus layer now follows it and JS never touches visibility here.

   `display`, not `visibility`: visibility inherits, and the mouth carries an
   inline `visibility: visible`, so a hidden parent would not hide it. The box
   above is a fixed 240x240 so nothing shifts when the face is removed. */
.gm-len__face,
.gm-len__fixation,
.gm-len__reward { display: none }
.gm-len[data-view="fixation"] .gm-len__fixation { display: block }
.gm-len[data-view="blank"] .gm-len__face,
.gm-len[data-view="mouth"] .gm-len__face,
.gm-len[data-view="respond"] .gm-len__face { display: block }
.gm-len[data-view="feedback"] .gm-len__reward { display: block }

/* ⛔ FIXED PIXELS, NEVER FLUID, AND THIS IS NOT A STYLE PREFERENCE.
   The mouths differ by 5.5px — 11.5mm against 13mm in the source task, converted
   at the CSS reference pixel. Size the face in vw or clamp() and that difference
   shrinks on a phone, which makes the discrimination genuinely harder for mobile
   players and quietly changes what the game measures. Everything else on this
   site is fluid; this one element must not be. 240px clears a 390px viewport
   with room for the stage padding, so it never needs to scale. */
/* ⚠️ NO `display` HERE. It is owned by the data-view rules above, and putting a
   `display: block` in this block silently defeated them once already — same
   specificity, later in the file, so it won and the face showed in every phase.
   This rule sizes the face and nothing else. */
.gm-len__face {
  width: 240px;
  height: 240px;
  flex: none;
}
.gm-len__head {
  fill: var(--surface-card);
  stroke: var(--text-primary);
  stroke-width: 3;
}
.gm-len__eye { fill: var(--text-primary) }
.gm-len__nose {
  fill: none;
  stroke: var(--text-primary);
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* No transition, ever. This is a 100ms stimulus in a discrimination task — an
   eased fade would add its own duration to the exposure and blur the one edge
   the player is judging. Same argument keypress makes about not easing its GO
   colour, and it applies harder here. */
.gm-len__mouth {
  fill: var(--text-primary);
  transition: none;
}

.gm-len__fixation {
  position: absolute;
  font-family: var(--font-display);
  font-size: var(--fs-h1);
  line-height: 1;
  color: var(--text-secondary);
  user-select: none;
}

.gm-len__reward {
  position: absolute;
  margin: 0;
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-lg);
  background: var(--success-bg);
  color: var(--success-fg);
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h4);
  line-height: 1;
  text-align: center;
}

/* ---- the keys ----
 *
 * ⭐ These are the cue, not just the target. The face is mouthless both before
 * the flash and after it, so disabled-versus-enabled is the only thing that
 * separates "wait for it" from "it happened, answer now" — and a disabled button
 * looks like an enabled one at a glance. That exact gap is what Nikkesh caught in
 * easy-or-hard. Here the keys go flat and unlit during the presentation and light
 * up on the same frame the mouth comes off, so the go signal is unmissable
 * without any of it being feedback about the answer. */
.gm-len__keys {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-2);
  width: 100%;
}

/* ⚠️ NOT .gm-action. That is `flex: 1 1 200px`, written for balloon's Pump and
   Bank sitting side by side in a row, and money-exchange-1 shipped a 738x200px
   button to Nikkesh by inheriting it into a column. These are their own class
   with their own sizing so the trap cannot fire again. */
.gm-len__key {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-05);
  min-height: 76px;
  padding: var(--space-2);
  background: var(--surface-card);
  border: var(--border-width) solid var(--border-default);
  border-radius: var(--radius-lg);
  color: var(--text-primary);
  cursor: pointer;
  /* Rapid alternating taps would otherwise trigger double-tap-to-zoom and end
     the run. Same three lines keypress needs, same reason. */
  touch-action: manipulation;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  transition: var(--transition-ui);
}
.gm-len__key-glyph {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  line-height: 1;
}
.gm-len__key-label {
  font-size: var(--fs-body-sm);
  line-height: 1;
  color: var(--text-secondary);
}

/* Both keys are identical in weight. Neither answer is the good one and the
   design must not hint that either is. */
.gm-len__key:hover:not(:disabled) {
  background: var(--accent-tint);
  border-color: var(--accent);
}
.gm-len__key:active:not(:disabled) { transform: scale(var(--press-scale)) }
.gm-len__key:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}
/* Deliberately more than the usual disabled treatment. This is the "not yet"
   state of a cue, and it fires 90 times a run, so it has to read instantly at a
   glance rather than on inspection. */
.gm-len__key:disabled {
  cursor: default;
  opacity: 0.4;
  border-style: dashed;
}
.gm-len[data-view="respond"] .gm-len__key { border-color: var(--accent) }

.gm-len__paused {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-2);
  text-align: center;
  max-width: 40ch;
}
.gm-len__paused-head {
  margin: 0;
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h3);
  line-height: 1;
}

@media (max-width: 560px) {
  .gm-stage--lengths { padding: var(--space-2) }
  /* ⛔ The face is NOT resized here. See the note on .gm-len__face — shrinking
     it would shrink the difference being judged. Only the surroundings give way. */
  .gm-len { gap: var(--space-2) }
  .gm-len__key { min-height: 68px }
}

/* Nothing in this game is animated, so there is nothing to switch off. The one
   moving part is the 100ms stimulus itself, which is the task rather than a
   flourish, and it is already transition-free by the rule above. Recorded so a
   later reader does not think reduced motion was forgotten. */

/* --------------------------------------------------------------------------
   Result panel. No comparison-to-average bars — we have no such data and this
   site does not print numbers it cannot stand behind.
   -------------------------------------------------------------------------- */
.gm-result {
  margin-top: var(--space-4);
  padding: var(--space-5);
  background: var(--surface-card);
  border: var(--border-width) solid var(--border-subtle);
  border-top: 3px solid var(--accent);
  border-radius: var(--radius-xl);
}
.gm-result:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}
.gm-result__title {
  margin: 0 0 var(--space-2);
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h2);
  line-height: var(--lh-h2);
  letter-spacing: var(--ls-h2);
}
.gm-result__lede {
  margin: 0 0 var(--space-4);
  max-width: 62ch;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--text-secondary);
}
.gm-result__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: var(--space-2);
}
.gm-result__stat {
  display: flex;
  flex-direction: column;
  gap: var(--space-05);
  padding: var(--space-3);
  border: var(--border-width) solid var(--border-subtle);
  border-radius: var(--radius-md);
}
.gm-result__value {
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h3);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.gm-result__reading {
  margin: var(--space-3) 0 0;
  max-width: 62ch;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--text-secondary);
}
.gm-result__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-top: var(--space-4);
}

/* --------------------------------------------------------------------------
   Game rail — every game, linked from the hub and from each game page
   -------------------------------------------------------------------------- */
.game-rail {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  gap: var(--space-2);
}
.game-rail__item {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  min-height: 48px;
  padding-inline: var(--space-2);
  background: var(--surface-card);
  border: var(--border-width) solid var(--border-subtle);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius-md);
  color: var(--text-primary);
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-medium);
  transition: var(--transition-ui);
}
.game-rail__item:hover {
  background: var(--accent-tint);
  color: var(--text-primary);
  text-decoration: none;
}
.game-rail__item[aria-current] {
  background: var(--accent-tint);
  color: var(--accent-fg);
}
.game-rail__num {
  color: var(--text-tertiary);
  font-variant-numeric: tabular-nums;
}
/* Games that do not have a page yet are listed, not linked. Being honest about
   what exists costs nothing and stops us shipping links to 404s. */
.game-rail__item--soon {
  border-left-color: var(--border-default);
  background: var(--surface-page);
  color: var(--text-tertiary);
  font-weight: var(--fw-body);
}
.game-rail__soon {
  margin-left: auto;
  font-size: var(--fs-caption);
  color: var(--text-tertiary);
}
.game-rail__item--all {
  border: var(--border-width) dashed var(--border-default);
  background: var(--surface-page);
  color: var(--text-secondary);
}

/* --------------------------------------------------------------------------
   Hub-only: the game explainer list. Ported from the design system's
   pymetrics-practice-test template — accent bar on top, number badge beside
   the heading, trait chips and a play link at the foot.
   -------------------------------------------------------------------------- */
.game-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}
.game-entry {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-4);
  background: var(--surface-card);
  border: var(--border-width) solid var(--border-subtle);
  border-top: 3px solid var(--accent);
  border-radius: var(--radius-lg);
  scroll-margin-top: calc(var(--nav-height) + var(--space-3));
}
.game-entry__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}
.game-entry__num {
  display: grid;
  place-items: center;
  flex: none;
  width: 36px;
  height: 36px;
  border-radius: var(--radius-sm);
  background: var(--accent-tint);
  color: var(--accent-fg);
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-semibold);
  font-variant-numeric: tabular-nums;
}
.game-entry__title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: var(--fw-display-medium);
  font-size: var(--fs-h3);
  line-height: var(--lh-h3);
  letter-spacing: var(--ls-h3);
}
.game-entry__desc {
  margin: 0;
  max-width: 64ch;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--text-secondary);
}
/* Preview-clip slot. The striped fill is the design system's "asset goes here"
   treatment, not a finished state. Replace with real recordings before launch. */
/* Frame for the game's animated preview. Was a dashed striped placeholder box
   until the previews landed; the aspect-ratio is unchanged so nothing below it
   moved. See previews.css for the loops themselves. */
.game-entry__media {
  display: grid;
  place-items: center;
  aspect-ratio: 16 / 9;
  /* At full column width 16:9 makes a 415px-tall box, which scales the artwork
     2.3x and turns 2.5px strokes into 6px ones. The cap keeps the drawing at a
     sane size; the SVG's `meet` letterboxes it in the middle. */
  max-height: 240px;
  overflow: hidden;
  border: var(--border-width) solid var(--border-default);
  border-radius: var(--radius-md);
  background: var(--surface-page);
}
/* Caption under the frame. Carries the same description the placeholder label
   used to, so nothing that was readable became decorative. */
.game-entry__media-label {
  display: block;
  margin-top: var(--space-1);
  font-family: var(--font-numeric);
  font-size: var(--fs-caption);
  color: var(--text-tertiary);
}
.game-entry__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}
.game-entry__play {
  align-self: flex-start;
  display: inline-flex;
  align-items: center;
  gap: var(--space-05);
  color: var(--accent-fg);
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-semibold);
}
/* Games without a page yet say so rather than offering a dead link. */
.game-entry__soon {
  align-self: flex-start;
  font-size: var(--fs-body-sm);
  color: var(--text-tertiary);
}

/* ============================================================= faces ==== */

/* The image is the stimulus, so it gets a fixed frame: no CLS as photos swap,
   and the 3:4 crop every stimulus was generated to. */
.gm-stage--faces {
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-3);
}
.gm-face {
  width: min(280px, 62vw);
  aspect-ratio: 3 / 4;
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--surface-sunken, var(--surface-card));
}
.gm-face__img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Display only — the setTimeout in faces.js owns the deadline. A bar, not a
   numeral: a digit clock invites clock-watching inside a 7-second window.

   The bar is the only visual timer, so it is a meaningful graphic and owes
   3:1 against its own track (WCAG 1.4.11), not just "looks fine". The obvious
   pairing — accent on border-default — measures 2.09 in light and fails.
   accent-fg on accent-tint measures 4.81 light / 5.9 dark and stays inside
   the category hue. Do not flatten it back to plain --accent. */
.gm-timerbar {
  width: min(280px, 62vw);
  height: 6px;
  border-radius: var(--radius-full, 999px);
  background: var(--accent-tint);
  overflow: hidden;
}
.gm-timerbar__fill {
  height: 100%;
  width: 100%;
  border-radius: inherit;
  background: var(--accent-fg);
}

.gm-face-story {
  margin: 0;
  max-width: 46ch;
  text-align: center;
  font-size: var(--fs-body);
  color: var(--text-primary);
}

/* Ten words, fixed order, every trial. Two rows of five on desktop, 2×5 grid
   on small screens. NOT .gm-action — that flex basis becomes height in a
   column and ships a 200px-tall button (see money exchange 1's postmortem). */
.gm-words {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: var(--space-1);
}
.gm-word {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-05);
  min-height: 48px;
  border: var(--border-width) solid var(--border-default);
  border-radius: var(--radius-md);
  background: var(--surface-card);
  color: var(--text-primary);
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-medium);
  cursor: pointer;
  transition: var(--transition-ui);
}
.gm-word:hover:not(:disabled) {
  background: var(--accent-tint);
  border-color: var(--accent);
}
.gm-word:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}
.gm-word:disabled { cursor: default }

/* Small label, but --text-secondary, never tertiary: tertiary at this size
   fails AA in the light theme (3.89:1). */
.gm-word__key {
  font-family: var(--font-numeric);
  font-size: var(--fs-caption, 0.75rem);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

/* Feedback is never colour alone — the announcer names the answer in text and
   the correct button keeps its word visible. Colour is the highlight, not the
   message. */
.gm-word.is-picked {
  border-color: var(--accent);
  background: var(--accent-tint);
}
.gm-word.is-correct {
  border-color: var(--success-solid);
  background: var(--success-bg);
  color: var(--success-fg);
}
.gm-word.is-wrong {
  border-color: var(--danger-solid);
  background: var(--danger-bg);
  color: var(--danger-fg);
}
.gm-word.is-correct:disabled,
.gm-word.is-wrong:disabled { opacity: 1 }

@media (max-width: 560px) {
  .gm-words { grid-template-columns: repeat(2, minmax(0, 1fr)) }
  .gm-face,
  .gm-timerbar { width: min(240px, 70vw) }
}

/* --------------------------------------------------------------------------
   Towers

   Zero image assets, same as cards. Discs are type on a filled block.

   ⭐ Every size in here derives from --tw-slot and --tw-mini-slot, including
   the stage min-height. Session 11 fixed a clipped animation by deriving the
   stage from the same variables the content uses, precisely so a later change
   to the content could not silently reintroduce the clipping. Same rule here:
   change the slot size and the stage follows.

   The board's height never changes — every tower always renders five slots and
   fills them from the bottom — so nothing reflows across the nine-odd moves of
   a run. Easy-or-Hard shipped a stage that jumped 120px eleven times a run
   before this was taken seriously.
   -------------------------------------------------------------------------- */
.gm-stage--towers {
  --tw-slot: 40px;
  --tw-slot-gap: 4px;
  --tw-mini-slot: 22px;
  --tw-mini-gap: 2px;
  --tw-tower-h: calc(var(--tw-slot) * 5 + var(--tw-slot-gap) * 4);
  --tw-mini-h: calc(var(--tw-mini-slot) * 5 + var(--tw-mini-gap) * 4);

  padding: var(--space-4);
  /* towers + target + both labels + the gaps between them */
  min-height: calc(var(--tw-tower-h) + var(--tw-mini-h) + var(--space-4) * 4);
}

.gm-tw {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  width: 100%;
  max-width: 460px;
}

/* ---- the target ----------------------------------------------------------
   Sunken and dashed, never raised. It is a reference, not a control, and if it
   looks like the board people tap it. Same reasoning that keeps the balloon's
   banked total out of the pump button. */
.gm-tw__target {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2);
  border: var(--border-width) dashed var(--border-default);
  border-radius: var(--radius-lg);
  background: var(--surface-sunken);
}
.gm-tw__target-label {
  margin: 0;
  color: var(--text-secondary);
}
.gm-tw__mini {
  display: flex;
  gap: var(--space-1);
  margin-left: auto;
}
/* The miniature reuses the board's own markup and just resizes it, so the two
   can never drift apart visually. Letters stay on: this is the target, and the
   letters are exactly what a player has to read off it. */
.gm-tw__mini .gm-tw__slots {
  --tw-slot: var(--tw-mini-slot);
  --tw-slot-gap: var(--tw-mini-gap);
  width: 40px;
}
.gm-tw__mini .gm-tw__disc { font-size: var(--fs-caption) }

/* ---- the working board --------------------------------------------------- */
.gm-tw__board {
  display: flex;
  gap: var(--space-2);
  align-items: flex-end;
}

.gm-tw__tower {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-1);
  flex: 1 1 0;
  min-width: 0;
  padding: var(--space-1);
  border: var(--border-width) solid var(--border-default);
  border-radius: var(--radius-lg);
  background: var(--surface-card);
  cursor: pointer;
  /* Mandatory, not cosmetic: without it a quick second tap on the same tower is
     read as double-tap-to-zoom on iOS and the board jumps mid-run. Keypress
     learned this at 6 Hz; nine moves is enough to hit it too. */
  touch-action: manipulation;
  transition: var(--transition-ui);
}

/* ⭐ Selection is signalled with --text-primary, NOT --accent. The accent on
   this page is emerald (Career & jobs) and one of the five discs is emerald, so
   an accent-coloured ring around a tower holding disc C would read as a
   property of the disc. Neutral-but-strong is the only safe choice when the
   content itself owns the palette. */
.gm-tw__tower[data-state="selected"] {
  border-color: var(--text-primary);
  box-shadow: 0 0 0 2px var(--text-primary);
}
/* Every other tower while a disc is lifted: a legal place to drop it. */
.gm-tw__tower[data-state="target"] {
  border-style: dashed;
  border-color: var(--text-secondary);
  background: var(--surface-sunken);
}
.gm-tw__tower:hover:not(:disabled) { border-color: var(--text-secondary) }
.gm-tw__tower:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}
.gm-tw__tower:disabled { cursor: default }

.gm-tw__base {
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

/* column-reverse so the first child in the DOM is the BOTTOM disc, which is the
   order the solver and every aria-label speak in. */
.gm-tw__slots {
  display: flex;
  flex-direction: column-reverse;
  gap: var(--tw-slot-gap);
  align-self: stretch;
}

.gm-tw__slot,
.gm-tw__disc {
  height: var(--tw-slot);
  border-radius: var(--radius-sm);
}
.gm-tw__slot {
  border: var(--border-width) dashed var(--border-subtle);
}

/* ⭐ Fill and label colour are both PINNED LITERALS. --text-inverse would flip
   with the theme, which is exactly how keypress shipped white-on-vivid-green at
   3.21:1 and only the light theme showed it. Measured, both themes identical:
   red 5.28, amber 11.82, emerald 6.06, blue 6.23, violet 5.55. All clear AA.

   Blue and violet use the -600 shade rather than -500 because white on the
   -500s measured 4.63 and 4.53 — passing, but with no margin at all.

   Teal is not in the set: it sits 32 degrees from blue, and the five that are
   here are 36 degrees apart at their closest. Balloon's palette collision was
   1 degree and made the game unlearnable. */
.gm-tw__disc {
  display: grid;
  place-items: center;
  border: var(--border-width) solid var(--disc-edge);
  background: var(--disc-fill);
  color: var(--disc-fg);
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-body);
  line-height: 1;
  transition: transform var(--dur-fast) var(--ease-out);
}
.gm-tw__disc[data-colour="red"]     { --disc-fill: var(--red-500);     --disc-fg: var(--n-900); --disc-edge: var(--red-700) }
.gm-tw__disc[data-colour="amber"]   { --disc-fill: var(--amber-500);   --disc-fg: var(--n-900); --disc-edge: var(--amber-800) }
.gm-tw__disc[data-colour="emerald"] { --disc-fill: var(--emerald-500); --disc-fg: var(--n-900); --disc-edge: var(--emerald-700) }
.gm-tw__disc[data-colour="blue"]    { --disc-fill: var(--blue-600);    --disc-fg: var(--n-0);   --disc-edge: var(--blue-900) }
.gm-tw__disc[data-colour="violet"]  { --disc-fill: var(--violet-600);  --disc-fg: var(--n-0);   --disc-edge: var(--violet-900) }

/* The lift is a step change, not a flourish. Under prefers-reduced-motion
   --dur-fast is 0ms, so the disc still visibly lifts — it just gets there
   instantly. Reduced motion should lose the animation, never the feedback. */
.gm-tw__disc.is-lifted {
  transform: translateY(-6px);
  box-shadow: var(--elev-2);
}
.gm-tw__disc.is-landed {
  animation: gm-tw-land var(--dur-slow) var(--ease-out);
}
@keyframes gm-tw-land {
  0%   { transform: translateY(-8px) }
  60%  { transform: translateY(1px) }
  100% { transform: translateY(0) }
}

/* Colour is the highlight here, never the message: the announcer says "Solved
   in 11 steps" and the result panel says it again in words. */
.gm-tw__board[data-state="solved"] .gm-tw__tower { border-color: var(--success-solid) }
.gm-tw__board[data-state="timeout"] { opacity: .75 }

/* Undo and Reset are small secondary controls in a row, so they explicitly do
   NOT get .gm-action. That class is `flex: 1 1 200px`, written for balloon's two
   big side-by-side actions, and Money Exchange 1 shipped a 738x200px button by
   reusing it in the wrong axis. */
.gm-controls--towers .otb {
  flex: 0 0 auto;
  min-width: 104px;
}

@media (max-width: 560px) {
  .gm-stage--towers {
    --tw-slot: 36px;
    --tw-mini-slot: 18px;
    padding: var(--space-2);
  }
  .gm-tw__target {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-1);
  }
  .gm-tw__mini {
    margin-left: 0;
    align-self: center;
  }
  .gm-tw__mini .gm-tw__slots { width: 34px }
}

/* --------------------------------------------------------------------------
   Stop

   ⚠️ Nothing about the circle may be transitioned or animated. Every other game
   in this suite can ease its state changes; here the circle's appearance IS the
   stimulus a reaction time is measured against, and easing it in would add the
   easing duration to every reaction time recorded. `transition: none` below is
   load-bearing, not defensive. See stop-game-logic.md §5.
   -------------------------------------------------------------------------- */
.gm-stage--stop {
  position: relative;
  --stop-circle: min(200px, 46vw);
  padding: var(--space-4);
  /* Fixed so the panel never jumps between phases. The intro card is the tallest
     view; easy-or-hard shipped a stage that jumped ~120px eleven times a run
     because its min-height came from reasoning rather than measurement. This one
     is measured — see the verification table in stop-game-logic.md. */
  min-height: 420px;
  /* The whole stage is the tap target on touch, so it must not scroll, select or
     double-tap-zoom under a finger that is deliberately hitting it fast. */
  touch-action: manipulation;
  -webkit-user-select: none;
  user-select: none;
}

/* Every phase's content shares ONE grid cell. `.gm-stage` is display:grid, so
   without this the children stack in rows — and because the circle is toggled
   with `visibility` rather than `display`, an invisible circle still reserves
   its full 200px of row height. The intro card was rendering under a 200px void
   for exactly that reason. Overlapping them also means the circle can never
   change the stage's height, which is what keeps the scored run stable.
 *
   `visibility` is deliberate and must not become `display`. Toggling `display`
   forces layout on the frame the stimulus appears; `visibility` does not, and
   this is the one game where a frame of jitter lands inside the measurement. */
/* ⚠️ The flash is deliberately EXCLUDED. It is absolutely positioned, and for an
   abspos child of a grid container the containing block becomes its GRID AREA
   rather than the stage's padding box — so `inset: 0` resolved against the
   content box and the frame rendered 16px down and to the right of where it
   belonged, flush on two sides and inset by a full padding step on the other
   two. Excluding it puts the containing block back on the padding box, where
   `inset: 0` means what it looks like it means. */
.gm-stage--stop > *:not(.gm-stop__flash) {
  grid-area: 1 / 1;
}

.gm-stop__circle {
  width: var(--stop-circle);
  height: var(--stop-circle);
  border-radius: 50%;
  /* The ring is what satisfies non-text contrast, and it is why the fills below
     are free to be chosen for LUMINANCE rather than for contrast against the
     stage. --text-primary is near-black in light and near-white in dark, so the
     circle's edge reads at roughly 18:1 against the page in both themes. */
  border: 2px solid var(--text-primary);
  visibility: hidden;
  transition: none;
}

/* Red is press, green is do nothing. Inverted relative to every lab version of
   this task and to every traffic light, and that inversion is the thing the game
   is worth practising for.
 *
 * ⚠️ These are NOT --danger-solid / --success-solid, and the reason is a
 * measurement rather than a preference. Those two tokens are luminance-matched
 * by design: they measure 1.15:1 against each other in light and 1.25:1 in dark,
 * which means that to a player with deuteranomaly — around 8% of men, and this
 * whole game hangs on exactly this one distinction — the two circles are very
 * nearly the same grey. The first build shipped that.
 *
 * red-600 against emerald-300 measures 2.29:1 in greyscale, in both themes,
 * while staying unmistakably red and green. That keeps the site 1:1 with the
 * real assessment on hue, which is the decision Nikkesh made, and stops the game
 * being unplayable for a chunk of the people it is for. See §6.1. */
.gm-stop__circle[data-colour="red"] {
  background: var(--red-600);
  visibility: visible;
}
.gm-stop__circle[data-colour="green"] {
  background: var(--emerald-300);
  visibility: visible;
}

/* ⛔ THREE KINDS, AND THE LINE BETWEEN THEM IS THE PHASE, NOT THE MODE.
   `ok` and `error` JUDGE the press and appear in the warm-up only, which itself
   runs on the game page and never at /full-test. `ack` is neutral and is the
   only thing a scored run ever shows, in either mode, because a flash that
   differs between a right press and a wrong one would induce the post-error
   slowing this game exists to measure. See the feedback note in stop.js. */
.gm-stop__flash {
  position: absolute;
  inset: 0;
  /* Follows the stage's corners. Without this the ring is a hard rectangle
     inside a 16px-rounded box, which is what "it looks off" was. `inherit`
     takes the stage's border-box radius, a hairline larger than the padding box
     the ring actually sits in — invisible at a 1px border, and it tracks any
     future change to --radius-lg for free. */
  border-radius: inherit;
  opacity: 0;
  pointer-events: none;
}
.gm-stop__flash.is-live { animation: gm-stop-flash 320ms var(--ease-out) }

/* Warm-up only. These two JUDGE the press — green for a legal one, red for a
   press on a green circle. */
.gm-stop__flash[data-kind="ok"]    { box-shadow: inset 0 0 0 3px var(--success-solid) }
.gm-stop__flash[data-kind="error"] { box-shadow: inset 0 0 0 3px var(--danger-solid) }

/* Scored run, in BOTH modes. Identical for EVERY press, right or wrong — see the
   note in stop.js. Neutral in colour and roughly half as long, so it reads as a
   receipt rather than as a verdict and cannot be mistaken for one at a glance. */
.gm-stop__flash[data-kind="ack"] { box-shadow: inset 0 0 0 3px var(--text-secondary) }
.gm-stop__flash.is-live[data-kind="ack"] { animation: gm-stop-flash 140ms var(--ease-out) }

@keyframes gm-stop-flash {
  0%   { opacity: 1 }
  100% { opacity: 0 }
}
@media (prefers-reduced-motion: reduce) {
  .gm-stop__flash.is-live { animation: none; opacity: 0 }
}

/* Intro, break, void and done all share one card. */
.gm-stop__card {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  align-items: flex-start;
  max-width: 46ch;
  text-align: left;
}
.gm-stop__title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h4);
  line-height: 1.15;
}
.gm-stop__body {
  margin: 0;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-primary);
}
/* --text-secondary, never --text-tertiary. Tertiary measures 3.89:1 on the card
   surface in the LIGHT theme and fails AA at this size. Site-wide trap, flagged
   by money-exchange-1 and still live on other shared chrome. */
.gm-stop__note {
  margin: 0;
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--text-secondary);
}
/* ⚠️ NOT .gm-action. That class is `flex: 1 1 200px`, written for balloon's two
   side-by-side buttons, and in a column that 200px becomes a HEIGHT — which is
   how money-exchange-1 shipped a 738x200px button. */
.gm-stop__start {
  flex: 0 0 auto;
  align-self: flex-start;
  min-width: 168px;
  margin-top: var(--space-1);
}

/* A bar, not a count. A number invites pacing against a known endpoint, and
   pacing is exactly the strategic behaviour that contaminates this measure. */
.gm-progress {
  height: 4px;
  border-radius: var(--radius-pill);
  background: var(--surface-sunken);
  overflow: hidden;
}
.gm-progress__bar {
  display: block;
  width: 0;
  height: 100%;
  background: var(--info-solid);
  transition: width var(--dur-fast) linear;
}
@media (prefers-reduced-motion: reduce) {
  .gm-progress__bar { transition: none }
}

@media (max-width: 560px) {
  .gm-stage--stop {
    padding: var(--space-2);
    min-height: 340px;
  }
  .gm-stop__start { width: 100% }
}

/* The stage is a programmatic focus target (tabindex="-1"), not a control, so it
   must not show a ring when focused by script — but a keyboard user who tabs to
   it still gets one. `:focus-visible` draws exactly that line; `outline: none`
   on plain `:focus` would have removed both. */
.gm-stage--stop:focus { outline: none }
.gm-stage--stop:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}

/* Hidden until the window actually loses keyboard focus mid-run. Uses the same
   --text-secondary the rest of this game's small text uses, never tertiary. */
.gm-stop__focus-notice {
  margin: 0;
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-md);
  background: var(--warning-bg);
  color: var(--text-primary);
  border: var(--border-width) solid var(--warning-border);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
}
.gm-stop__focus-notice[hidden] { display: none }

/* ===== full-test.css ===== */
/* /full-test/ — the sequential run and its report.
 *
 * Everything here is prefixed `ft-`. The runner deliberately reuses the game
 * layer's own classes wherever one already exists — `.gm`, `.gm-result__grid`,
 * `.gm-result__stat`, `.gm-hint`, `.notice`, `.otb` — so a game mounted inside
 * this page looks identical to the same game on its own page. Only the shell
 * around it is new.
 *
 * Concatenated into app.css by build.sh. Adding a file means adding it to the
 * list in build.sh; it is not picked up automatically.
 */

/* --------------------------------------------------------------------------
   Shell
   -------------------------------------------------------------------------- */

/* ⚠️ NO margin-top HERE. `.test-main` is a flex column with `gap: 40px`, so
   every section on the page is already separated by the site's own rhythm.
   This carried `margin-top: var(--space-4)` from when the runner sat below two
   notice blocks inside the same section; once those were removed it was pure
   double-spacing and pushed the card 106px below the header instead of 40. */
.ft-progress {
  margin: 0 0 var(--space-2);
  font: var(--fw-semibold) var(--fs-overline)/var(--lh-overline) var(--font-body);
  letter-spacing: var(--ls-overline);
  text-transform: uppercase;
  color: var(--text-secondary);
}

/* Empty before the run starts, and an empty overline still reserved a line box
   plus its margin — 34px of nothing above the intro card. It collapses instead.
   The height it used to reserve was guarding against a layout shift when the
   first game's progress text appears, but that shift lands on a deliberate
   button press that replaces the whole card anyway, so there was nothing to
   protect. */
.ft-progress:empty { display: none }

/* The stage takes focus on every transition — see the note above show() in
   full-test.js. It must therefore have a visible focus ring when a keyboard
   user lands on it, and must NOT show one on a mouse-driven transition, which
   is exactly the :focus-visible split. */
.ft-stage { outline: none }
.ft-stage:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
  border-radius: var(--radius-lg);
}

/* --------------------------------------------------------------------------
   Cards: intro, per-game brief, report
   -------------------------------------------------------------------------- */

.ft-card {
  padding: var(--space-5);
  background: var(--surface-card);
  border: var(--border-width) solid var(--border-subtle);
  border-top: 3px solid var(--accent);
  border-radius: var(--radius-xl);
}

 /* ⛔ --accent-deep is a LIGHT-THEME-ONLY token. colors.css redefines
    --accent, --accent-fg and --accent-tint for dark mode but NOT --accent-deep,
    so it stays emerald-700 on a near-black card and measured 3.37:1 there.
    --accent-fg is the theme-aware pair. Same fix applied to the report's
    fold-out summary below. */
.ft-card__eyebrow {
  display: block;
  margin: 0 0 var(--space-2);
  color: var(--accent-fg);
}

.ft-card__title {
  margin: 0 0 var(--space-2);
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h2);
  line-height: var(--lh-h2);
  letter-spacing: var(--ls-h2);
}


.ft-card__lede {
  margin: 0 0 var(--space-4);
  max-width: 62ch;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--text-secondary);
}

.ft-card .notice { margin: var(--space-4) 0 0 }

.ft-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-top: var(--space-5);
}

/* --------------------------------------------------------------------------
   Intro facts + the brief's numbered steps
   -------------------------------------------------------------------------- */

.ft-facts {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: var(--space-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.ft-fact {
  display: flex;
  flex-direction: column;
  gap: var(--space-05);
  padding: var(--space-3);
  border: var(--border-width) solid var(--border-subtle);
  border-radius: var(--radius-md);
}

/* Was --text-tertiary, which measures 3.89:1 on --surface-card in light mode
   and fails AA below 18px. Session 18's finding; do not move it back. */
.ft-fact__label { color: var(--text-secondary) }

.ft-fact__value {
  font-family: var(--font-display);
  font-weight: var(--fw-display-medium);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}

.ft-steps {
  margin: 0;
  padding-left: var(--space-5);
  display: grid;
  gap: var(--space-2);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--text-secondary);
}

/* --------------------------------------------------------------------------
   Hub callout + hero alt link. Not part of the runner, but the same feature.
   -------------------------------------------------------------------------- */

.ft-callout {
  padding: var(--space-5);
  background: var(--accent-tint, var(--surface-card));
  border: var(--border-width) solid var(--border-subtle);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius-xl);
}
.ft-callout .faq__intro { max-width: 68ch }
.ft-callout__actions { margin-top: var(--space-4) }

/* `.test-hero__alt` lived here — the "or just see the 12 games" link that sat
   beside the two hero buttons. Deleted 17 Aug 2026 with the hero's other two
   actions, rather than left orphaned. Same call as session 19: dead CSS goes,
   it does not sit around looking load-bearing. */

/* --------------------------------------------------------------------------
   Report
   -------------------------------------------------------------------------- */

/* The run at a glance. Reuses .ft-facts / .ft-fact from the intro card on
   purpose — it is the same kind of object (four flat facts about the run), and
   a second visually-different tile would imply a difference that is not there. */
.ft-overview {
  margin-top: var(--space-5);
  padding: var(--space-4);
  background: var(--accent-tint);
  border-radius: var(--radius-lg);
}

.ft-overview__title,
.ft-tips__title,
.ft-results__title {
  margin: 0 0 var(--space-3);
  font-family: var(--font-display);
  font-weight: var(--fw-display-medium);
  font-size: var(--fs-h4);
  line-height: var(--lh-h4);
}

.ft-overview__note {
  margin: var(--space-3) 0 0;
  max-width: 68ch;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
}

/* ⚠️ The facts inside the overview sit on --accent-tint, not on the card, so
   they get their own surface. Without this the bordered tiles read as holes
   punched in the tinted panel. */
.ft-overview .ft-fact {
  background: var(--surface-card);
  border-color: transparent;
}

/* What to work on. Format and rules only — see the describe() contract in
   games/core.js. */
.ft-tips { margin-top: var(--space-5) }

.ft-tips__lede {
  margin: 0 0 var(--space-3);
  max-width: 68ch;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
}

.ft-tips__list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: var(--space-2);
}

.ft-tip {
  padding: var(--space-3) var(--space-4);
  border: var(--border-width) solid var(--border-subtle);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius-md);
}

.ft-tip__game {
  display: block;
  margin-bottom: var(--space-05);
  color: var(--accent-fg);
}

.ft-tip__text {
  margin: 0;
  max-width: 68ch;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
}

.ft-results__title { margin-top: var(--space-5) }

.ft-results {
  display: grid;
  gap: var(--space-3);
  margin-top: var(--space-3);
}

.ft-result {
  padding: var(--space-4);
  border: var(--border-width) solid var(--border-subtle);
  border-radius: var(--radius-lg);
}

.ft-result__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2);
  margin-bottom: var(--space-2);
}

.ft-result__name {
  margin: 0;
  font-family: var(--font-display);
  font-weight: var(--fw-display-medium);
  font-size: var(--fs-h4);
  line-height: var(--lh-h4);
}

.ft-result__trait { color: var(--text-secondary) }

.ft-result__headline {
  margin: 0 0 var(--space-3);
  max-width: 68ch;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}

/* Twelve full readings stacked is a wall nobody reads, so each one folds. The
   summary is a real control and needs a real focus ring and hit area. */
.ft-result__more { margin-top: var(--space-3) }

.ft-result__more > summary {
  cursor: pointer;
  padding: var(--space-1) 0;
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-semibold);
  color: var(--accent-fg);
}
.ft-result__more > summary:focus-visible {
  outline: var(--focus-width) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
  border-radius: var(--radius-sm);
}

.ft-result__more > p {
  margin: var(--space-2) 0 0;
  max-width: 68ch;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
}

.ft-result__link {
  display: inline-block;
  margin-top: var(--space-3);
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-semibold);
}

/* Printing the report is the closest thing to a save this page has, given
   nothing is stored. Open every reading and drop the controls. */
@media print {
  .ft-progress,
  .ft-actions,
  .ft-result__link { display: none }
  .ft-result__more > p { display: block !important }
  .ft-result { break-inside: avoid }
}

/* ===== previews.css ===== */
/* ==========================================================================
   Game previews — the animated SVG loops on the test hub.

   Replaces the striped `gif · …` placeholder boxes from session 5.

   Rules this file follows, all of them deliberate:

   1. transform and opacity only. Never width, height, x, y, r, top or left.
      Twelve simultaneous loops on the site's most important page is exactly
      where a layout-animating property would show up as jank.
   2. Base CSS is the resting frame. Every loop is written so that switching
      the animation off leaves a still image that still reads. That is what
      makes the prefers-reduced-motion block small.
   3. Colour comes from --accent-fg, which the entry's data-category already
      sets. No preview hardcodes a hue.
   4. Loops are paused while off screen by main.js adding .gp-paused. They run
      by default, so if that script never executes the page is merely less
      efficient rather than visibly broken.
   ========================================================================== */

.gp {
  display: block;
  width: 100%;
  height: 100%;
}

/* SVG defaults: strokes are strokes, fills are explicit. Without this the
   browser fills every path solid black.

   ⚠️ Both selectors are wrapped in :where() on purpose. Written the obvious way,
   `.gp rect` scores 0,1,1 and silently outranks every single-class component
   rule below it (`.gp-pill`, `.gp-node`, `.gp-coin`, `.gp-disc`, …), which
   repainted the balloon, the pills and the discs in pale accent-tint and made
   several of them nearly invisible. :where() contributes zero specificity, so
   these behave as real defaults that any class can override. */
:where(.gp) :where(line, path) {
  fill: none;
  stroke: var(--accent-fg);
  stroke-width: 2.5;
  stroke-linecap: round;
}
:where(.gp) :where(rect, circle) {
  fill: var(--accent-tint);
  stroke: var(--accent-fg);
  stroke-width: 2;
}

.gp-fill-accent {
  fill: var(--accent-fg);
  stroke: none;
}
/* --text-tertiary, not --border-default. Against the frame's own background a
   border token lands around 1.4:1 and the line effectively disappears. */
.gp-stroke-muted {
  stroke: var(--text-tertiary);
  stroke-dasharray: 4 5;
}
.gp-pill {
  fill: var(--surface-card);
  stroke: var(--border-default);
}
.gp-label,
.gp-num {
  fill: var(--text-secondary);
  stroke: none;
  font-family: var(--font-numeric);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.06em;
}
.gp-num {
  font-size: 22px;
  fill: var(--accent-fg);
}
.gp-node,
.gp-head {
  fill: var(--surface-card);
}
/* Declared AFTER .gp-head deliberately. Both are single classes, so source
   order is what decides, and the eyes must win over the face they sit on.
   Styling the head as `.gp-face circle` instead scores 0,1,1, beats this rule,
   and paints the eyes the same colour as the face — which is exactly what
   happened, leaving two blank circles. */
.gp-eye {
  fill: var(--accent-fg);
  stroke: none;
}
.gp-panel {
  fill: var(--surface-card);
  stroke: var(--border-default);
}
.gp-missing {
  font-family: var(--font-numeric);
  font-size: var(--fs-caption);
  color: var(--text-tertiary);
}

/* Off-screen loops idle instead of burning CPU. Applied by main.js. */
.gp-paused .gp,
.gp-paused .gp * {
  animation-play-state: paused !important;
}

/* --- 01 balloon — pumping and banking ---------------------------------- */
.gp-balloon {
  transform-box: fill-box;
  transform-origin: center bottom;
  animation: gp-pump 3.4s var(--ease-out, ease-out) infinite;
}
.gp-bank {
  animation: gp-bank-flash 3.4s ease-in-out infinite;
}
/* Starts at 0.58 rather than 0.45: below that the balloon reads as a dot for
   the first fifth of every loop. */
@keyframes gp-pump {
  0%   { transform: scale(0.58); }
  20%  { transform: scale(0.72); }
  40%  { transform: scale(0.85); }
  60%  { transform: scale(0.97); }
  72%  { transform: scale(1); }
  80%  { transform: scale(0.58); }
  100% { transform: scale(0.58); }
}
@keyframes gp-bank-flash {
  0%, 68%   { fill: var(--surface-card); }
  76%, 84%  { fill: var(--accent-tint); }
  100%      { fill: var(--surface-card); }
}

/* --- 02 digits — sequence flashes, then recall -------------------------- */
.gp-digit {
  animation: gp-digit-flash 4s ease-in-out infinite;
  animation-delay: calc(var(--i) * 0.4s);
}
.gp-digit rect {
  fill: var(--surface-card);
}
.gp-recall line {
  stroke: var(--border-default);
  animation: gp-recall-in 4s ease-in-out infinite;
}
/* The dim state is 0.4, not 0.18. Lower than this and the four boxes read as a
   broken graphic for most of the loop rather than as unlit ones. */
@keyframes gp-digit-flash {
  0%, 4%    { opacity: 0.4; }
  10%, 18%  { opacity: 1; }
  26%, 100% { opacity: 0.4; }
}
@keyframes gp-recall-in {
  0%, 45%   { opacity: 0.45; }
  60%, 88%  { opacity: 1; }
  100%      { opacity: 0.45; }
}

/* --- 03 arrows — rows switching colour ---------------------------------- */
.gp-arrow {
  animation: gp-arrow-swap 2.8s steps(1, end) infinite;
  animation-delay: calc(var(--i) * 0.09s);
}
.gp-swap {
  fill: none;
  stroke: var(--accent-fg);
  animation: gp-swap-frame 2.8s steps(1, end) infinite;
}
@keyframes gp-arrow-swap {
  0%, 49%   { stroke: var(--accent-fg); opacity: 1; }
  50%, 100% { stroke: var(--border-default); opacity: 0.55; }
}
@keyframes gp-swap-frame {
  0%, 49%   { opacity: 0.35; }
  50%, 100% { opacity: 0; }
}

/* --- 04 keypress — GO cue, then rapid tapping --------------------------- */
.gp-go,
.gp-go-text {
  animation: gp-go-cue 2.6s ease-in-out infinite;
}
.gp-key {
  fill: var(--surface-card);
  animation: gp-key-press 2.6s ease-in-out infinite;
  transform-box: fill-box;
  transform-origin: center;
}
.gp-tap {
  fill: none;
  stroke: var(--accent-fg);
  transform-box: fill-box;
  transform-origin: center;
  opacity: 0;
  animation: gp-ripple 2.6s ease-out infinite;
  animation-delay: calc(0.9s + var(--i) * 0.22s);
}
@keyframes gp-go-cue {
  0%, 24%   { opacity: 0.2; }
  32%, 62%  { opacity: 1; }
  74%, 100% { opacity: 0.2; }
}
@keyframes gp-key-press {
  0%, 34%       { transform: scale(1); }
  40%, 50%, 60% { transform: scale(0.94); }
  45%, 55%, 70% { transform: scale(1); }
  100%          { transform: scale(1); }
}
@keyframes gp-ripple {
  0%   { opacity: 0.7; transform: scale(0.6); }
  40%  { opacity: 0;   transform: scale(1.5); }
  100% { opacity: 0;   transform: scale(1.5); }
}

/* --- 05 cards — drawing from four decks --------------------------------- */
.gp-deck rect {
  fill: var(--surface-card);
  animation: gp-deck-cycle 4.8s ease-in-out infinite;
  animation-delay: calc(var(--i) * 1.2s);
}
.gp-draw rect {
  transform-box: fill-box;
  transform-origin: center bottom;
  animation: gp-card-draw 4.8s ease-in-out infinite;
}
@keyframes gp-deck-cycle {
  0%, 6%    { fill: var(--accent-tint); }
  22%, 100% { fill: var(--surface-card); }
}
@keyframes gp-card-draw {
  0%       { opacity: 0; transform: translateY(0) rotate(0deg); }
  8%       { opacity: 1; transform: translateY(0) rotate(0deg); }
  26%      { opacity: 1; transform: translateY(-26px) rotate(-7deg); }
  42%      { opacity: 0; transform: translateY(-26px) rotate(-7deg); }
  100%     { opacity: 0; transform: translateY(0) rotate(0deg); }
}

/* --- 06 money exchange 1 — send, then receive back --------------------- */
.gp-coin {
  fill: var(--accent-fg);
  stroke: none;
  transform-box: fill-box;
}
.gp-coin--out {
  animation: gp-send 4s ease-in-out infinite;
}
.gp-coin--back {
  animation: gp-return 4s ease-in-out infinite;
}
.gp-dash {
  animation: gp-dash-pulse 4s ease-in-out infinite;
}
@keyframes gp-send {
  0%, 6%    { opacity: 0; transform: translateX(0); }
  14%       { opacity: 1; transform: translateX(0); }
  40%       { opacity: 1; transform: translateX(124px); }
  46%, 100% { opacity: 0; transform: translateX(124px); }
}
@keyframes gp-return {
  0%, 50%   { opacity: 0; transform: translateX(0); }
  58%       { opacity: 1; transform: translateX(0); }
  84%       { opacity: 1; transform: translateX(-124px); }
  90%, 100% { opacity: 0; transform: translateX(-124px); }
}
@keyframes gp-dash-pulse {
  0%, 100% { opacity: 0.4; }
  45%      { opacity: 1; }
}

/* --- 07 money exchange 2 — splitting a bonus ---------------------------- */
/* No `opacity: 0` here, deliberately. animation-fill-mode is `none`, so the base
   style IS the resting frame — setting the base to 0 made all six bars vanish
   whenever the animation was off, which is the whole preview gone under
   prefers-reduced-motion. The fade-in belongs in the keyframes, not the base. */
.gp-split rect {
  fill: var(--accent-tint);
  transform-box: fill-box;
  transform-origin: center;
  animation: gp-stack 4.2s ease-out infinite;
  animation-delay: calc(var(--i) * 0.28s);
}
.gp-split--right rect {
  animation-name: gp-stack-late;
  animation-delay: calc(0.5s + var(--i) * 0.28s);
}
@keyframes gp-stack {
  0%, 4%    { opacity: 0; transform: translateY(-14px); }
  16%, 84%  { opacity: 1; transform: translateY(0); }
  96%, 100% { opacity: 0; transform: translateY(0); }
}
@keyframes gp-stack-late {
  0%, 4%    { opacity: 0; transform: translateY(-14px); }
  20%, 84%  { opacity: 1; transform: translateY(0); }
  96%, 100% { opacity: 0; transform: translateY(0); }
}

/* --- 08 easy or hard — a selector moving between two panels ------------- */
.gp-pick {
  fill: none;
  stroke: var(--accent-fg);
  stroke-width: 3;
  transform-box: fill-box;
  animation: gp-pick-move 3.6s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
@keyframes gp-pick-move {
  0%, 34%   { transform: translateX(0); }
  50%, 84%  { transform: translateX(128px); }
  100%      { transform: translateX(0); }
}

/* --- 09 lengths — short mouth versus long mouth ------------------------- */
.gp-face {
  animation: gp-face-focus 3.2s ease-in-out infinite;
  animation-delay: calc(var(--i) * 1.6s);
}
.gp-mouth {
  stroke: var(--accent-fg);
  stroke-width: 3;
}
@keyframes gp-face-focus {
  0%, 6%    { opacity: 1; }
  46%, 96%  { opacity: 0.32; }
  100%      { opacity: 1; }
}

/* --- 10 faces — a face and three emotion words -------------------------- */
.gp-mouth--curve {
  stroke: var(--accent-fg);
  stroke-width: 3;
}
.gp-option {
  animation: gp-option-scan 4.2s ease-in-out infinite;
  animation-delay: calc(var(--i) * 0.5s);
}
.gp-option .gp-pill {
  transform-box: fill-box;
  transform-origin: center;
}
@keyframes gp-option-scan {
  0%, 8%    { opacity: 0.4; }
  18%, 30%  { opacity: 1; }
  42%, 100% { opacity: 0.4; }
}

/* --- 11 stop — go and stop signals alternating --------------------------
   The only preview that does not use the entry's accent hue. This game really
   is red and green circles, and depicting it in career-green would describe a
   different game. The square inside the stop signal is a deliberate second
   cue, so the two states are still distinguishable without colour vision.
   Raw --red-500/--emerald-500 are used rather than --cat-* tokens because the
   --cat-* set is remapped in dark mode and these two must not shift. */
.gp-signal--go {
  fill: var(--emerald-500);
  stroke: var(--emerald-600);
  animation: gp-signal-a 2.2s steps(1, end) infinite;
}
.gp-signal--stop {
  animation: gp-signal-b 2.2s steps(1, end) infinite;
}
.gp-signal--stop circle {
  fill: var(--red-500);
  stroke: var(--red-600);
}
.gp-signal--stop rect {
  fill: var(--surface-page);
  stroke: none;
}
@keyframes gp-signal-a {
  0%, 54%   { opacity: 1; }
  55%, 100% { opacity: 0; }
}
@keyframes gp-signal-b {
  0%, 54%   { opacity: 0; }
  55%, 100% { opacity: 1; }
}

/* --- 12 towers — a disc hopping between pegs ---------------------------- */
.gp-peg {
  stroke: var(--text-tertiary);
  stroke-width: 4;
}
/* Solid, unlike .gp-stroke-muted. A dashed floor under the pegs read as an
   unfinished graphic rather than as ground. */
.gp-floor {
  stroke: var(--text-tertiary);
  stroke-width: 2;
}
.gp-disc {
  fill: var(--accent-tint);
  stroke: var(--accent-fg);
  transform-box: fill-box;
}
.gp-disc--mover {
  animation: gp-hop 4s cubic-bezier(0.45, 0, 0.55, 1) infinite;
}
@keyframes gp-hop {
  0%   { transform: translate(0, 0); }
  18%  { transform: translate(28px, -56px); }
  34%  { transform: translate(70px, -14px); }
  50%  { transform: translate(70px, -14px); }
  66%  { transform: translate(112px, -56px); }
  82%  { transform: translate(140px, -14px); }
  100% { transform: translate(140px, -14px); }
}

/* --------------------------------------------------------------------------
   Reduced motion. Every loop above rests on a readable frame, so switching
   the animations off is nearly enough on its own. These four rules cover the
   cases whose resting state is deliberately invisible.
   -------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  .gp,
  .gp * {
    animation: none !important;
  }
  /* Only three overrides are needed, because every base state above is already
     a readable frame. Anything more than this is a sign a loop is hiding its
     resting state in the base rule instead of in its keyframes. */
  .gp-balloon {
    transform: scale(0.85);
  }
  /* One coin in flight reads as a transfer; two reads as a bug. */
  .gp-coin--back {
    opacity: 0;
  }
  /* The two signals sit on the same centre, so showing both at once blends red
     over green into a muddy olive that reads as neither. Rest on the stop state
     alone: a red circle with a cut-out square is unambiguous, and the caption
     already says the two alternate. */
  .gp-signal--go {
    opacity: 0;
  }
  .gp-signal--stop {
    opacity: 1;
  }
  /* Rest the drawn card lifted clear of the deck it came from. */
  .gp-draw rect {
    transform: translateY(-26px) rotate(-7deg);
  }
}

/* ===== category.css ===== */
/* --------------------------------------------------------------------------
   Category browse page — the accent band, the six-way switcher and the grid.
   Every value reads from the token files. The page frame itself (.test-layout,
   .test-main, .test-rail) and the cards (.otcard--solid) are reused from
   games.css and site.css, so a card here and a card on the home page cannot
   drift apart.
   -------------------------------------------------------------------------- */

/* --------------------------------------------------------------------------
   Hero band. Solid --accent-deep, which is the one accent value guaranteed to
   carry --accent-on at AA in every category. Do NOT swap it for --accent: the
   plain hue fails contrast against white text on amber and on teal.
   -------------------------------------------------------------------------- */
.cat-hero {
  position: relative;
  overflow: hidden;
  background: var(--accent-deep);
  color: var(--accent-on);
}
.cat-hero__inner {
  position: relative;   /* lifts the copy above the decorative ring */
  z-index: 1;
  max-width: var(--container-max);
  margin-inline: auto;
  padding: var(--space-3) var(--gutter) var(--space-6);
}

/* Decorative, and marked aria-hidden in the markup. Sized in px rather than a
   token because it is a graphic, not layout — it is meant to bleed off the
   corner at every width. */
.cat-hero__ring {
  position: absolute;
  top: -140px;
  right: -100px;
  width: 420px;
  height: 420px;
  border: 46px solid color-mix(in srgb, var(--accent-on) 14%, transparent);
  border-radius: 50%;
  pointer-events: none;
}
/* Languages is the one band with DARK ink on a light fill, and dark-on-amber at
   14% reads far heavier than white-on-emerald at the same figure. Same graphic,
   matched weight. */
.cat-hero[data-category="language"] .cat-hero__ring {
  border-color: color-mix(in srgb, var(--accent-on) 8%, transparent);
}

/* The breadcrumb inherits tertiary grey from games.css, which is invisible on a
   saturated fill. Tie it to the band's own ink instead. */
.cat-hero .crumbs { color: color-mix(in srgb, var(--accent-on) 72%, transparent) }
.cat-hero .crumbs a:hover { color: var(--accent-on) }
.cat-hero .crumbs [aria-current] { color: var(--accent-on) }

.cat-hero__head {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-3);
}
.cat-hero__glyph {
  display: grid;
  place-items: center;
  flex: none;
  width: 56px;
  height: 56px;
  border-radius: var(--radius-lg);
  background: color-mix(in srgb, var(--accent-on) 20%, transparent);
  color: var(--accent-on);
}
.cat-hero__glyph .ot-icon { width: 28px; height: 28px }

.cat-hero__title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-display-2);
  line-height: var(--lh-display-2);
  letter-spacing: var(--ls-display-2);
  text-wrap: balance;
}
.cat-hero__lede {
  margin: var(--space-2) 0 0;
  max-width: 52ch;
  font-size: var(--fs-body-lg);
  line-height: var(--lh-body-lg);
  color: color-mix(in srgb, var(--accent-on) 88%, transparent);
  text-wrap: pretty;
}

.cat-hero__pills {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1);
  margin-top: var(--space-3);
}
.cat-pill {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 6px 14px;
  border: var(--border-width) solid color-mix(in srgb, var(--accent-on) 32%, transparent);
  border-radius: var(--radius-pill);
  background: color-mix(in srgb, var(--accent-on) 14%, transparent);
  font-size: var(--fs-body-sm);
  color: var(--accent-on);
}
.cat-pill b { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums }

/* --------------------------------------------------------------------------
   Category switcher. This is the page's filter control, and every pill is a
   real destination — see the note in category.njk. Each pill carries its OWN
   data-category, so the row reads as the six-hue palette spine rather than six
   copies of the current colour.
   -------------------------------------------------------------------------- */
.cat-nav {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1);
  max-width: var(--container-max);
  margin-inline: auto;
  padding: var(--space-4) var(--gutter) 0;
}
.cat-nav__item {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 42px;
  padding: 0 18px;
  border: var(--border-width) solid var(--border-subtle);
  border-radius: var(--radius-pill);
  background: var(--surface-card);
  color: var(--text-primary);
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-medium);
  white-space: nowrap;
  text-decoration: none;
  transition: var(--transition-ui);
}
.cat-nav__item .ot-icon { color: var(--accent-fg) }
a.cat-nav__item:hover {
  color: var(--text-primary);
  text-decoration: none;
  border-color: var(--accent);
  box-shadow: var(--elev-1);
  transform: translateY(-1px);
}
.cat-nav__item[aria-current="page"] {
  background: var(--accent-deep);
  border-color: var(--accent-deep);
  color: var(--accent-on);
}
.cat-nav__item[aria-current="page"] .ot-icon { color: var(--accent-on) }
a.cat-nav__item[aria-current="page"]:hover { color: var(--accent-on); box-shadow: none; transform: none }

/* No page yet. Keeps full icon saturation — the palette is how the site says
   which category you are in, so dimming four of six breaks the spine. The
   missing link affordance carries the meaning. Matches .category-tile--soon. */
.cat-nav__item--soon {
  background: transparent;
  border-style: dashed;
  color: var(--text-tertiary);
  cursor: default;
}

/* --------------------------------------------------------------------------
   Toolbar and grid
   -------------------------------------------------------------------------- */
.cat-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  flex-wrap: wrap;
  padding-bottom: var(--space-2);
  border-bottom: var(--border-width) solid var(--border-subtle);
}
.cat-bar__sort { font-size: var(--fs-body-sm); color: var(--text-secondary) }
.cat-bar__link {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-semibold);
}

/* Two columns, not auto-fit: beside a 300px ad rail an auto-fit track drops to
   one column at the exact width where two still fit comfortably. */
.cat-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--grid-gap);
}
@media (max-width: 700px) { .cat-grid { grid-template-columns: minmax(0, 1fr) } }

.cat-note {
  max-width: 68ch;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-tertiary);
}

@media (max-width: 620px) {
  .cat-hero__head { gap: var(--space-2) }
  .cat-hero__glyph { width: 46px; height: 46px }
  .cat-hero__glyph .ot-icon { width: 24px; height: 24px }
  /* The ring keeps its size while the band gets narrower and shorter, so at
     phone width it stops being a corner detail and becomes a blob behind the
     lede. Smaller and pushed further off the corner. */
  .cat-hero__ring {
    top: -120px;
    right: -170px;
    width: 300px;
    height: 300px;
    border-width: 34px;
  }
  /* Six pills at 42px each stack to six rows on a phone and push the grid off
     screen. Scroll them sideways instead, the way the game rail does. */
  .cat-nav {
    flex-wrap: nowrap;
    overflow-x: auto;
    overscroll-behavior-inline: contain;
    scrollbar-width: none;
    padding-bottom: var(--space-05);
  }
  .cat-nav::-webkit-scrollbar { display: none }
}

/* ===== lang.css ===== */
/* ==========================================================================
   Language test layer.

   The one stylesheet the CEFR language test pages need on top of the
   design system. Kept in its own file, and loaded through build.sh like every
   other layer.

   Nothing here overrides an existing rule: every selector is new, and every
   value comes from an existing token. No new colours, no new spacing values,
   no hardcoded hex.
   ========================================================================== */

/* --------------------------------------------------------------------------
   CEFR ladder
   Six rungs, each carrying one of the six category hues via data-category,
   the same trick the game entries use.
   -------------------------------------------------------------------------- */

.ladder {
  list-style: none;
  margin: var(--space-4) 0 0;
  padding: 0;
  display: grid;
  gap: var(--space-1);
}

.ladder__step {
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(0, 8rem) minmax(0, 1fr) auto;
  align-items: baseline;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  background: var(--surface-card);
  border: 1px solid var(--border-subtle);
  border-left: 4px solid var(--accent);
  border-radius: var(--radius-md);
}

.ladder__step--current {
  background: var(--accent-tint);
  border-color: var(--accent);
  border-left-width: 6px;
}

.ladder__code {
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h4);
  color: var(--accent-fg);
  min-width: 2.25rem;
}

.ladder__name {
  font-weight: var(--fw-semibold);
  font-size: var(--fs-body);
  color: var(--text-primary);
}

.ladder__desc {
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
}

.ladder__badge {
  font-size: var(--fs-overline);
  letter-spacing: var(--ls-overline);
  text-transform: uppercase;
  font-weight: var(--fw-semibold);
  white-space: nowrap;
  padding: var(--space-025) var(--space-1);
  border-radius: var(--radius-pill);
  background: var(--accent);
  color: var(--accent-on, var(--text-on-accent));
}

@media (max-width: 720px) {
  .ladder__step {
    grid-template-columns: auto minmax(0, 1fr);
    row-gap: var(--space-05);
  }
  .ladder__desc { grid-column: 1 / -1; }
  .ladder__badge { grid-column: 2; justify-self: start; }
}

/* --------------------------------------------------------------------------
   Exam note
   The block that names the real certificates, plus the TEFL / TestDaF
   correction. The warning paragraph is the reason this block exists.
   -------------------------------------------------------------------------- */

.exam-note {
  margin-top: var(--space-4);
  padding: var(--space-3);
  background: var(--surface-sunken);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
}

.exam-note__title {
  font-family: var(--font-display);
  font-size: var(--fs-h4);
  line-height: var(--lh-h4);
  font-weight: var(--fw-display-medium);
  margin: 0 0 var(--space-1);
  color: var(--text-primary);
}

.exam-note p {
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
  margin: 0 0 var(--space-2);
}

.exam-note__list {
  margin: 0 0 var(--space-2);
  padding-left: var(--space-3);
  display: grid;
  gap: var(--space-05);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
}

.exam-note__list strong { color: var(--text-primary); }

.exam-note__warn {
  margin: 0;
  padding: var(--space-2);
  background: var(--info-bg);
  border: 1px solid var(--info-border);
  border-radius: var(--radius-md);
}

/* --------------------------------------------------------------------------
   Skill placeholder
   Stands in for listening and speaking, which cannot be practised with a
   multiple-choice widget. Borrows the striped look of the design system's
   media placeholder so it reads as "slot reserved" rather than "broken".
   -------------------------------------------------------------------------- */

.skill-de {
  font-family: var(--font-display);
  font-weight: var(--fw-display-medium);
  font-size: 0.75em;
  color: var(--accent-fg);
  margin-left: var(--space-1);
}

.skill-de::before { content: "· "; color: var(--text-tertiary); }

.skill-placeholder {
  margin: var(--space-3) 0 var(--space-2);
  padding: var(--space-4) var(--space-3);
  border: 1px dashed var(--border-default);
  border-radius: var(--radius-md);
  text-align: center;
  background-color: var(--surface-sunken);
  background-image: repeating-linear-gradient(
    -45deg,
    transparent 0 10px,
    var(--accent-tint) 10px 20px
  );
}

.skill-placeholder__label {
  display: block;
  font-size: var(--fs-overline);
  letter-spacing: var(--ls-overline);
  text-transform: uppercase;
  font-weight: var(--fw-semibold);
  color: var(--accent-fg);
}

.skill-placeholder__note {
  max-width: 46ch;
  margin: var(--space-2) auto 0;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
}

/* --------------------------------------------------------------------------
   Question sets
   This is what replaces the animated preview slot on the Pymetrics hub.
   Same position inside .game-entry, same visual weight, but it does something.
   -------------------------------------------------------------------------- */

.qset {
  margin: var(--space-3) 0 var(--space-2);
  display: grid;
  gap: var(--space-2);
}

.q {
  padding: var(--space-2) var(--space-3);
  background: var(--surface-sunken);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
}

/* The German source text a reading question is asked about. Set apart from the
   question itself so it is obvious which part is the material and which part is
   being asked, and monospaced-ish via the display face for a "document" feel. */
.q__source {
  margin: 0 0 var(--space-2);
  padding: var(--space-2);
  background: var(--surface-card);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius-sm);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--text-primary);
}

.q__source-label {
  display: block;
  margin-bottom: var(--space-05);
  font-size: var(--fs-overline);
  letter-spacing: var(--ls-overline);
  text-transform: uppercase;
  font-weight: var(--fw-semibold);
  color: var(--text-secondary);
}

.q__text {
  margin: 0 0 var(--space-2);
  font-size: var(--fs-question);
  line-height: var(--lh-question);
  font-weight: var(--fw-medium);
  color: var(--text-primary);
}

/* Two columns, not auto-fit. Every question here has four options, and auto-fit
   packed them 3 + 1 at desktop width, which leaves an orphan on its own row and
   reads like a mistake. A fixed pair gives a clean 2 x 2 block instead. */
.q__opts {
  display: grid;
  gap: var(--space-1);
  grid-template-columns: 1fr;
}

@media (min-width: 560px) {
  .q__opts { grid-template-columns: repeat(2, 1fr); }
}

.q__opt {
  font: inherit;
  font-size: var(--fs-body-sm);
  text-align: left;
  cursor: pointer;
  padding: var(--space-1) var(--space-2);
  min-height: 2.75rem;
  color: var(--text-primary);
  background: var(--surface-card);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-sm);
  /* Transform only, per the project's performance rule: animate
     compositor-friendly properties and leave paint properties alone. Colour
     still changes on hover and on answer, it just changes instantly.

     Historical note so nobody "fixes" this back: an earlier version also
     transitioned background-color and border-color, and automated checks
     appeared to show the button keeping the old theme's colour after a theme
     flip. That turned out to be the test environment, which freezes CSS
     transitions at currentTime 0, not a real defect. The transform-only rule
     was kept anyway because it is the correct choice on its own merits. */
  transition: transform var(--dur-fast) var(--ease-out);
}

.q__opt:hover:not(:disabled) {
  border-color: var(--accent);
  background: var(--accent-tint);
  transform: translateY(-1px);
}

.q__opt:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.q__opt:disabled { cursor: default; }

/* Resolved states. The mark is a pseudo element so the option text is never
   pushed around when the answer lands. */
.q__opt[data-state="correct"] {
  background: var(--success-bg);
  border-color: var(--success-border);
  color: var(--success-fg);
  font-weight: var(--fw-semibold);
}

.q__opt[data-state="correct"]::after {
  content: "✓";
  float: right;
  margin-left: var(--space-1);
}

.q__opt[data-state="wrong"] {
  background: var(--danger-bg);
  border-color: var(--danger-border);
  color: var(--danger-fg);
}

.q__opt[data-state="wrong"]::after {
  content: "✕";
  float: right;
  margin-left: var(--space-1);
}

.q__opt[data-state="muted"] { opacity: 0.55; }

/* The explanation. Hidden until the question is answered, because showing it
   first would give the answer away. */
.q__why {
  margin: var(--space-2) 0 0;
  padding-left: var(--space-2);
  border-left: 3px solid var(--accent);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
  display: none;
}

.q[data-done="true"] .q__why { display: block; }

@media (prefers-reduced-motion: reduce) {
  .q__opt { transition: none; }
  .q__opt:hover:not(:disabled) { transform: none; }
}

/* --------------------------------------------------------------------------
   Question slider
   One question at a time, with prev and next, a dot per question and a live
   count. The dots double as a progress read: they colour in as questions are
   answered, so a glance says how far through the section you are.

   Accessibility notes, because a carousel is easy to get wrong:
   - Off-screen slides get `inert`, which removes them from the tab order and
     from the accessibility tree. Without it, tabbing walks into invisible
     options and the page appears to lose focus.
   - The count is aria-live, so moving between questions is announced.
   - Arrow keys work while focus is inside the slider, and are not hijacked
     anywhere else on the page.
   -------------------------------------------------------------------------- */

.qslider {
  margin: var(--space-3) 0 var(--space-2);
}

.qslider__viewport {
  overflow: hidden;
  border-radius: var(--radius-md);
}

.qslider__track {
  display: flex;
  align-items: flex-start;
  /* JS sets transform directly. See the note on .result__fill for why this is
     not driven by a custom property. */
  transform: translateX(0);
  transition: transform var(--dur-base) var(--ease-out);
}

.qslide {
  flex: 0 0 100%;
  min-width: 0;
}

.qslider__controls {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-2);
}

.qslider__btn {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  height: 2.75rem;
  cursor: pointer;
  color: var(--text-primary);
  background: var(--surface-card);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-pill);
  transition: transform var(--dur-fast) var(--ease-out);
}

.qslider__btn:hover:not(:disabled) {
  border-color: var(--accent);
  transform: translateY(-1px);
}

.qslider__btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.qslider__btn:disabled {
  opacity: 0.4;
  cursor: default;
}

/* One arrow icon, flipped for the previous button. */
.qslider__btn--prev .ot-icon { transform: rotate(180deg); }

.qslider__dots {
  display: flex;
  flex: 1 1 auto;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-1);
}

/* The visible bar is 6px tall, but the button is 24px so it meets the WCAG 2.2
   minimum target size. The padding is transparent and background-clip keeps the
   paint inside the content box, so the hit area is bigger than the mark. */
.qslider__dot {
  width: 1.75rem;
  height: 1.5rem;
  padding: 0.5625rem 0;
  box-sizing: border-box;
  cursor: pointer;
  border: 0;
  border-radius: var(--radius-pill);
  background-color: var(--border-default);
  background-clip: content-box;
  transition: transform var(--dur-fast) var(--ease-out);
}

.qslider__dot:hover { transform: scaleY(1.6); }

.qslider__dot:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.qslider__dot[aria-current="true"] { background-color: var(--accent); }
.qslider__dot[data-answered="correct"] { background-color: var(--success-solid); }
.qslider__dot[data-answered="wrong"] { background-color: var(--danger-solid); }

.qslider__count {
  margin: var(--space-1) 0 0;
  text-align: center;
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--text-secondary);
}

@media (prefers-reduced-motion: reduce) {
  .qslider__track,
  .qslider__btn,
  .qslider__dot { transition: none; }
  .qslider__btn:hover:not(:disabled),
  .qslider__dot:hover { transform: none; }
  .qslider__btn--prev .ot-icon { transform: rotate(180deg); }
}

/* --------------------------------------------------------------------------
   Running result
   Fills in as questions are answered. Nothing is stored, so this resets on
   reload by design and the copy says so.
   -------------------------------------------------------------------------- */

.result {
  margin-top: var(--space-3);
  padding: var(--space-4);
  background: var(--surface-card);
  border: 1px solid var(--border-subtle);
  border-top: 4px solid var(--accent);
  border-radius: var(--radius-lg);
}

.result__head {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  flex-wrap: wrap;
}

.result__score {
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-score);
  line-height: 1;
  color: var(--accent-fg);
  font-variant-numeric: tabular-nums;
}

.result__of {
  font-size: var(--fs-h4);
  color: var(--text-tertiary);
}

.result__label {
  font-size: var(--fs-body);
  color: var(--text-secondary);
}

.result__bar {
  margin: var(--space-3) 0 var(--space-2);
  height: 0.5rem;
  border-radius: var(--radius-pill);
  background: var(--surface-sunken);
  overflow: hidden;
}

/* Scaled rather than resized. Animating width is layout-bound; scaleX runs on
   the compositor and gives the same visual.

   JS sets the whole `transform` value rather than a custom property that this
   rule reads. `scaleX(var(--fill))` would not animate smoothly, because an
   unregistered custom property is not interpolatable and so jumps rather than
   transitions. Registering it with @property would also work; setting the
   transform directly is simpler and needs no extra declaration. */
.result__fill {
  display: block;
  height: 100%;
  width: 100%;
  transform: scaleX(0);
  transform-origin: left center;
  background: var(--accent);
  border-radius: var(--radius-pill);
  transition: transform var(--dur-base) var(--ease-out);
}

.result__verdict {
  margin: 0 0 var(--space-2);
  font-size: var(--fs-body-lg);
  line-height: var(--lh-body-lg);
  color: var(--text-primary);
}

/* --text-secondary rather than --text-tertiary. Tertiary measures about 3.9:1
   against the card in light mode, which fails AA for text this small, and this
   paragraph carries the "not a certificate" caveat so it has to be readable. */
.result__caveat {
  margin: 0;
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--text-secondary);
}

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

/* --------------------------------------------------------------------------
   Next levels
   The A2 / B1 / B2 / C1 hand-off. Same shape as the home page category tiles
   so a visitor recognises it, but deliberately quieter since none are live.
   -------------------------------------------------------------------------- */

.next-grid {
  margin-top: var(--space-4);
  display: grid;
  gap: var(--space-2);
  grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
}

.next-card {
  position: relative;
  padding: var(--space-3);
  background: var(--surface-card);
  border: 1px solid var(--border-subtle);
  border-top: 3px solid var(--accent);
  border-radius: var(--radius-md);
}

.next-card__code {
  display: inline-block;
  font-family: var(--font-display);
  font-weight: var(--fw-display-bold);
  font-size: var(--fs-h3);
  line-height: 1;
  color: var(--accent-fg);
}

.next-card__title {
  margin: var(--space-1) 0 var(--space-05);
  font-family: var(--font-display);
  font-size: var(--fs-h4);
  line-height: var(--lh-h4);
  font-weight: var(--fw-display-medium);
  color: var(--text-primary);
}

.next-card__desc {
  margin: 0 0 var(--space-2);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--text-secondary);
}

/* Secondary rather than tertiary for the same reason as .result__caveat:
   tertiary drops to about 3.9:1 in light mode, and "In build" is information
   rather than decoration. */
.next-card__soon {
  display: inline-block;
  font-size: var(--fs-overline);
  letter-spacing: var(--ls-overline);
  text-transform: uppercase;
  font-weight: var(--fw-semibold);
  color: var(--text-secondary);
  padding: var(--space-025) var(--space-1);
  border: 1px dashed var(--border-default);
  border-radius: var(--radius-pill);
}
