@layer reset, tokens, base, layout, components, utilities;

@layer reset {
  *,
  *::before,
  *::after {
    box-sizing: border-box;
  }

  body,
  fieldset,
  h1,
  p {
    margin: 0;
  }
}

@layer tokens {
  :root {
    color-scheme: light dark;

    /* Colors — light scheme. Roles, not values. */
    --color-bg: oklch(99% 0.002 260);
    --color-text: oklch(22% 0.02 260);

    --color-surface: oklch(96% 0.005 260);
    --color-text-muted: oklch(45% 0.02 260);
    --color-accent: oklch(45% 0.17 260);
    --color-error: oklch(45% 0.17 25);
    --color-border: oklch(62% 0.02 260);

    /* The canvas keeps a dark ground in both schemes: the game draws its
       white "Loading..." and "Click to start" text on a cleared canvas. */
    --color-canvas: oklch(18% 0.01 260);

    /* The hero selection: the pick page and the room page, dark in both
       schemes. A warm near-black ground, parchment text, gold for what is
       chosen, and the light the chosen hero stands in. */
    --color-select-ground: oklch(13% 0.012 60);
    --color-select-panel: oklch(19% 0.016 60);
    --color-select-text: oklch(90% 0.025 85);
    --color-select-muted: oklch(74% 0.03 80);
    --color-select-gold: oklch(80% 0.13 82);
    --color-select-border: oklch(52% 0.04 70);
    --color-select-error: oklch(74% 0.13 32);
    --color-select-light: oklch(62% 0.12 70 / 0.35);

    --space: clamp(1rem, 0.5rem + 2vw, 2rem);
    --space-sm: calc(var(--space) / 2);
    --space-xs: calc(var(--space) / 4);
    --font-body: system-ui, sans-serif;
    --font-mono: ui-monospace, monospace;
    --font-display: ui-serif, Georgia, serif;

    /* The selection's type scale: one face, the display serif, and five
       sizes, so a thing's size says what it is. A caption names a group, the
       body says what something does, a name names a hero, a skill or a
       mode, the chosen hero's name is larger, and the title is the
       largest. */
    --text-caption: 0.875rem;
    --text-body: 1rem;
    --text-name: 1.25rem;
    --text-chosen: 1.5rem;
    --text-title: clamp(1.75rem, 1rem + 3vw, 3rem);
  }

  @media (prefers-color-scheme: dark) {
    :root {
      --color-bg: oklch(18% 0.01 260);
      --color-surface: oklch(24% 0.01 260);
      --color-text: oklch(93% 0.01 260);
      --color-text-muted: oklch(72% 0.02 260);
      --color-accent: oklch(75% 0.12 260);
      --color-error: oklch(75% 0.12 25);
      --color-border: oklch(56% 0.02 260);
    }
  }

  /* The pick and room pages are the hero selection, and a selection is a
     dark screen whatever the system asks for: the roles point at its
     palette, so every rule below that reads a role reads it dark. */
  :root:has(.pick, .room) {
    accent-color: var(--color-select-gold);
    color-scheme: dark;
    --color-bg: var(--color-select-ground);
    --color-surface: var(--color-select-panel);
    --color-text: var(--color-select-text);
    --color-text-muted: var(--color-select-muted);
    --color-accent: var(--color-select-gold);
    --color-error: var(--color-select-error);
    --color-border: var(--color-select-border);
  }
}

@layer base {
  body {
    background: var(--color-bg);
    color: var(--color-text);
    font-family: var(--font-body);
    line-height: 1.5;
    min-block-size: 100dvb;
    overflow-wrap: anywhere;
  }

  h1 {
    font-size: clamp(1.5rem, 1rem + 2vw, 2.5rem);
    line-height: 1.2;
    text-align: center;
  }

  a {
    color: var(--color-accent);
  }

  code {
    font-family: var(--font-mono);
  }

  button,
  input {
    font: inherit;
    max-inline-size: 100%;
  }

  fieldset {
    border: 1px solid var(--color-border);
    min-inline-size: 0;
    padding: var(--space-sm);
  }

  input:not([type="radio"]) {
    background: var(--color-bg);
    border: 1px solid var(--color-border);
    color: var(--color-text);
    padding: var(--space-xs);
  }

  button {
    background: var(--color-accent);
    border: 1px solid var(--color-accent);
    color: var(--color-bg);
    cursor: pointer;
    padding: var(--space-xs) var(--space);
  }

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

@layer layout {
  main {
    display: grid;
    gap: var(--space);
    min-block-size: 100dvb;
    padding: var(--space);
    place-content: center;
    place-items: center;
  }
}

@layer components {
  /* The hero selection, the pick page and the room page's join form: the
     heroes standing in a row, the chosen one's kit in a panel under them,
     the how-to and the opponent along the bottom, and Play at their right. */
  .pick {
    display: grid;
    gap: var(--space);
    inline-size: min(100%, 60rem);
  }

  /* The selection speaks in one face. Every rule under here sets a size
     from the scale and never a family, and the controls inherit it. */
  :root:has(.pick, .room) body {
    font-family: var(--font-display);
    font-size: var(--text-body);
  }

  :root:has(.pick, .room) code {
    color: var(--color-accent);
    font-family: inherit;
    letter-spacing: 0.12em;
  }

  /* The game's name over the selection is a title engraved in gold: the
     letters cut from a gradient lit from above, a dark edge under them and
     the room's light around them, a gleam passing over them now and then,
     and on a wide screen a rule either side ending in a gem. It settles into
     place once, as a title card does. */
  :root:has(.pick, .room) h1 {
    --gold-high: color-mix(in oklch, var(--color-accent), white 45%);
    --gold-low: color-mix(in oklch, var(--color-accent), black 30%);
    --gleam: white;
    color: var(--color-accent);
    font-size: var(--text-title);
    font-weight: 700;
    letter-spacing: 0.12em;
    padding-inline: 3.25rem;
    position: relative;
    text-transform: uppercase;
  }

  /* Where the browser can cut a background to the letters, the gold is a
     gradient and the shadows are filters, since a text-shadow would paint
     over letters that have no colour of their own. Elsewhere the plain gold
     above stands. */
  @supports (background-clip: text) {
    :root:has(.pick, .room) h1 {
      background:
        linear-gradient(105deg, transparent 42%, var(--gleam) 50%, transparent 58%) 150% 0 / 200% 100% no-repeat,
        linear-gradient(var(--gold-high), var(--color-accent) 50%, var(--gold-low));
      background-clip: text;
      color: transparent;
      filter:
        drop-shadow(0 2px 0 color-mix(in oklch, var(--color-select-ground), black 60%))
        drop-shadow(0 0 1.25rem var(--color-select-light));
    }
  }

  /* The rules stand outside the title's box, so they never squeeze it; they
     wait for a screen wide enough to hold them beside it. */
  @media (min-width: 72rem) {
    :root:has(.pick, .room) h1::before,
    :root:has(.pick, .room) h1::after {
      background:
        radial-gradient(circle closest-side, var(--color-accent) 60%, transparent) 100% 50% / 0.5rem 0.5rem no-repeat,
        linear-gradient(to right, transparent, var(--color-accent)) 0 calc(50% - 2px) / calc(100% - 0.75rem) 1px no-repeat,
        linear-gradient(to right, transparent, var(--color-border)) 0 calc(50% + 2px) / calc(100% - 0.75rem) 1px no-repeat;
      block-size: 0.75rem;
      content: "";
      inline-size: 6rem;
      inset-block-start: calc(50% - 0.375rem);
      position: absolute;
    }

    :root:has(.pick, .room) h1::before {
      inset-inline-end: calc(100% + var(--space));
    }

    :root:has(.pick, .room) h1::after {
      inset-inline-start: calc(100% + var(--space));
      scale: -1 1;
    }
  }

  /* The padding keeps the note's width clear either side of the title, so
     it stays centred where the music note shares its line. From 40rem the
     note is out of its way and the title is one line, so a wide title is
     never a column of words. */
  @media (min-width: 40rem) {
    :root:has(.pick, .room) h1 {
      padding-inline: 0;
      white-space: nowrap;
    }
  }

  @media (prefers-reduced-motion: no-preference) {
    :root:has(.pick, .room) h1 {
      animation: title-in 900ms ease-out, gleam 7s ease-in-out 1.2s infinite;
    }

    /* The lore follows the title in; the chosen hero is in the room
       already and casts as the page settles, and the other three, the
       legend and the road step out of the dark after it. The keeps do
       not: Chrome paints nothing of a filtered pseudo-element whose
       entrance is animated, so the far end of the vale is just there. */
    .lore {
      animation: rise 700ms ease-out 400ms backwards;
    }

    .hero:has(:checked) label {
      animation: rise 400ms ease-out 150ms backwards;
    }

    .heroes legend,
    .hero:not(:has(:checked)) label,
    .heroes::before {
      animation: rise 700ms ease-out 700ms backwards;
    }
  }

  @keyframes title-in {
    from {
      letter-spacing: 0.3em;
      opacity: 0;
    }
  }

  /* The gleam crosses left to right in the first second and a half of
     seven and rests the rest. */
  @keyframes gleam {
    0% {
      background-position: 150% 0, 0 0;
    }

    22%,
    100% {
      background-position: -50% 0, 0 0;
    }
  }

  /* The selection is a room lit from above and dark at its edges: a warm
     glow over the heroes and a vignette closing in on the corners, both
     fixed, so a long page scrolls under the same light. */
  :root:has(.pick, .room) body {
    background:
      radial-gradient(ellipse 70% 55% at 50% 30%, var(--color-select-light), transparent),
      radial-gradient(ellipse at 50% 50%, transparent 55%, color-mix(in oklch, var(--color-select-ground), black 70%)),
      var(--color-select-ground);
    background-attachment: fixed;
  }

  .pick {
    isolation: isolate;
    position: relative;
  }

  /* Embers drifting up through the light: gold specks on a fixed layer
     over the whole room, two screens tall and tiled, so it can rise by
     half its height and loop. It moves by translate, which the compositor
     does without repainting a pixel; a background-position on the form's
     own box repainted the page sixty times a second. Behind everything,
     never in the way of a click. */
  :root:has(.pick, .room) body::before {
    background-image:
      radial-gradient(2px 2px at 12% 80%, var(--color-select-gold), transparent),
      radial-gradient(1.5px 1.5px at 28% 40%, var(--color-select-gold), transparent),
      radial-gradient(2.5px 2.5px at 47% 70%, var(--color-select-gold), transparent),
      radial-gradient(1.5px 1.5px at 63% 25%, var(--color-select-gold), transparent),
      radial-gradient(2px 2px at 81% 60%, var(--color-select-gold), transparent),
      radial-gradient(1.5px 1.5px at 93% 15%, var(--color-select-gold), transparent);
    background-size: 100% 25%;
    block-size: 200vh;
    content: "";
    inset: 0 0 auto;
    opacity: 0.6;
    pointer-events: none;
    position: fixed;
    z-index: -1;
  }

  @media (prefers-reduced-motion: no-preference) {
    :root:has(.pick, .room) body::before {
      animation: embers 36s linear infinite;
    }
  }

  /* Half its height in 36 s, with a lean, as heat drifts. */
  @keyframes embers {
    to {
      translate: 1.5rem -50%;
    }
  }

  /* Two by two on a narrow screen, so Play is never four heroes down a
     phone; a row of four from 40rem, where four sheets fit side by side,
     and the modes beside the button rather than over it. */
  .heroes {
    border: 0;
    display: grid;
    gap: var(--space-sm);
    grid-template-columns: repeat(2, 1fr);
    padding: 0;
    position: relative;
  }

  /* The ground the heroes stand on: the lane's own road, the all-road row
     of the ground sheet at the size the figures are drawn, a band under
     their feet fading out at its ends and edges. It is placed from the
     fieldset's content box, which begins under the legend, so its place is
     the figure's top padding, its name row, its gap and its sprite row,
     each fixed in rem for that reason: the road ends where the feet are.
     From 40rem, where the four stand in one row; under it two rows would
     need two roads. Behind the figures and their light, and never in the
     way of a click. */
  @media (min-width: 40rem) {
    .heroes::before {
      background: url("/static/ground/road.png") 0 -60rem / 32rem 64rem repeat-x;
      block-size: 4rem;
      content: "";
      image-rendering: pixelated;
      inset-block-start: 8rem;
      inset-inline: 0;
      mask-composite: intersect;
      mask-image:
        linear-gradient(to right, transparent, black 12%, black 88%, transparent),
        linear-gradient(transparent, black 35%, black 80%, transparent);
      opacity: 0.85;
      pointer-events: none;
      position: absolute;
      z-index: -1;
    }
  }

  /* The keep at either end of the road: the blue tower at the left end and
     the red at the right, at the figures' size, standing outside the row
     the way the title's rules stand outside the title, from the width that
     has room for them. In the low light of the far end of the vale. */
  @media (min-width: 72rem) {
    /* With the keeps there, the road runs out to their doors. */
    .heroes::before {
      inset-inline: -2rem;
      mask-image:
        linear-gradient(to right, transparent, black 6%, black 94%, transparent),
        linear-gradient(transparent, black 35%, black 80%, transparent);
    }

    .heroes::after {
      background:
        url("/static/img/tower-blue.png") 0 100% / 4.625rem 11.25rem no-repeat,
        url("/static/img/tower-red.png") 100% 100% / 4.5rem 10.875rem no-repeat;
      block-size: 11.25rem;
      content: "";
      filter: brightness(0.55) saturate(0.7) drop-shadow(0 0.25rem 0.5rem color-mix(in oklch, var(--color-select-ground), black 60%));
      image-rendering: pixelated;
      inset-block-start: 0.75rem;
      inset-inline: -6.5rem;
      pointer-events: none;
      position: absolute;
      z-index: -1;
    }
  }

  @media (min-width: 40rem) {
    .heroes {
      grid-template-columns: repeat(4, 1fr);
    }

    .pick {
      align-items: end;
      grid-template-columns: 1fr auto;
    }

    /* The how-to and the modes share the left column, and Play's column
       is its own from the how-to down, so the button covers neither
       while it is stuck to the window's bottom. The button is placed by
       hand, since auto-placement would seat the modes beside the how-to. */
    .pick > :not(.modes, .howto, button) {
      grid-column: 1 / -1;
    }

    .pick > .howto,
    .pick > .modes {
      grid-column: 1;
    }

    .pick:has(.howto) > button {
      grid-column: 2;
      grid-row: 2 / span 2;
    }
  }

  /* A grid item is at least as wide as its own content unless it is told
     otherwise. Floored at zero a mode's setting wraps inside its column
     instead of setting the track. The figure's floor keeps the columns even,
     so a sheet wider than its share cannot set the track. */
  .hero label,
  .modes .option {
    min-inline-size: 0;
  }

  /* A caption names a group: the heroes and the opponent. Both alike, muted
     capitals spaced wide. */
  .heroes legend,
  .modes legend {
    color: var(--color-text-muted);
    font-size: var(--text-caption);
    letter-spacing: 0.16em;
    text-transform: uppercase;
  }

  .heroes legend {
    line-height: 1.5;
    margin-inline: auto;
    padding-block-end: 0.75rem;
  }

  .modes legend {
    padding-inline: var(--space-xs);
  }

  /* A card is not a box: its label and its kit are the fieldset's
     own grid items, so the four figures share a row and the one kit shown
     can take a row of its own under all of them. */
  .hero {
    display: contents;
  }

  /* A figure is its radio button: the name, the sprite and the line are one
     label, standing in low light. The pointed one brightens, the checked one
     stands in the light. The kit sits outside the label, because a label may
     hold phrasing content only — a list inside one is invalid, and
     everything inside one is read out as the radio's name. */
  .hero label {
    align-content: start;
    cursor: pointer;
    color: var(--color-text-muted);
    display: grid;
    gap: 0.25rem;
    grid-template-rows: 2rem 9rem minmax(3lh, auto);
    justify-items: center;
    padding-block: 0.5rem var(--space-sm);
    position: relative;
    text-align: center;
  }

  .hero label:has(:checked) {
    background: radial-gradient(closest-side, var(--color-select-light), transparent);
  }

  /* The ground each hero stands on: a shadow at its feet, which the chosen
     one lights gold. It sits under the sprite and over the label's light. */
  .hero label::before {
    aspect-ratio: 4 / 1;
    background: radial-gradient(closest-side, color-mix(in oklch, var(--color-select-ground), black 75%) 55%, transparent);
    content: "";
    grid-row: 2;
    grid-column: 1;
    align-self: end;
    inline-size: min(100%, 5rem);
    opacity: 0.7;
    transition: background 200ms;
  }

  .hero label:has(:checked)::before {
    background: radial-gradient(closest-side, var(--color-select-gold), transparent);
    opacity: 0.45;
  }

  /* The gold ground is the room's one firelight, and it breathes, slowly;
     Play breathes on the same count, so the room has one slow pulse under
     the heroes' quick step. */
  @media (prefers-reduced-motion: no-preference) {
    .hero label:has(:checked)::before {
      animation: firelight 4.8s ease-in-out infinite;
    }
  }

  @keyframes firelight {
    50% {
      opacity: 0.3;
    }
  }

  .hero label > * {
    grid-column: 1;
  }

  .hero label > strong {
    grid-row: 1;
  }

  .hero label .hero-face {
    align-self: end;
    grid-row: 2;
  }

  .hero label > span {
    grid-row: 3;
  }

  .hero label > .hero-tag {
    font-size: var(--text-caption);
    font-style: normal;
    grid-row: 4;
  }

  /* The visitor's record with the hero against the computer, under its
     tag, in the same small size: only on a hero already played. */
  .hero label > .hero-record {
    font-size: var(--text-caption);
    grid-row: 5;
  }

  /* The low light is on the sprite and not the label, so a name and its
     line keep their contrast while the figure above them is dimmed. */
  .hero label .hero-face {
    filter: brightness(0.68) saturate(0.45);
    transition: filter 150ms;
  }

  .hero label:hover .hero-face,
  .hero label:has(:focus-visible) .hero-face {
    filter: brightness(0.92) saturate(0.85);
  }

  /* Each sheet leaves its own slack under the feet — Kran 2 source pixels,
     Shal 3, Korr 4, Fara 8 — so three figures are drawn down by the
     difference and the four pairs of feet stand on one line. Pixels,
     because the sprite is pixels. */
  .hero label:has([value="fara"]) .hero-face {
    margin-block-end: -12px;
  }

  .hero label:has([value="shal"]) .hero-face {
    margin-block-end: -2px;
  }

  .hero label:has([value="korr"]) .hero-face {
    margin-block-end: -4px;
  }

  .hero label:has(:checked) .hero-face {
    filter: none;
  }

  .hero label:has(:focus-visible) {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
  }

  /* The radio stays in the page for the keyboard and the screen reader, and
     out of sight: the figure in the light is what says which is checked. */
  .hero input {
    block-size: 1px;
    clip-path: inset(50%);
    inline-size: 1px;
    margin: 0;
    position: absolute;
  }

  .hero strong {
    font-size: var(--text-name);
    letter-spacing: 0.04em;
    line-height: 2rem;
  }

  .hero label:has(:checked) strong {
    color: var(--color-accent);
    font-size: var(--text-chosen);
    text-shadow: 0 0 1rem var(--color-select-light);
  }

  /* A hero's four skills with what each does: on the checked hero alone, in
     a panel under the row. All four kits at once would be twelve lines
     about three heroes nobody is picking, so the kit follows the choice. */
  .kit {
    display: none;
  }

  /* The markers go, so the list carries role="list" in the template: Safari
     stops announcing a list as a list the moment its markers are dropped,
     and four skills nobody is told the count of is the one thing this kit
     exists to say. */
  .hero:has(:checked) .kit {
    background: linear-gradient(var(--color-surface), var(--color-select-ground));
    border: 1px solid var(--color-border);
    box-shadow: inset 0 0 0 3px var(--color-select-ground), inset 0 0 0 4px var(--color-border);
    color: var(--color-text-muted);
    display: grid;
    gap: var(--space-sm);
    grid-column: 1 / -1;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr));
    list-style: none;
    margin: 0;
    order: 1;
    padding: var(--space-sm);
  }

  /* A skill is its icon in a slot, framed the way the HUD frames it, with
     its name and its line beside it. The icon is drawn at twice its size
     and centred in a slot one size fits every icon of. */
  .kit li {
    align-items: start;
    column-gap: var(--space-sm);
    display: grid;
    grid-template-columns: auto 1fr;
    grid-template-rows: auto 1fr;
  }

  .kit-icon {
    background: radial-gradient(closest-side, var(--color-select-light), var(--color-select-ground));
    block-size: 4.5rem;
    border: 1px solid var(--color-border);
    box-shadow: inset 0 0 0 2px var(--color-select-ground);
    grid-row: 1 / 3;
    image-rendering: pixelated;
    inline-size: 4.5rem;
    object-fit: none;
  }

  /* The hero's rank bonuses are the kit's last row, a caption on a rule
     across the panel: what the third and fifth point in each of the first
     three skills adds. A row of the list, so it rises with the list. */
  .kit .kit-ranks {
    border-block-start: 1px solid var(--color-border);
    display: block;
    font-size: var(--text-caption);
    grid-column: 1 / -1;
    padding-block-start: var(--space-xs);
  }

  /* The ultimate says what it is and the level it waits for, under its
     line, in gold: the one skill a first point cannot buy. */
  .kit-ult {
    color: var(--color-accent);
    display: block;
    font-size: var(--text-caption);
    grid-column: 2;
    letter-spacing: 0.08em;
  }

  /* A skill's name is in the text colour, not gold: gold is the title, the
     chosen hero and Play, so the row of heroes outshines the panel that
     explains one of them. */
  .kit strong {
    color: var(--color-text);
    display: block;
    font-size: var(--text-name);
    letter-spacing: 0.04em;
  }

  /* A key is a keycap: a small framed plate in the caption's size, the
     letter in the text colour, so it reads as a thing to press and not as
     part of the skill's name. The how-to's keys are the same cap. */
  .kit kbd,
  .howto kbd {
    background: var(--color-select-ground);
    border: 1px solid var(--color-border);
    border-block-end-width: 2px;
    color: var(--color-text);
    display: inline-block;
    font-family: inherit;
    font-size: var(--text-caption);
    line-height: 1.4;
    min-inline-size: 1.6em;
    padding-inline: 0.3em;
    text-align: center;
    vertical-align: 0.15em;
  }

  /* How a match goes: three lines in a panel framed like the kit's, under
     the heroes and their kit. Open on a first visit and closed after, so it
     is there to read and not in the way. The summary is a caption like a
     group's. */
  .howto {
    background: linear-gradient(var(--color-surface), var(--color-select-ground));
    border: 1px solid var(--color-border);
    box-shadow: inset 0 0 0 3px var(--color-select-ground), inset 0 0 0 4px var(--color-border);
    color: var(--color-text-muted);
    inline-size: min(100%, 60rem);
    padding: var(--space-xs) var(--space-sm);
  }

  .howto summary {
    color: var(--color-text-muted);
    cursor: pointer;
    font-size: var(--text-caption);
    letter-spacing: 0.16em;
    padding-block: var(--space-xs);
    text-transform: uppercase;
  }

  .howto summary:hover,
  .howto[open] summary {
    color: var(--color-accent);
  }

  .howto ol {
    display: grid;
    gap: var(--space-xs);
    margin: 0;
    padding-block: 0 var(--space-xs);
    padding-inline-start: 1.5em;
  }

  .howto li::marker {
    color: var(--color-accent);
  }

  /* The rest of the how-to folds under the three lines a first match
     needs, on its own summary, so the block is three lines, and seven more
     for whoever wants them. */
  .howto .more summary {
    letter-spacing: 0.08em;
    padding-block: var(--space-xs) 0;
    text-transform: none;
  }

  .howto .more ol {
    padding-block-start: var(--space-xs);
  }

  /* A pick changes the kit, so the kit rises into place, its four skills a
     beat apart; display: none to grid restarts the animation on every pick. */
  @media (prefers-reduced-motion: no-preference) {
    .hero:has(:checked) .kit {
      animation: rise 250ms ease-out;
    }

    .hero:has(:checked) .kit li {
      animation: rise 300ms ease-out backwards;
    }

    .kit li:nth-child(2) {
      animation-delay: 60ms;
    }

    .kit li:nth-child(3) {
      animation-delay: 120ms;
    }

    .kit li:nth-child(4) {
      animation-delay: 180ms;
    }

    .kit li:nth-child(5) {
      animation-delay: 240ms;
    }
  }

  @keyframes rise {
    from {
      opacity: 0;
      translate: 0 var(--space-xs);
    }
  }

  .heroes > .field-error {
    grid-column: 1 / -1;
    order: 2;
  }

  /* The hero from its own sheet at twice its size: the sheet is declared at
     half its density, so the browser draws it doubled, and the box clips it
     to one cell. Under 20rem — a 320 px screen at 200% zoom — a sheet can
     be wider than its column, so the box is clamped and the hero stands
     still on its south face, because a clamped box would move the frames. */
  .hero-face {
    display: block;
    image-rendering: pixelated;
    max-inline-size: 100%;
    object-fit: none;
    object-position: 0 0;
  }

  /* From 20rem the heroes idle, and the checked one steps forward, casts
     once and idles again. Every hero sheet has the same rows and eight
     columns, so a frame is a percentage and not a pixel: with the box one
     cell wide, column c of 8 is c/7 across and row r of 41 is r/40 down,
     whatever the cell. Idle south is row 25, four frames at 100 ms; cast
     south is row 9, eight at 50 ms. design_test.go holds these to
     internal/art. */
  @media (min-width: 20rem) {
    .hero-face {
      animation: idle 400ms steps(4) infinite;
      max-inline-size: none;
      object-position: 0 62.5%;
      transform-origin: 50% 100%;
      transition: scale 200ms, translate 200ms;
    }

    /* Pointed at, or reached by the keyboard, a hero draws its weapon:
       its attack south row once, then idles. Before the checked rule, so
       the chosen hero casts rather than swings when pointed at. */
    .hero label:hover .hero-face,
    .hero label:has(:focus-visible) .hero-face {
      animation: idle 400ms steps(4) 400ms infinite, attack 400ms steps(8);
    }

    /* The chosen one is drawn at three times its size, from its feet, so
       it stays on the road and every pixel is three; it steps forward
       over 200 ms and then casts, so the step and the cast are two beats. */
    .hero label:has(:checked) .hero-face {
      animation: idle 400ms steps(4) 600ms infinite, cast 400ms steps(8) 200ms;
      scale: 1.5;
      translate: 0 var(--space-xs);
    }
  }

  /* The others turn to face the chosen one: the heroes before it idle on
     the south-east row, the heroes after it on the south-west, and their
     steps fall out of time with each other, so four figures are four
     people and not one poster. The turned idles are wrapped in :where so
     a hover's weapon and the chosen one's cast still outrank them; the
     delay's second value is for the hover's attack, which starts at once. */
  @media (min-width: 20rem) and (prefers-reduced-motion: no-preference) {
    .hero:where(:has(~ .hero :checked)) .hero-face {
      animation: idle-se 400ms steps(4) infinite;
    }

    :where(.hero:has(:checked)) ~ .hero .hero-face {
      animation: idle-sw 400ms steps(4) infinite;
    }

    .hero:nth-child(5):not(:has(:checked)) .hero-face {
      animation-delay: -130ms, 0s;
    }

    .hero:nth-child(6):not(:has(:checked)) .hero-face {
      animation-delay: -270ms, 0s;
    }

    .hero:nth-child(7):not(:has(:checked)) .hero-face {
      animation-delay: -60ms, 0s;
    }
  }

  /* Still, and the light alone says which hero is chosen. */
  @media (prefers-reduced-motion: reduce) {
    .hero-face,
    .hero label:hover .hero-face,
    .hero label:has(:focus-visible) .hero-face,
    .hero label:has(:checked) .hero-face {
      animation: none;
      scale: none;
      transition: none;
      translate: none;
    }
  }

  @keyframes idle {
    from {
      object-position: 0 62.5%;
    }

    to {
      object-position: 57.1429% 62.5%;
    }
  }

  @keyframes cast {
    from {
      object-position: 0 22.5%;
    }

    to {
      object-position: 114.2857% 22.5%;
    }
  }

  /* Attack south is row 1; idle south-east row 26 and south-west row 32,
     four frames each like idle south. design_test.go holds all of them. */
  @keyframes attack {
    from {
      object-position: 0 2.5%;
    }

    to {
      object-position: 114.2857% 2.5%;
    }
  }

  @keyframes idle-se {
    from {
      object-position: 0 65%;
    }

    to {
      object-position: 57.1429% 65%;
    }
  }

  @keyframes idle-sw {
    from {
      object-position: 0 80%;
    }

    to {
      object-position: 57.1429% 80%;
    }
  }

  /* The opponent, on a panel along the bottom framed like the kit, and
     Play at its right where a selection screen keeps the button that goes
     on. */
  .modes {
    background: linear-gradient(var(--color-surface), var(--color-select-ground));
    box-shadow: inset 0 0 0 3px var(--color-select-ground), inset 0 0 0 4px var(--color-border);
    display: grid;
    gap: var(--space-xs);
    padding: var(--space-sm);
  }

  /* A mode is a slab to step onto: the whole row is the label, in low
     light until it is pointed at, and the chosen one stands in the light
     with its name in gold. */
  .modes > label {
    align-items: center;
    background: color-mix(in oklch, var(--color-select-ground), transparent 40%);
    border: 1px solid transparent;
    color: var(--color-text-muted);
    cursor: pointer;
    display: flex;
    font-size: var(--text-name);
    gap: var(--space-sm);
    letter-spacing: 0.04em;
    padding: var(--space-xs) var(--space-sm);
    transition: background 150ms, border-color 150ms, color 150ms;
  }

  .modes > label:hover {
    border-color: var(--color-border);
    color: var(--color-text);
  }

  .modes > label:has(:checked) {
    background: radial-gradient(farthest-side at 0 50%, var(--color-select-light), transparent), var(--color-select-ground);
    border-color: var(--color-border);
    color: var(--color-accent);
    text-shadow: 0 0 1rem var(--color-select-light);
  }

  /* The radio is a gem: a diamond set in the border's colour, lit gold and
     glowing when its mode is chosen. It stays a real radio, so the keyboard
     and the screen reader keep it. */
  .modes > label input {
    appearance: none;
    background: var(--color-select-ground);
    block-size: 0.75rem;
    border: 1px solid var(--color-border);
    flex: none;
    inline-size: 0.75rem;
    margin: 0 0.125rem;
    rotate: 45deg;
    transition: background 150ms, box-shadow 150ms;
  }

  .modes > label input:checked {
    background: var(--color-accent);
    border-color: var(--color-accent);
    box-shadow: 0 0 0.5rem var(--color-accent);
  }

  /* The keyboard's ring is on the slab, as on a hero's label, and not on
     a gem the size of a letter. */
  .modes > label input:focus-visible {
    outline: none;
  }

  .modes > label:has(:focus-visible) {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
  }

  /* What making a room does, under its slab, only while it is chosen. */
  .modes .note {
    color: var(--color-text-muted);
    font-size: var(--text-caption);
    margin: 0;
  }

  /* Play is a gilded plate set in the panels' frame: gold lit from above and
     darker below, a bevel of light along its top edge and shade along its
     bottom, a diamond either side of the word, and a slow glow breathing
     around it, the one thing on the screen that asks to be pressed. Pointed
     at, a gleam sweeps across it; pressed, it sinks. */
  .pick > button {
    --gold-high: color-mix(in oklch, var(--color-accent), white 30%);
    --gold-low: color-mix(in oklch, var(--color-accent), black 25%);
    --gleam: color-mix(in oklch, white, transparent 45%);
    /* The ground it stands on: a hard plate of the page's colour, so what
       scrolls under it stops at an edge instead of dissolving in a fog
       that reads as a rendering fault. Last, under every other shadow. */
    --halo: 0 0 0 0.5rem var(--color-bg);
    align-items: center;
    background:
      linear-gradient(110deg, transparent 35%, var(--gleam) 50%, transparent 65%) 150% 0 / 250% 100% no-repeat,
      linear-gradient(var(--gold-high), var(--color-accent) 45%, var(--gold-low));
    border: 1px solid var(--gold-low);
    box-shadow:
      inset 0 1px 0 var(--gold-high),
      inset 0 -2px 0 var(--gold-low),
      0 0 0 3px var(--color-select-ground),
      0 0 0 4px var(--color-border),
      0 0 1.5rem var(--color-select-light),
      var(--halo);
    display: inline-flex;
    font-size: var(--text-name);
    font-weight: 700;
    gap: var(--space-sm);
    justify-self: end;
    letter-spacing: 0.2em;
    padding: var(--space-sm) var(--space);
    text-shadow: 0 1px 0 var(--gold-high);
    text-transform: uppercase;
    transition: background-position 600ms ease-out, box-shadow 150ms, filter 150ms, translate 80ms;
  }

  /* Play stays in reach: stuck to the bottom of the window while the form
     runs on under it, so a short screen never scrolls past four heroes to
     find it. The page scrolls a focused control clear of it, so it covers
     nothing a keyboard is on. */
  .pick > button {
    inset-block-end: var(--space);
    position: sticky;
    z-index: 1;
  }

  :root:has(.pick > button) {
    scroll-padding-block-end: calc(4 * var(--space));
  }

  /* The diamonds are the modes' gems, dark on the gold, and silent. */
  .pick > button::before,
  .pick > button::after {
    content: "◆" / "";
    font-size: 0.625rem;
    letter-spacing: 0;
    text-shadow: none;
  }

  .pick > button:hover,
  .pick > button:focus-visible {
    background-position: -50% 0, 0 0;
    box-shadow:
      inset 0 1px 0 var(--gold-high),
      inset 0 -2px 0 var(--gold-low),
      0 0 0 3px var(--color-select-ground),
      0 0 0 4px var(--color-accent),
      0 0 2.5rem var(--color-accent),
      var(--halo);
    filter: brightness(1.08);
  }

  .pick > button:active {
    box-shadow:
      inset 0 2px 0.25rem var(--gold-low),
      0 0 0 3px var(--color-select-ground),
      0 0 0 4px var(--color-accent),
      0 0 1.5rem var(--color-accent),
      var(--halo);
    filter: brightness(0.95);
    translate: 0 1px;
  }

  @media (prefers-reduced-motion: no-preference) {
    .pick > button:not(:hover, :focus-visible, :active) {
      animation: breathe 4.8s ease-in-out infinite;
    }
  }

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

  @keyframes breathe {
    50% {
      filter: brightness(1.06) drop-shadow(0 0 0.75rem var(--color-select-light));
    }
  }

  /* A mode's own block — the ladder, the room note, the room code — hangs
     under its slab on a gold rule, and only under the chosen one: a setting
     for a mode nobody picked is a question nobody asked. The block follows
     its label in the markup, so the adjacent sibling is enough. */
  .modes .option {
    align-items: center;
    border-inline-start: 1px solid var(--color-accent);
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-xs) var(--space-sm);
    margin-inline-start: calc(var(--space-sm) + 0.4rem);
    padding: var(--space-xs) var(--space-sm);
  }

  .modes > label:not(:has(:checked)) + .option {
    display: none;
  }

  /* The computer's ladder, under its slab: one cell a rung from the bottom
     to the top, the rungs under 0 in shade, the one the next match is
     played on lit gold with its number on it. The line under it says what
     moves it. */
  .modes .ladder {
    display: grid;
    gap: var(--space-xs);
  }

  .ladder p {
    color: var(--color-text-muted);
    font-size: var(--text-caption);
    margin: 0;
  }

  .rungs {
    display: flex;
    gap: 2px;
    inline-size: min(100%, 24rem);
    list-style: none;
    margin: 0;
    padding: 0;
  }

  .rungs li {
    background: color-mix(in oklch, var(--color-text-muted), transparent 45%);
    block-size: 1rem;
    flex: 1 1 0;
    font-size: var(--text-caption);
    line-height: 1rem;
    min-inline-size: 0;
    text-align: center;
  }

  .rungs li span {
    display: none;
  }

  /* The three rungs under 0 are the learner's, in deeper shade. */
  .rungs li:nth-child(-n + 3) {
    background: color-mix(in oklch, var(--color-text-muted), transparent 65%);
  }

  .rungs li[aria-current] {
    background: var(--color-accent);
    box-shadow: 0 0 0.5rem var(--color-accent);
    color: var(--color-select-ground);
  }

  .rungs li[aria-current] span {
    display: inline;
  }

  .modes .option input:not([type="radio"]) {
    background: var(--color-select-ground);
    transition: border-color 150ms;
  }

  .modes .option input:not([type="radio"]):hover,
  .modes .option input:not([type="radio"]):focus-visible {
    border-color: var(--color-accent);
  }

  @media (prefers-reduced-motion: no-preference) {
    .modes .option {
      animation: rise 250ms ease-out;
    }
  }

  /* The music toggle: a gold note in a framed roundel in the corner of the
     selection, fixed so it stays in reach on a long phone page. Shown by
     its script, so with scripts off nothing is there that does nothing.
     Pressed is on; off, the note dims and a rule is struck through
     it. */
  .music {
    align-items: center;
    background: radial-gradient(closest-side, var(--color-select-light), var(--color-select-ground));
    block-size: 2.75rem;
    border: 1px solid var(--color-border);
    border-radius: 50%;
    box-shadow: inset 0 0 0 2px var(--color-select-ground), 0 0 1rem var(--color-select-light);
    color: var(--color-accent);
    display: grid;
    font-size: var(--text-name);
    inline-size: 2.75rem;
    inset-block-start: var(--space-sm);
    inset-inline-end: var(--space-sm);
    justify-items: center;
    padding: 0;
    position: fixed;
    transition: border-color 150ms, color 150ms, filter 150ms;
    z-index: 1;
  }

  .music[hidden] {
    display: none;
  }

  .music::before {
    content: "♪" / "";
    grid-area: 1 / 1;
    text-shadow: 0 0 0.5rem var(--color-select-light);
  }

  .music::after {
    background: currentColor;
    block-size: 2px;
    content: "";
    grid-area: 1 / 1;
    inline-size: 60%;
    opacity: 0;
    rotate: -45deg;
    transition: opacity 150ms;
  }

  .music:hover,
  .music:focus-visible {
    border-color: var(--color-accent);
  }

  .music[aria-pressed="false"] {
    color: var(--color-text-muted);
    filter: saturate(0.4);
  }

  .music[aria-pressed="false"]::after {
    opacity: 1;
  }

  .field-error {
    color: var(--color-error);
  }

  /* What the module is doing before there is a canvas to say it on: a
     line in the page, removed by the loader the moment Go takes over.
     Without it a slow link is a blank frame with nothing in it. */
  .loading {
    color: var(--color-text-muted);
  }

  /* Shown to a coarse pointer and to nobody else. The game wants WASD, a
     mouse and a dozen more keys, and a phone has none of them: better to
     say so on the page where a hero is picked than to leave somebody
     tapping a canvas that will not answer. */
  .needs-keyboard {
    display: none;
  }

  /* The world in a sentence, under the title: in the title's face, quiet. */
  .lore {
    font-style: italic;
    margin-inline: auto;
    max-inline-size: 40rem;
    text-align: center;
  }

  /* The build the server runs, under the selection: a caption, so a player
     reporting a bug can name it and nobody else has to read it. */
  .version {
    color: var(--color-text-muted);
    font-size: var(--text-caption);
    opacity: 0.65;
    text-align: center;
  }

  .version::before {
    content: "build ";
  }

  @media (pointer: coarse) {
    .needs-keyboard {
      color: var(--color-error);
      display: block;
      text-align: center;
    }
  }

  .room {
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    display: grid;
    gap: var(--space-sm);
    inline-size: min(100%, 40rem);
    padding: var(--space-sm);
  }

  /* The room's link, whole, in a line a click selects for copying: a
     plain element, because a browser does not apply user-select to the
     text inside an input. */
  .room .room-link {
    display: block;
    inline-size: 100%;
    overflow-wrap: anywhere;
    user-select: all;
  }

  .room small {
    color: var(--color-text-muted);
    display: block;
    font-size: var(--text-caption);
  }

  /* The game renders at 640x360; CSS scales it to fill the window at 16:9,
     as wide as the window allows or as tall, whichever runs out first, so a
     resize rescales it and nothing is cropped. Pixelated scaling keeps the
     art crisp; the aspect ratio keeps it undistorted. The play page gives up
     its padding and its scrollbars for it: the canvas is the page. */
  canvas {
    aspect-ratio: 16 / 9;
    background: var(--color-canvas);
    display: block;
    image-rendering: pixelated;
    inline-size: min(100vw, 100dvb * 16 / 9);
  }

  /* The play canvas's pointer, by the name the game gives it in
     data-cursor: a crosshair over the lane, a red one while a click would
     strike something, and a marker for a moment when an attack lands. Each
     is an SVG in the stylesheet, so it costs the module nothing, scales
     crisply on a dense screen and is never an image a cache keeps stale.
     The hotspot is the middle, and the crosshair keyword stands in where a
     browser refuses the image. */
  canvas[data-cursor="aim"] {
    cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' shape-rendering='crispEdges'%3E%3Cg fill='%2312141a'%3E%3Crect x='10' y='1' width='4' height='9'/%3E%3Crect x='10' y='14' width='4' height='9'/%3E%3Crect x='1' y='10' width='9' height='4'/%3E%3Crect x='14' y='10' width='9' height='4'/%3E%3Crect x='10' y='10' width='4' height='4'/%3E%3C/g%3E%3Cg fill='%23f4f1e8'%3E%3Crect x='11' y='2' width='2' height='7'/%3E%3Crect x='11' y='15' width='2' height='7'/%3E%3Crect x='2' y='11' width='7' height='2'/%3E%3Crect x='15' y='11' width='7' height='2'/%3E%3Crect x='11' y='11' width='2' height='2'/%3E%3C/g%3E%3C/svg%3E") 12 12, crosshair;
  }

  canvas[data-cursor="target"] {
    cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' shape-rendering='crispEdges'%3E%3Cg fill='%2312141a'%3E%3Crect x='10' y='1' width='4' height='9'/%3E%3Crect x='10' y='14' width='4' height='9'/%3E%3Crect x='1' y='10' width='9' height='4'/%3E%3Crect x='14' y='10' width='9' height='4'/%3E%3Crect x='10' y='10' width='4' height='4'/%3E%3C/g%3E%3Cg fill='%23ff3b30'%3E%3Crect x='11' y='2' width='2' height='7'/%3E%3Crect x='11' y='15' width='2' height='7'/%3E%3Crect x='2' y='11' width='7' height='2'/%3E%3Crect x='15' y='11' width='7' height='2'/%3E%3Crect x='11' y='11' width='2' height='2'/%3E%3C/g%3E%3C/svg%3E") 12 12, crosshair;
  }

  canvas[data-cursor="hit"] {
    cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' shape-rendering='crispEdges'%3E%3Cg fill='%2312141a'%3E%3Crect x='2' y='2' width='4' height='4'/%3E%3Crect x='3' y='3' width='4' height='4'/%3E%3Crect x='4' y='4' width='4' height='4'/%3E%3Crect x='5' y='5' width='4' height='4'/%3E%3Crect x='16' y='5' width='4' height='4'/%3E%3Crect x='17' y='4' width='4' height='4'/%3E%3Crect x='18' y='3' width='4' height='4'/%3E%3Crect x='19' y='2' width='4' height='4'/%3E%3Crect x='2' y='19' width='4' height='4'/%3E%3Crect x='3' y='18' width='4' height='4'/%3E%3Crect x='4' y='17' width='4' height='4'/%3E%3Crect x='5' y='16' width='4' height='4'/%3E%3Crect x='16' y='16' width='4' height='4'/%3E%3Crect x='17' y='17' width='4' height='4'/%3E%3Crect x='18' y='18' width='4' height='4'/%3E%3Crect x='19' y='19' width='4' height='4'/%3E%3C/g%3E%3Cg fill='%23f4f1e8'%3E%3Crect x='3' y='3' width='2' height='2'/%3E%3Crect x='4' y='4' width='2' height='2'/%3E%3Crect x='5' y='5' width='2' height='2'/%3E%3Crect x='6' y='6' width='2' height='2'/%3E%3Crect x='17' y='6' width='2' height='2'/%3E%3Crect x='18' y='5' width='2' height='2'/%3E%3Crect x='19' y='4' width='2' height='2'/%3E%3Crect x='20' y='3' width='2' height='2'/%3E%3Crect x='3' y='20' width='2' height='2'/%3E%3Crect x='4' y='19' width='2' height='2'/%3E%3Crect x='5' y='18' width='2' height='2'/%3E%3Crect x='6' y='17' width='2' height='2'/%3E%3Crect x='17' y='17' width='2' height='2'/%3E%3Crect x='18' y='18' width='2' height='2'/%3E%3Crect x='19' y='19' width='2' height='2'/%3E%3Crect x='20' y='20' width='2' height='2'/%3E%3C/g%3E%3C/svg%3E") 12 12, crosshair;
  }

  :root:has(canvas) body {
    background: var(--color-canvas);
    overflow: hidden;
  }

  :root:has(canvas) main {
    padding: 0;
  }

  /* A fullscreen canvas owns every gesture over it: no touch pans, and no
     overscroll that a trackpad swipe could turn into a history step. The
     wheel is the engine's, which swallows it while fullscreen. */
  canvas:fullscreen {
    inline-size: 100%;
    object-fit: contain;
    touch-action: none;
  }

  :root:has(canvas:fullscreen) {
    overscroll-behavior: none;
  }
}

@layer components {
  /* Attract mode: a creep from either keep walks the far edge of the road
     behind the heroes, the wave the lore promises, at half the heroes'
     size and in the low light, which is what far away looks like from a
     low camera. With the road only, and only for a reader who has not
     asked for stillness, so the image is not even fetched there. The
     soldier sheet has 25 rows: walk east is row 19 and walk west row 23,
     eight frames each, which design_test.go holds. */
  .march {
    display: none;
  }

  @media (min-width: 40rem) and (prefers-reduced-motion: no-preference) {
    .march {
      animation: walk-east 800ms steps(8) infinite, march 32s linear infinite;
      display: block;
      filter: brightness(0.4) saturate(0.6);
      image-rendering: pixelated;
      inset-block-start: calc(8.5rem - 49px);
      inset-inline-start: -4rem;
      object-fit: none;
      object-position: 0 79.1667%;
      pointer-events: none;
      position: absolute;
      z-index: -1;
    }

    .march-west {
      animation: walk-west 800ms steps(8) infinite, march 32s linear -16s infinite reverse;
      object-position: 0 95.8333%;
    }
  }

  @keyframes walk-east {
    from {
      object-position: 0 79.1667%;
    }

    to {
      object-position: 114.2857% 79.1667%;
    }
  }

  @keyframes walk-west {
    from {
      object-position: 0 95.8333%;
    }

    to {
      object-position: 114.2857% 95.8333%;
    }
  }

  /* The walk is the start offset animated: one small positioned element,
     laid out alone, which costs less than a layer of its own would. */
  @keyframes march {
    to {
      inset-inline-start: 100%;
    }
  }

  /* Windows high contrast strips backgrounds and shadows, so the checked
     gem, the lit rung, the chosen name and the gradient-cut title are said
     in the system's own colours. */
  @media (forced-colors: active) {
    .modes > label input:checked {
      background: SelectedItem;
      border-color: SelectedItem;
      forced-color-adjust: none;
    }

    .rungs li {
      border: 1px solid CanvasText;
    }

    .rungs li[aria-current] {
      background: SelectedItem;
      color: SelectedItemText;
      forced-color-adjust: none;
    }

    .hero label:has(:checked) strong {
      text-decoration: underline 2px;
      text-underline-offset: 0.2em;
    }

    :root:has(.pick, .room) h1 {
      background: none;
      color: CanvasText;
      filter: none;
    }
  }

  /* More contrast: the frames in the muted text's colour and the resting
     figures out of the low light. */
  @media (prefers-contrast: more) {
    :root:has(.pick, .room) {
      --color-border: var(--color-select-muted);
    }

    .hero label .hero-face,
    .hero label:hover .hero-face {
      filter: brightness(0.8) saturate(0.8);
    }
  }
}

@layer utilities {
  .visually-hidden {
    block-size: 1px;
    clip-path: inset(50%);
    inline-size: 1px;
    overflow: hidden;
    position: absolute;
    white-space: nowrap;
  }
}
