/*
 * design.css — FROZEN platform design system. The agent cannot write this file;
 * frozenSync re-copies it into every app on every build, so a fix here reaches
 * the whole v2 fleet on each app's next rebuild.
 *
 * THREE GENRES, ONE SYSTEM
 * The token layer below has three treatments of the same structure:
 *   (default)             app / dashboard — warm paper, cobalt accent
 *   <body data-genre="landing">  marketing page — same palette, editorial scale
 *   <body data-genre="game">     arcade — near-black, violet/mint/amber HUD
 * A genre is chosen by setting data-genre on <body> in views/layout.ts. Every
 * component below reads tokens, so switching genre restyles the whole app
 * without touching a single component rule.
 *
 * THE TOKEN CONTRACT
 * Every brand-able value is consumed as var(--token) and every token's DEFAULT
 * is defined here. public/theme.css is loaded AFTER this file and is agent-
 * writable, so an app brands itself by re-declaring only the tokens it needs.
 * Defaults live here, not there, on purpose: theme.css is writable, and a
 * broken or emptied theme must degrade to this look rather than leave the
 * system with undefined values and an unstyled app.
 *
 * CSP: generated apps run under script-src 'self' and default-src 'self'.
 * No @import, no external url(), no CDN, no data: URI fonts (data: is allowed
 * in img-src only). A webfont must be a self-hosted file under public/ that is
 * ALSO registered in the frozen src/lib/assets.ts allowlist, or it 404s. The
 * font stacks below name Public Sans and IBM Plex Mono first and fall back to
 * system faces, so they upgrade automatically once those files are hosted.
 */

/* ================================================================== *
 * FONTS — self-hosted, latin subset. Both are SIL OFL 1.1 (see
 * public/fonts/LICENSE.txt). They must be served from our own origin:
 * default-src 'self' blocks every font CDN, and a data: URI would be blocked
 * too (data: is allowed in img-src only). Each file also needs an entry in
 * the frozen src/lib/assets.ts allowlist or it 404s and the stack silently
 * falls back to system faces.
 * ================================================================== */

@font-face {
  font-family: "Public Sans";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/static/fonts/public-sans-400.woff2") format("woff2");
}

@font-face {
  font-family: "Public Sans";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("/static/fonts/public-sans-500.woff2") format("woff2");
}

@font-face {
  font-family: "Public Sans";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/static/fonts/public-sans-600.woff2") format("woff2");
}

@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/static/fonts/ibm-plex-mono-400.woff2") format("woff2");
}

@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("/static/fonts/ibm-plex-mono-500.woff2") format("woff2");
}

/* ================================================================== *
 * TOKENS
 * ================================================================== */

:root {
  /* surfaces */
  --canvas: #f7f6f3;
  --surface: #fbfaf8;
  --card: #ffffff;
  --border: #e7e3db;
  --border-strong: #d8d3c8;

  /* ink */
  --ink: #16151a;
  --muted: #6f6b62;
  --icon: #8a8579;

  /* accent + status */
  --accent: #2b44e0;
  --accent-soft: #eef0fe;
  --accent-ink: #ffffff;
  --success: #17845a;
  --success-soft: #e7f4ee;
  --warning: #b45309;
  --warning-soft: #fdf0e3;
  --danger: #b42318;
  --danger-soft: #fdeceb;

  /* type */
  --font-sans: "Public Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  /* shape — 6 small controls, 8 inputs/nav, 12 cards */
  --radius-sm: 6px;
  --radius: 8px;
  --radius-lg: 12px;

  /* Elevation stays HAIRLINE by rule — the flat card shadow is the spec'd one
     and must never get heavier. Depth comes from a second, wider and softer
     ambient layer at very low alpha, not from a darker drop: that is the
     difference between "raised" and "cheap". --shadow-raised is for a surface
     that is deliberately lifted (hover, popover), never the resting card. */
  --shadow: 0 1px 2px rgba(22, 21, 26, 0.04);
  --shadow-raised:
    0 1px 2px rgba(22, 21, 26, 0.05),
    0 8px 24px -8px rgba(22, 21, 26, 0.08);

  /* Interaction. A focus ring that is the accent at low alpha reads as part of
     the system rather than an OS artefact; the tint is the hover surface, so a
     hover never has to invent a colour. */
  --ring: 0 0 0 3px var(--accent-soft);
  --tint: color-mix(in srgb, var(--ink) 3%, transparent);

  /* Motion. One duration and one easing for the whole system — inconsistent
     timing is the most common reason an otherwise clean UI feels cheap. The
     curve settles rather than bounces. */
  --ease: cubic-bezier(0.2, 0.7, 0.3, 1);
  --dur: 150ms;

  /* Spacing rhythm — a 4px scale, so padding across components is related
     rather than hand-picked per rule. */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;

  /* Optical tracking. Type tightens as it grows: at display sizes default
     spacing reads loose and amateur, at small sizes it needs to stay open. */
  --tracking-display: -0.038em;
  --tracking-head: -0.03em;
  --tracking-tight: -0.02em;
  --tracking-caps: 0.06em;

  /* layout */
  --sidebar-w: 244px;
  --row-h: 46px;
  /* The mobile nav bar. ONE token, read by BOTH states: the no-JS strip and the
     bar the drawer leaves behind. That is what makes them the same height by
     construction rather than by coincidence — if they differ by even 2px, the
     upgrade from one to the other is a visible jump. */
  --navbar-h: 56px;
  --content-max: 1180px;
}

/* Landing: same palette, editorial scale, alternating section bands. */
body[data-genre="landing"] {
  --canvas: #f7f6f3;
  --surface: #fbfaf8;
  --content-max: 1120px;
}

/* Game: inverted shell, mono HUD. */
body[data-genre="game"] {
  --canvas: #0d0c11;
  --surface: #131118;
  --card: #1b1922;
  --border: #2f2c39;
  --border-strong: #3b3747;
  --ink: #f4f2f7;
  --muted: #7b7688;
  --icon: #7b7688;
  --accent: #7c5cff;
  --accent-soft: #1f1b33;
  --accent-ink: #ffffff;
  --success: #7cf7c1;
  --success-soft: #14261f;
  --warning: #ffd166;
  --warning-soft: #2a2318;
  --shadow: none;
}

/* ================================================================== *
 * BASE
 * ================================================================== */

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  /* An id, a filename, an email, a hash and a bare URL are all one word with no
     space in it, and a generated app fills up with them. Without this a single
     such string cannot break and pushes its whole container past the viewport —
     ten of the thirteen shapes the layout check found were this and nothing else.
     Inherited, so it reaches every element the generator emits.

     `anywhere` and not `break-word`: break-word breaks the LINE but leaves the
     element's min-content contribution as the longest word, so a flex or grid
     parent is still forced open by it. Measured, that difference is four of the
     ten — .cell, .hero, .hud and a .list row all still overflowed on break-word
     and are clean on anywhere.

     No word-break and no hyphens with it. word-break: break-all breaks every
     word whether it needs it or not, which is a wrapping style rather than an
     overflow fix; hyphens is cosmetic, needs a lang attribute, and changes no
     width. */
  overflow-wrap: anywhere;
  font-family: var(--font-sans);
  font-size: 14px;
  line-height: 1.5;
  background: var(--canvas);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
}

/* Replaced media renders at its INTRINSIC size, which has nothing to do with the
   screen: an 1200px-wide image took the page 871px past a 360px viewport. There
   was no img rule in this file at all — the only max-width:100% in it belonged
   to .tablewrap.
   height:auto only where a ratio has to be preserved from a width attribute.
   NOT on svg: inline icons carry explicit width and height, and letting one of
   them compute its own height collapses it. */
img,
video,
canvas,
svg,
iframe,
embed,
object {
  max-width: 100%;
}

img,
video {
  height: auto;
}

/* <pre> had no rule either, and it is the one text element overflow-wrap cannot
   reach: white-space:pre forbids the line break that overflow-wrap would take.
   So the block scrolls instead — the same treatment .tablewrap gets, and the
   right one for code, where wrapping a line misrepresents it. */
pre {
  max-width: 100%;
  overflow-x: auto;
}

/* A bare <a> is usually STRUCTURAL — nav, a card that links, a brand mark —
   and those must not read as body-copy links. Left at the browser default they
   came out underlined and, once tinted, painted every nav item accent-blue,
   which breaks the one-accent-per-page rule (the accent belongs to the primary
   CTA alone). So the base link inherits its surroundings and only reveals
   itself on hover; a link inside PROSE opts back into looking like a link.
   Anything carrying .btn keeps its own colour — a class beats an element. */
a {
  color: inherit;
  text-decoration: none;
}

/* :not(.btn) on both of these, and it is load-bearing (2026-08-13).
   An element selector with a pseudo-class or an ancestor outranks a bare class:
   `a:hover` and `.empty a` are both (0,1,1), `.btn` is (0,1,0). So an anchor
   rendered as a button LOST to them — every `<a class="btn">` inside an empty
   state came out underlined and accent-coloured instead of a solid button, and
   every link-button anywhere recoloured its label on hover. Source order does
   not save it: `.btn` is four hundred lines further down and still loses.
   Reported from a real generated app, whose agent could only work around it in
   its own markup — design.css is frozen, so the app side cannot reach this.
   These three selectors mean "a link in PROSE". A button is not prose, so the
   right fix is for them not to MATCH it, rather than for `.btn` to fight back
   with a heavier selector. `.btn` re-states its own colour at :hover as well —
   see the note there for why both halves are kept. */
a:not(.btn):hover {
  color: var(--accent);
}

p a:not(.btn),
.empty a:not(.btn) {
  color: var(--accent);
  text-decoration: underline;
  text-underline-offset: 2px;
  text-decoration-thickness: 1px;
}

/* Every number, ID, badge and metric is mono — this is what stops a data app
   from reading like a document. */
.num,
td.num,
th.num {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

/* ================================================================== *
 * APP SHELL — sidebar + topbar + content
 * ================================================================== */

.shell {
  display: grid;
  grid-template-columns: var(--sidebar-w) 1fr;
  min-height: 100vh;
}

/* A grid item's default min-width is AUTO, not 0 — it refuses to shrink below
   its content's min-content width. So one wide table drags the whole 1fr track
   past the viewport and the PAGE scrolls sideways: measured 192px of horizontal
   overflow at 375px, with the shell's single column computing to 566.672px in a
   375px window.
   The second cost is quieter and worse. .tablewrap already carries
   overflow-x:auto so a wide table scrolls INSIDE its card — but it never
   received a narrow box to scroll in, so it never scrolled once. This one
   declaration both stops the page overflowing and lets that existing rule do
   the job it was written for. */
.shell > * {
  min-width: 0;
}

.sidebar {
  background: var(--surface);
  border-right: 1px solid var(--border);
  padding: 14px 12px;
  display: flex;
  flex-direction: column;
  gap: 18px;
}

.sidebar .brand {
  display: flex;
  align-items: center;
  gap: 9px;
  font-weight: 600;
  padding: 4px 8px 0;
}

.sidebar .brand .mark {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  border-radius: var(--radius-sm);
  background: var(--accent);
  color: var(--accent-ink);
  font-weight: 600;
  font-size: 13px;
}

.navgroup {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.navgroup > .label {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--icon);
  padding: 6px 8px;
}

.navgroup a {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 8px;
  border-radius: var(--radius);
  color: var(--ink);
  text-decoration: none;
  font-weight: 500;
  box-shadow: inset 0 0 0 1px transparent;
  transition: background var(--dur) var(--ease), color var(--dur) var(--ease),
    box-shadow var(--dur) var(--ease);
}

.navgroup a:hover {
  background: var(--tint);
}

.navgroup a[aria-current="page"] {
  background: var(--accent-soft);
  color: var(--accent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent);
}

.navgroup a .count {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--muted);
}

.sidebar .foot {
  margin-top: auto;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.topbar {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 22px;
  background: var(--card);
  border-bottom: 1px solid var(--border);
}

.topbar .spacer {
  flex: 1;
}

.crumbs {
  color: var(--muted);
}

.crumbs b {
  color: var(--ink);
  font-weight: 500;
}

/* The cap without the centering left the column pinned to the sidebar and every
   pixel past it dead: on a 1920 screen the content ended at 1424 and 465px of
   canvas sat unused to the right. The landing genre already centers its own
   container (see the .band rule); this is the app genre doing the same thing. */
.content {
  padding: 22px;
  max-width: var(--content-max);
  margin-inline: auto;
}

.page-head {
  margin-bottom: 18px;
}

/* ================================================================== *
 * TYPE
 * ================================================================== */

/* WORDS, not characters. body sets `overflow-wrap: anywhere`, which is right
   for the machine strings a generated app fills up with — an id, a hash, a bare
   URL — and wrong for everything made of words: it breaks ANY word at ANY point
   the moment its box is narrow. In a 210px card column that shipped as "Websit
   e Relaun ch" and "Q3 Marketi ng Campai gn" (customer screenshot, 2026-08-13).
   `break-word` is the difference: it breaks a word only when that word cannot
   fit on a line BY ITSELF, so "Website Relaunch" wraps at its space and a
   60-character hash still breaks rather than overflowing. The body rule was
   measured and `anywhere` chosen over `break-word` for a REASON that does not
   apply here — it was about a flex or grid PARENT being forced open by its
   min-content, and a heading inside an already-sized column is not that case. */
h1,
h2,
h3,
summary,
th,
label,
.field > span,
.field > label {
  overflow-wrap: break-word;
}

/* A heading needs its own ratio. Left undeclared these inherited the body's
   1.5, which is a BODY-COPY ratio: it put 11px of leading inside a 22px title
   and made every two-line heading read as two separate lines. The ratio falls
   as the size rises, which is why h1 is tighter than h2. */
h1 {
  font-size: 22px;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: var(--tracking-tight);
  margin: 0 0 4px;
}

h2 {
  font-size: 16px;
  font-weight: 600;
  line-height: 1.25;
  margin: 0 0 4px;
}

/* Levels 3 to 6 had NO rule at all, so they fell through to the browser's own
   em-relative sizes against a 14px body — and the scale came out inverted:
   h3 rendered at 16.38px/700, both LARGER and HEAVIER than the h2 above it, and
   every level below carried a 16-22px margin nothing in this system asked for.
   An app that used <h3> for a subsection got a heading that outranked its own
   section title, which is the kind of wrongness that reads as "the pages look
   inconsistent" without anyone being able to point at it.
   One weight for the whole scale, sizes that only ever go down, and the same
   `0 0 4px` h1 and h2 already use. */
h3 {
  font-size: 14px;
  font-weight: 600;
  line-height: 1.35;
  margin: 0 0 4px;
}

h4,
h5,
h6 {
  font-size: 13px;
  font-weight: 600;
  line-height: 1.35;
  margin: 0 0 4px;
}

/* Prose. Also unstyled until now: a <p> carried the browser's 1em top AND
   bottom margin, so two paragraphs sat 14px apart in a card whose own rhythm is
   16px, and a list was indented 40px — outside the grid everything else lines
   up to. The trailing margin is dropped on the last child so a card does not
   end in a gap. */
/* A paragraph gets its rhythm on BOTH sides, not just below (2026-08-13).
   `margin: 0 0 12px` works for as long as a paragraph follows a paragraph —
   the two margins collapse and 12px is what you get. It fails the moment one
   follows something that has no bottom margin of its own: an input, a control
   row, a table. That is exactly the helper line the reference example teaches
   (`<p class="muted">No notes yet.</p>`), which therefore sat flush against the
   bottom edge of the field above it in a real app.
   Adjacent margins still collapse, so paragraph-after-paragraph is unchanged at
   12px. The one case that would regress is a paragraph FIRST inside a padded
   box, where padding blocks the collapse and the 12px would be added on top —
   so `:first-child` takes it back. Every deliberate exception below
   (`.stack > p`, `.empty p`, `.hero p`) already states its own margin and is
   unaffected. */
p {
  margin: 12px 0;
}

p:first-child {
  margin-top: 0;
}

ul,
ol {
  margin: 0 0 12px;
  padding-left: 20px;
}

blockquote {
  margin: 0 0 12px;
  padding-left: 12px;
  border-left: 2px solid var(--border);
  color: var(--muted);
}

figure {
  margin: 0 0 12px;
}

p:last-child,
ul:last-child,
ol:last-child,
blockquote:last-child,
figure:last-child {
  margin-bottom: 0;
}

/* The browser's <hr> is a 2px inset ridge — a 3D bevel from 1996 sitting in a
   flat system. It is the same hairline every card and table row uses. */
hr {
  height: 0;
  border: 0;
  border-top: 1px solid var(--border);
  margin: 16px 0;
}

.muted {
  color: var(--muted);
}

.small {
  font-size: 12px;
}

/* ================================================================== *
 * CARD
 * ================================================================== */

.card {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
  /* How much padding THIS card has, for the .list pull-back below to cancel.
     Raised to 16px only on a card that actually gets padding. Declared here on
     every .card because custom properties INHERIT: without the reset a card
     nested inside a padded one would inherit 16px and pull its list out on a
     padding it does not have. */
  --card-pad: 0px;
}

.card-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 13px 16px;
  border-bottom: 1px solid var(--border);
}

.card-head h2 {
  margin: 0;
}

.card-head .spacer {
  flex: 1;
}

.card-body {
  padding: 16px;
}

.stack {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

/* A .stack OWNS the rhythm between its children, so a child that also carries a
   block margin adds to it — flex does not collapse margins, so the two stack up.
   Measured on a real form: a .field (14px bottom margin) followed by a hint <p>
   (12px top margin, UA default) inside a 16px stack rendered 42px apart, three
   times the 14px the same form used between its own fields.
   Scoped to DIRECT children on purpose: .field keeps its margin everywhere else,
   which is what spaces fields inside a plain .card-body. */
.stack > .field {
  margin-bottom: 0;
}

.stack > p {
  margin-block: 0;
}

.grid {
  display: grid;
  gap: 12px;
  grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
}

/* SELF-HEALING LAYOUT.
   A .card is a SURFACE: its padding lives on .card-head / .card-body, and the
   space between two cards comes from .stack. Both are opt-in, so markup that
   skips them renders content flush to the border with no gap between cards —
   valid CSS, no error anywhere, wrong layout. These two rules repair that case
   without touching markup that composed correctly.

   The padding rule is written as :not(:has(...)) deliberately. Stated the other
   way round — pad every card, then zero the ones that carry a body — a browser
   without :has() drops the RESET and doubles the inset on correct markup. This
   way an unsupported :has() invalidates the whole selector, the rule is dropped
   and the card falls back to exactly the behaviour it has today. The fallback
   is the bug, not a worse bug.

   A card holding a table is excluded too: a table runs edge to edge on purpose
   so its header fill and row rules can span the full card width. A .list is
   NOT excluded — see the pull-back rule below for why. */

/* ORDER DEPENDENCY — THIS RULE MUST STAY ABOVE .steps .card.
   Both compute to (0,2,0): .card is one class, and :not() takes the
   specificity of its most specific argument, which is the :has(), which in
   turn takes its own most specific argument — a class either way. .steps .card
   is two classes. Equal specificity means SOURCE ORDER decides it, so the
   landing genre keeps its 18px only by being declared later in this file.
   Move either rule past the other and .steps silently drops to 16px, with
   nothing failing. designSystem.test.ts resolves this exact pair and asserts
   the winner is 18px, so a reorder breaks the suite instead of the layout. */
.card:not(:has(> .card-head, > .card-body, > .empty, > table, > .tablewrap)) {
  padding: 16px;
  --card-pad: 16px;
}

/* A .list sits INSIDE that padding and is pulled back out to the card's edges,
   rather than being excluded from it.

   Excluding it was the obvious move and it was wrong: whether a card holds a
   .list depends on the DATA, not on the markup. The reference shape renders a
   <p> when the collection is empty and a .list once it has rows, so the card
   was padded while empty and unpadded the moment the first row landed — the
   heading jumped to the border on the first insert. Padding that changes with
   the contents of the database is harder to diagnose than padding that is
   simply always wrong.

   Pulled per EDGE, never as a blanket margin:-16px. An all-sides negative
   margin drags the list UP over whatever heading sits above it, since h1/h2
   only carry a 4px bottom margin. Horizontal always; top only when the list
   opens the card; bottom only when it closes it. A card that is nothing but a
   list therefore nets back to zero and renders exactly as it did before.

   The row keeps its own 10px 16px inset (see .list > li), so the row TEXT now
   lands on the same left edge as the heading while the divider still spans the
   full card width — the two were 16px apart before. .card-body > .list is a
   different shape and is deliberately untouched here.

   The pull is bound to --card-pad, not written as a literal, because it must
   cancel a padding that is CONDITIONAL. Written as a flat -16px it also fired
   on cards excluded from the padding rule — an "At a glance" card with a
   .card-head has no padding to cancel, so its list was dragged 16px clean
   outside the card on both sides and the row text landed 16px LEFT of the
   card-head's own heading. Nothing errored; it read as a card whose rows were
   mysteriously wider than its header. */
.card > .list {
  margin-inline: calc(-1 * var(--card-pad));
}

.card > .list:first-child {
  margin-top: calc(-1 * var(--card-pad));
}

.card > .list:last-child {
  margin-bottom: calc(-1 * var(--card-pad));
}

/* A SUBMIT that follows a full-width control, with nothing between them.
   `.field` gives a field its bottom margin and `.actions` gives a button row
   its gap, but both are opt-in — so the plainest form anyone writes,
   `<input><button>Add</button>`, put the button flush against the input's
   bottom edge with no gap at all (customer screenshot, 2026-08-13). Neither
   element carries a margin of its own and nothing was there to supply one.

   Same shape and the same exclusion list as the sibling-card rule below: a
   margin on a flex or grid child ADDS to its container's gap, so this must not
   fire where the parent already spaces its children. The `.field` case is
   covered by that list too — a button inside one is the rare shape, and a
   button after one is a sibling of the .field, not of the input. */
:not(.stack, .grid, .hero, .steps, .row, .actions) > input + button,
:not(.stack, .grid, .hero, .steps, .row, .actions) > input + .btn,
:not(.stack, .grid, .hero, .steps, .row, .actions) > select + button,
:not(.stack, .grid, .hero, .steps, .row, .actions) > select + .btn,
:not(.stack, .grid, .hero, .steps, .row, .actions) > textarea + button,
:not(.stack, .grid, .hero, .steps, .row, .actions) > textarea + .btn {
  margin-top: 10px;
}

/* Excluded wherever the parent ALREADY provides the gap: a margin on a flex or
   grid child ADDS to that container's gap, so an unscoped rule would open
   .stack to 32px instead of 16px. */
:not(.stack, .grid, .hero, .steps, .row, .actions) > .card + .card {
  margin-top: 16px;
}

/* …and the same for every OTHER pair of page-level blocks (2026-08-13).
   `.card + .card` alone covered the reference example — two bare cards under
   `<main class="content">`, which has padding and no gap — and nothing else. A
   dashboard is `.grid + .grid + .grid + .card`: a row of KPI cards, a row of
   two, another row of two, then a full-width card. Not one of those pairs is
   card-to-card, so a whole page of blocks stacked flush against each other
   while the cards INSIDE each grid were correctly spaced by its gap. That is
   what the customer screenshot showed, and it reads as a design with no
   rhythm rather than as a rule that did not match.

   Kept as a SECOND rule rather than folded into the first: `:is()` is the only
   sane way to write sixteen pairs, and a selector list is invalidated whole by
   an unknown part. An engine without `:is()` drops this rule and still gets
   `.card + .card` above — the fallback is today's bug, never a worse one, the
   same reasoning the `:has()` rule below is written for. */
:not(.stack, .grid, .hero, .steps, .row, .actions)
  > :is(.card, .grid, .alert, .tablewrap)
  + :is(.card, .grid, .alert, .tablewrap) {
  margin-top: 16px;
}

/* Stat tile — label, mono figure, delta chip. */
.stat {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
  padding: 14px 16px;
}

.stat .label {
  color: var(--muted);
  font-size: 12px;
  margin-bottom: 6px;
}

.stat .figure {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: 24px;
  font-weight: 600;
  letter-spacing: var(--tracking-tight);
  display: flex;
  align-items: baseline;
  gap: 8px;
}

/* ================================================================== *
 * BUTTONS — primary is solid ink, never a coloured gradient
 * ================================================================== */

button,
.btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 7px 13px;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: var(--ink);
  color: var(--card);
  font-family: inherit;
  font-size: 13px;
  font-weight: 500;
  line-height: 1.3;
  cursor: pointer;
  text-decoration: none;
  white-space: nowrap;
}

button,
.btn {
  transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease),
    box-shadow var(--dur) var(--ease), transform var(--dur) var(--ease);
}

/* Fading a button to 90% opacity fades it into the PAGE — the label goes grey
   with it. Mixing the surface toward ink keeps the text at full strength. */
button:hover,
/* text-decoration is RE-STATED here, not only excluded above: the exclusion
   says "a button is not a prose link", this says "a button knows what it looks
   like", so the next broad `a:hover` rule somebody adds cannot put a line
   through a label again. No variant sets text-decoration, so nothing can
   conflict with it.
   `color` is deliberately NOT re-stated. `.btn:hover` is (0,2,0) and so are
   `.btn.ghost` / `.accent` / `.danger` / `.success`, each of which sets its own
   colour and relies on it surviving the hover — a colour here would tie with
   them and be decided by source order alone, which is a trap waiting for the
   day somebody moves a block. The colour problem is already solved one rule
   up, by the hover no longer matching a button at all. */
.btn:hover {
  background: color-mix(in srgb, var(--ink) 88%, #fff);
  text-decoration: none;
}

button:active,
.btn:active {
  transform: translateY(0.5px);
}

button:focus-visible,
.btn:focus-visible,
a:focus-visible {
  outline: none;
  box-shadow: var(--ring);
}

button.ghost,
.btn.ghost {
  background: var(--card);
  color: var(--ink);
  border-color: var(--border);
}

button.ghost:hover,
.btn.ghost:hover {
  background: var(--tint);
  border-color: var(--border-strong);
}

button.accent,
.btn.accent {
  background: var(--accent);
  color: var(--accent-ink);
}

button.accent:hover,
.btn.accent:hover {
  background: color-mix(in srgb, var(--accent) 88%, #000);
}

/* A COLOUR class names the role; .solid fills it.
   .danger alone is the QUIET destructive — a Delete in a table row, where a
   filled red on every line shouts. .danger.solid is the loud one: the confirm
   button in a dialog, where the action is the point of the screen. The same
   pair exists for success, so "did it" and "will destroy it" read alike.
   .danger had no hover at all until now, while .ghost and .accent both did. */
button.danger,
.btn.danger {
  background: var(--card);
  color: var(--danger);
  border-color: var(--border);
}

button.danger:hover,
.btn.danger:hover {
  background: var(--danger-soft);
  border-color: color-mix(in srgb, var(--danger) 30%, transparent);
}

button.success,
.btn.success {
  background: var(--card);
  color: var(--success);
  border-color: var(--border);
}

button.success:hover,
.btn.success:hover {
  background: var(--success-soft);
  border-color: color-mix(in srgb, var(--success) 30%, transparent);
}

button.danger.solid,
.btn.danger.solid {
  background: var(--danger);
  color: #fff;
  border-color: transparent;
}

button.danger.solid:hover,
.btn.danger.solid:hover {
  background: color-mix(in srgb, var(--danger) 88%, #000);
}

button.success.solid,
.btn.success.solid {
  background: var(--success);
  color: #fff;
  border-color: transparent;
}

button.success.solid:hover,
.btn.success.solid:hover {
  background: color-mix(in srgb, var(--success) 88%, #000);
}

button.sm,
.btn.sm {
  padding: 4px 9px;
  font-size: 12px;
}

/* A FILLED button cannot be disabled with opacity: the label fades with the
   fill, so white-on-blue becomes pale-on-pale and the one state a button spends
   its time in — "you cannot press this yet" — is the least readable thing on
   screen. A filled control STATES its disabled colours; only an outlined one
   can be dimmed, and even then not below 0.6. */
button:disabled,
.btn:disabled {
  cursor: default;
  opacity: 0.6;
}

button:disabled,
.btn:disabled,
button.accent:disabled,
.btn.accent:disabled,
button.danger.solid:disabled,
.btn.danger.solid:disabled,
button.success.solid:disabled,
.btn.success.solid:disabled {
  opacity: 1;
  background: var(--border);
  color: var(--muted);
  border-color: transparent;
}

button.ghost:disabled,
.btn.ghost:disabled,
button.danger:disabled,
.btn.danger:disabled,
button.success:disabled,
.btn.success:disabled {
  opacity: 0.6;
  background: var(--card);
  border-color: var(--border);
}

/* An <input type="submit"> is a BUTTON, and it was matching the text-field rule
   instead: measured 35px tall with an 8px radius and a white fill, next to a
   32.9px, 6px-radius, ink-filled <button>. A form built with one came out
   looking like it had a stray empty field where its submit should be. */
/* And a checkbox or radio is a CONTROL, not a field — the same trap one family
   over, found in a real app on 2026-08-13. The text-field rule below sets
   `width:100%` plus padding, a border, a radius and a fill, so a checkbox
   stretched the width of its grid column and floated in the middle of it with
   a box drawn around the tick. It never lined up with the fields beside it
   because it was not being drawn as a checkbox at all.
   `input[type=…]` is (0,1,1) against the bare `input`'s (0,0,1), so this wins
   wherever it sits in the file — the same reason the submit reset above works.
   `accent-color` is what tints the NATIVE control: no custom box, no pseudo-
   element, and it follows the app's own --accent through every genre.
   Worth knowing why this one mattered more than it looks: design.css is frozen,
   so the agent that hit it could not fix the rule. It replaced the checkbox
   with a DROPDOWN — a boolean turned into a select to dodge a stylesheet bug.
   A broken platform rule does not just look wrong, it changes what gets built. */
input[type="checkbox"],
input[type="radio"] {
  width: auto;
  height: auto;
  flex: none;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  accent-color: var(--accent);
  cursor: pointer;
}

/* TOGGLE SWITCH — a checkbox that looks like a switch. There is no native
   element for one, and building it out of a <span> would need a markup
   contract and a label wired to a hidden input; a checkbox already submits
   with the form, already takes a label, and already has a :checked state.
   Beats the reset above at (0,2,1) against its (0,1,1).

   The knob is a background-image, NOT a ::after. Pseudo-elements on a replaced
   element are not guaranteed, and while `appearance:none` makes them work in
   today's engines it is a grey area to build a control on. A radial-gradient
   has none of that doubt — and it is the same trick the <select> chevron below
   uses, for the same reason: these apps run under a CSP where a data: URI is
   allowed in img-src only, so an inline SVG is not available here either.

   Geometry, so it can be changed safely: track 34×20, knob radius 8 centred at
   x=10, which puts it 2px inside the left edge. Checked shifts the background
   14px, landing the knob 2px inside the right edge. */
input[type="checkbox"].switch {
  appearance: none;
  -webkit-appearance: none;
  width: 34px;
  height: 20px;
  border-radius: 999px;
  background-color: var(--border-strong);
  background-image: radial-gradient(circle 8px at 10px 10px, var(--card) 100%, transparent 100%);
  background-repeat: no-repeat;
  background-position: 0 0;
  transition: background-color var(--dur) var(--ease), background-position var(--dur) var(--ease);
}

input[type="checkbox"].switch:checked {
  background-color: var(--accent);
  background-position: 14px 0;
}

@media (prefers-reduced-motion: reduce) {
  input[type="checkbox"].switch {
    transition: none;
  }
}

/* COLOUR — a swatch, not a field. It was matching the text-field rule, so a
   colour picker rendered as a full-width block of colour across its whole grid
   column (customer screenshot, 2026-08-13). It keeps the shared 36px height so
   it still lines up with the inputs beside it, and loses only the width.
   The two swatch pseudo-elements are the browser's own inner chrome: without
   them the colour sits inside a white inset with a border of its own. They are
   SEPARATE rules on purpose — one unknown selector invalidates a whole list,
   so grouping the -webkit- one with the standard one would drop both in any
   engine that knows only the other. */
input[type="color"] {
  width: 44px;
  padding: 3px;
  cursor: pointer;
}

input[type="color"]::-webkit-color-swatch-wrapper {
  padding: 0;
}

input[type="color"]::-webkit-color-swatch {
  border: 0;
  border-radius: calc(var(--radius) - 3px);
}

input[type="color"]::-moz-color-swatch {
  border: 0;
  border-radius: calc(var(--radius) - 3px);
}

/* FILE — the "upload area" a form asks for, without a dropzone widget and
   without script. A dashed border is the one convention that reads as "put
   something here" rather than "type something here", and `height:auto` lets
   the native button inside set the height instead of being clipped by the
   shared 36px.
   ::file-selector-button is the standard name; ::-webkit-file-upload-button is
   the older one Safari still answers to. Separate rules, same reason as the
   colour swatch above. */
input[type="file"] {
  height: auto;
  padding: 10px 12px;
  border-style: dashed;
  border-color: var(--border-strong);
  background: var(--surface);
  cursor: pointer;
}

input[type="file"]::file-selector-button {
  margin-right: 10px;
  padding: 4px 9px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--card);
  color: var(--ink);
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
}

input[type="file"]::-webkit-file-upload-button {
  margin-right: 10px;
  padding: 4px 9px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--card);
  color: var(--ink);
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
}

input[type="submit"],
input[type="button"],
input[type="reset"] {
  width: auto;
  padding: 7px 13px;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: var(--ink);
  color: var(--card);
  font-family: inherit;
  font-size: 13px;
  font-weight: 500;
  line-height: 1.3;
  cursor: pointer;
}

.actions {
  display: flex;
  align-items: center;
  gap: 8px;
}

/* Segmented control (30 days / Quarter / Year). */
.segmented {
  display: inline-flex;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 2px;
  gap: 2px;
}

.segmented a,
.segmented button {
  background: transparent;
  color: var(--muted);
  border: 0;
  padding: 5px 11px;
  border-radius: var(--radius-sm);
  font-size: 12px;
  font-weight: 500;
  text-decoration: none;
}

.segmented [aria-current="true"] {
  background: var(--card);
  color: var(--ink);
  box-shadow: var(--shadow);
}

/* ================================================================== *
 * FORMS
 * ================================================================== */

/* Works both ways round: <label class="field"><span>Name</span><input></label>
   (no id/for needed) and <div class="field"><label>Name</label><input></div>. */
.field {
  display: block;
  margin-bottom: 14px;
}

.field > span,
.field > label {
  display: block;
  font-size: 12px;
  font-weight: 500;
  color: var(--muted);
  margin-bottom: 5px;
}

/* The HEIGHT is stated, and that is the whole point of it (2026-08-13).
   Without it a control's height is padding + border + a content box the browser
   sizes PER INPUT TYPE — and `type="date"` and `type="time"` lay out an
   internal editor plus a native picker button, so they came out taller than the
   text fields beside them and a form row stopped lining up. Reported from a
   real app, where the Date and Time fields sat visibly below their neighbours.
   36px is what the text fields already measured (8+8 padding + ~17 of 13px text
   + 2 border), so this changes nothing about how they look — it drags the
   picker types and the select back to the same line.
   The builder's own stylesheet solved this years-equivalent ago with
   `.ui-input { height:38px }`; this rule is that lesson, one system over. */
input,
textarea,
select {
  width: 100%;
  height: 36px;
  padding: 8px 11px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
  color: var(--ink);
  font-family: inherit;
  font-size: 13px;
}

/* …and every control that is NOT a single-line field opts back out. A textarea
   is sized by its rows, and the button-shaped inputs carry their own padding.
   `height:auto` rather than omitting them from the rule above: the generic
   selector is what gives them their border, radius and font, and splitting that
   into two lists is how one of them silently stops matching later.
   (checkbox and radio already opt out in their own reset further up, which is
   (0,1,1) and wins wherever it sits.) */
textarea,
input[type="submit"],
input[type="button"],
input[type="reset"] {
  height: auto;
}

/* The native select arrow is drawn by the browser against the BORDER, and it
   ignores the padding every other control's icon respects — so next to a date
   field, whose calendar sits 11px in, the chevron reads as stuck to the edge.
   Drawn here instead, at the same 11px, so every icon in a filter row lines up.

   Two linear-gradients rather than an svg: the CSP these apps run under allows
   data: in img-src only, and designSystem.test.ts rejects any url(data:…) in
   this file outright. A file under /static/ would work but needs an assets.ts
   entry and a frozen path for one chevron. Gradients need neither.

   background-color is stated separately: naming `background` here would drop
   the image the shorthand does not carry. */
select {
  appearance: none;
  -webkit-appearance: none;
  padding-right: 30px;
  background-color: var(--card);
  background-image:
    linear-gradient(45deg, transparent 50%, var(--icon) 50%),
    linear-gradient(135deg, var(--icon) 50%, transparent 50%);
  background-position: right 15px top 52%, right 11px top 52%;
  background-size: 4px 4px, 4px 4px;
  background-repeat: no-repeat;
}

input::placeholder,
textarea::placeholder {
  color: var(--icon);
}

input,
textarea,
select {
  transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}

input:hover,
textarea:hover,
select:hover {
  border-color: var(--border-strong);
}

input:focus,
textarea:focus,
select:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: var(--ring);
}

.search {
  position: relative;
  min-width: 260px;
}

.search input {
  border-radius: var(--radius);
  background: var(--surface);
}

/* ================================================================== *
 * TABLE — 46px rows, mono IDs and amounts
 * ================================================================== */

.tablewrap {
  max-width: 100%;
  overflow-x: auto;
  overflow-y: hidden;
}

table {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
}

th {
  text-align: left;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
  color: var(--muted);
  padding: 9px 16px;
  border-bottom: 1px solid var(--border);
  background: var(--surface);
  white-space: nowrap;
}

td {
  height: var(--row-h);
  padding: 0 16px;
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
}

tbody tr:last-child td {
  border-bottom: 0;
}

tbody tr {
  transition: background var(--dur) var(--ease);
}

tbody tr:hover td {
  background: var(--tint);
}

th.right,
td.right {
  text-align: right;
}

/* Avatar chip used in identity cells. */
.avatar {
  display: inline-grid;
  place-items: center;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--accent-soft);
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 600;
}

.cell {
  display: flex;
  align-items: center;
  gap: 9px;
}

/* Plain list — the non-tabular row stack (run logs, simple item lists). */
.list {
  list-style: none;
  padding: 0;
  margin: 0;
}

/* The inset lives on the ROW, not on the list box, for two reasons. A list is
   usually dropped straight into a .card, and `.list { padding: 0 }` (which kills
   the browser's 40px ul indent) is declared after .card-body — equal specificity,
   later wins — so `class="list card-body"` silently lost ALL its padding and the
   rows ran to the card edges while the header stayed inset (2026-08-08). Putting
   it here makes both `list` and `list card-body` render identically, with no
   doubled padding. It also lets the divider span the full card width while the
   content stays aligned, which is what the reference tables do. */
/* Height is the LIST's own, not --row-h. That token is the TABLE row (see the
   td rule), and a table row carries a full cell of content; a list row is a
   label and a trailing badge, so 46px left 21px of text sitting in 25px of
   air. 40px, with the padding brought down to match.
   Note where this stops paying: .badge is 24.5px tall, so on a row that ends
   in one the badge sets the floor at 42.5px and min-height no longer binds.
   Going tighter than this only separates badge rows from text rows further. */
.list > li {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 40px;
  padding: 9px 16px;
  border-top: 1px solid var(--border);
}

.list > li:first-child {
  border-top: 0;
}

/* ================================================================== *
 * BADGE — mono, dot + label
 * ================================================================== */

.badge {
  display: inline-flex;
  white-space: nowrap;
  align-items: center;
  gap: 6px;
  padding: 3px 9px;
  border-radius: 999px;
  background: var(--surface);
  border: 1px solid var(--border);
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 500;
}

.badge::before {
  content: "";
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: currentColor;
}

.badge.plain::before {
  display: none;
}

.badge.success {
  background: var(--success-soft);
  border-color: transparent;
  color: var(--success);
}

.badge.warning {
  background: var(--warning-soft);
  border-color: transparent;
  color: var(--warning);
}

.badge.danger {
  background: var(--danger-soft);
  border-color: transparent;
  color: var(--danger);
}

.badge.accent {
  background: var(--accent-soft);
  border-color: transparent;
  color: var(--accent);
}

/* ================================================================== *
 * EMPTY STATE — graphic + one-line reason + primary action.
 * Never a bare zero and never a blank table.
 * ================================================================== */

.empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 10px;
  padding: 46px 24px;
}

.empty .graphic {
  display: grid;
  place-items: center;
  width: 46px;
  height: 46px;
  border-radius: var(--radius-lg);
  background: var(--accent-soft);
  color: var(--accent);
}

.empty h2 {
  margin: 0;
}

.empty p {
  margin: 0;
  color: var(--muted);
  max-width: 42ch;
}

/* ================================================================== *
 * LANDING
 * ================================================================== */

body[data-genre="landing"] .band {
  background: var(--canvas);
  padding: 72px 22px;
}

body[data-genre="landing"] .band:nth-child(even) {
  background: var(--surface);
}

/* A HEADER is a bar, not a section. .band is the section primitive and its
   72px is section breathing room; used for a header it puts 144px of padding
   around a 33px bar, which is 81% air. The generator reaches for .band because
   there is no header primitive to reach for — the same substitution that made
   it reach for .actions, a button gap, to space a nav.

   The selector has to name .band. An element-only hook is (0,1,2) against
   .band's (0,2,1) and LOSES on class count whatever the source order — checked
   in a browser, not deduced. So this corrects the one property that is wrong
   and adds no layout: the bar's own row already carries that, and the nav rule
   below already spaces the links. 12px matches .topbar, the app genre's bar. */
body[data-genre="landing"] header.band {
  padding: 12px 22px;
}

body[data-genre="landing"] .band > .inner {
  max-width: var(--content-max);
  margin: 0 auto;
}

/* HEADER NAV. There is no primitive for it, so the generator reaches for
   .actions — whose 8px gap is a BUTTON gap. Four text links 8px apart read as
   one run of words rather than as four targets.

   Hooked on the <nav> ELEMENT rather than on a class, for two reasons: the
   class is whatever the generator happened to pick, and an element hook still
   outranks .actions on specificity — (0,1,2) against (0,1,0) — so it corrects
   that choice instead of colliding with it.

   MARKUP CONTRACT, AND IT IS UNVERIFIED BEYOND ONE APP: a landing header nav
   is a <nav> in the landing genre holding its links as direct children. That
   shape was read off a SINGLE local landing app, which is the only one on this
   machine; the deployed fleet was not inspected. The scoping is what makes
   that acceptable — a landing app that structures its nav differently simply
   does not match, and nothing it renders today changes. */
body[data-genre="landing"] nav {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 28px;
}

.hero {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 54px;
  align-items: center;
}

/* The size here is FLUID and the ratio was FIXED, which cannot be right at both
   ends: 1.04 is a display ratio for 64px, and at the clamp's 40px floor it left
   1.6px of leading. Inside line-height, em resolves against the element's OWN
   font-size — so one declaration tracks the clamp by itself, giving a looser
   ratio to the small end and a tighter one to the large end. It also cannot
   drift: a parallel vw formula would have to be retuned by hand every time the
   font clamp above changes, and nothing would report it if that were missed. */
.hero h1 {
  font-size: clamp(40px, 5vw, 64px);
  font-weight: 600;
  line-height: calc(1em + 6px);
  letter-spacing: var(--tracking-display);
  margin: 0 0 18px;
}

/* The one coloured phrase in a headline. */
.hero h1 em,
.display em {
  font-style: normal;
  color: var(--accent);
}

.hero p {
  font-size: 16px;
  color: var(--muted);
  max-width: 46ch;
  margin: 0 0 24px;
}

.display {
  font-size: clamp(26px, 3vw, 34px);
  font-weight: 600;
  letter-spacing: var(--tracking-head);
  line-height: 1.15;
  margin: 0 0 10px;
}

.eyebrow {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--accent);
  margin-bottom: 10px;
}

.pill {
  display: inline-flex;
  white-space: nowrap;
  align-items: center;
  gap: 8px;
  padding: 5px 12px;
  border-radius: 999px;
  background: var(--card);
  border: 1px solid var(--border);
  font-size: 12px;
  margin-bottom: 22px;
}

/* Exactly one dark card per landing page, for contrast. */
.card.invert {
  background: var(--ink);
  border-color: var(--ink);
  color: var(--card);
}

.card.invert .muted,
.card.invert p {
  color: color-mix(in srgb, var(--card) 68%, transparent);
}

.steps {
  display: grid;
  gap: 14px;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}

.steps .card {
  padding: 18px;
}

.steps .n {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--muted);
  margin-bottom: 10px;
}

/* ================================================================== *
 * GAME
 * ================================================================== */

body[data-genre="game"] .hud {
  display: flex;
  align-items: stretch;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  font-family: var(--font-mono);
}

body[data-genre="game"] .hud .slot {
  padding: 9px 18px;
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: 3px;
}

body[data-genre="game"] .hud .slot .label {
  font-size: 10px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
}

body[data-genre="game"] .hud .slot .value {
  font-size: 19px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

body[data-genre="game"] .hud .spacer {
  flex: 1;
  border-right: 0;
}

/* Radial glow + masked grid, never flat black. */
.playfield {
  position: relative;
  background:
    radial-gradient(ellipse at 50% 40%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 62%),
    linear-gradient(var(--border) 1px, transparent 1px) 0 0 / 40px 40px,
    linear-gradient(90deg, var(--border) 1px, transparent 1px) 0 0 / 40px 40px,
    var(--canvas);
  -webkit-mask-image: radial-gradient(ellipse at 50% 45%, #000 55%, transparent 100%);
  mask-image: radial-gradient(ellipse at 50% 45%, #000 55%, transparent 100%);
  min-height: 420px;
}

/* Entities glow in their own colour. */
.glow {
  filter: drop-shadow(0 0 6px currentColor);
}

.keycap {
  display: inline-grid;
  place-items: center;
  min-width: 22px;
  padding: 2px 6px;
  border-radius: var(--radius-sm);
  background: var(--card);
  border: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--muted);
}

/* Pause / game-over overlay. */
.overlay {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: rgba(9, 8, 12, 0.72);
}

.overlay .panel {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 26px;
  text-align: center;
  min-width: 300px;
}

/* ================================================================== *
 * FLOATING SURFACES — things that sit ON TOP of the page
 *
 * This whole category was missing (2026-08-13), and three separate customer
 * screenshots were the same hole: a notifications panel rendering in the middle
 * of a card grid, a confirmation dialog with a thick BLACK border, and an
 * actions menu with nowhere to go. prompt.md asked the generator for modals and
 * dropdowns; design.css had nothing to give it, and design.css is frozen, so
 * the app could not add any. The generator's only options were to invent CSS
 * (which prompt.md forbids) or leave the browser default — and the browser
 * default for <dialog> is where the black border comes from.
 *
 * Both primitives below work with NO JavaScript, which is the same floor
 * behavior.js is written against: the stylesheet must render something usable
 * on its own, and script may only improve it.
 * ================================================================== */

/* MODAL — the native <dialog>. Nothing here re-implements it: the browser
   already gives the top layer, the backdrop, the focus trap and Esc-to-close,
   and none of that is worth rebuilding. What the browser does NOT give is a
   look, and its default is actively wrong for us:
       dialog { position:absolute; margin:auto; border:solid; padding:1em }
   `border: solid` names no width and no colour, so it resolves to ~3px of
   currentColor — the black box in the screenshot — and `position:absolute`
   drops a non-modal dialog wherever it happens to sit in the document flow
   rather than over the page.
   `position:fixed; inset:0; margin:auto` is what centres it against the
   VIEWPORT, and it centres a dialog opened with .show() or a bare `open`
   attribute, not only one opened with .showModal(). That matters because htmx
   swaps markup in: a server-rendered <dialog open> now lands in the right
   place with no script at all. ::backdrop is the one part that still needs
   .showModal() — the browser paints it for nothing else — so it is a bonus on
   top of a dialog that already looks right without it. */
dialog {
  position: fixed;
  inset: 0;
  margin: auto;
  max-width: min(520px, calc(100vw - 32px));
  max-height: calc(100dvh - 32px);
  padding: 18px;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--card);
  color: var(--ink);
  box-shadow: var(--shadow-raised);
  overflow: auto;
}

dialog::backdrop {
  background: rgba(9, 8, 12, 0.5);
}

/* DROPDOWN — <details class="menu"> with a <summary> trigger and a .menu-panel.
   Built on <details> because it opens and closes with no script: the element
   is a disclosure widget, the browser handles the toggle, and a dropdown that
   needs JS to open is a dropdown that does not exist when the script fails.
   Click-outside and Esc are NOT here and are deliberately absent rather than
   half-done — a listener that closes on any outside click has to know about
   every other open panel to avoid fighting them, which is a behaviour contract,
   not a stylesheet's job.

   `list-style:none` plus the -webkit marker rule: <summary> draws a disclosure
   triangle by default in every engine, and it is drawn two different ways.
   Both have to go or the trigger has a stray arrow in one browser only. */
.menu {
  position: relative;
  display: inline-block;
}

.menu > summary {
  list-style: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.menu > summary::-webkit-details-marker {
  display: none;
}

/* Anchored to the trigger's RIGHT edge: a row's ⋮ sits at the right of its
   card or row, so a panel growing rightwards would leave the viewport. 41 puts
   it over the sidebar drawer's scrim (39) and the drawer itself (40), which is
   the only other thing in this stylesheet that stacks. */
.menu > .menu-panel {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 41;
  min-width: 190px;
  padding: 4px 0;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-raised);
}

/* Items run edge to edge, so the hover fill is the ROW rather than a chip
   floating inside a margin — which is why the panel's padding is vertical
   only. `width:100%` and `text-align:left` are for the <button> form: a menu
   item that ACTS is a button and would otherwise centre its own label. */
.menu-panel a,
.menu-panel button {
  display: block;
  width: 100%;
  height: auto;
  padding: 8px 14px;
  border: 0;
  border-radius: 0;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 13px;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
}

.menu-panel a:hover,
.menu-panel button:hover {
  background: var(--tint);
  text-decoration: none;
}

.menu-panel .danger {
  color: var(--danger);
}

/* ================================================================== *
 * PROGRESS — the native <progress value max>, not a pair of divs.
 *
 * It was unstyled, so every app drew its own track-and-fill out of two
 * elements. Native buys three things that a div pair does not: the value is
 * announced to a screen reader, `value`/`max` is the whole markup contract, and
 * an indeterminate bar (omit `value`) is free.
 *
 * The two engines disagree about which part is which — WebKit gives the element
 * a ::-webkit-progress-bar TRACK with a ::-webkit-progress-value inside it,
 * while Firefox makes the ELEMENT the track and ::-moz-progress-bar the fill.
 * So the element carries the track colour for Firefox and WebKit paints over
 * it. Separate rules per vendor: one unknown selector invalidates a whole
 * list, so grouping them would drop both in either engine.
 * ================================================================== */

progress {
  appearance: none;
  -webkit-appearance: none;
  display: block;
  width: 100%;
  height: 6px;
  border: 0;
  border-radius: 999px;
  background: color-mix(in srgb, var(--ink) 9%, transparent);
  overflow: hidden;
}

progress::-webkit-progress-bar {
  background: color-mix(in srgb, var(--ink) 9%, transparent);
  border-radius: 999px;
}

progress::-webkit-progress-value {
  background: var(--accent);
  border-radius: 999px;
}

progress::-moz-progress-bar {
  background: var(--accent);
  border-radius: 999px;
}

/* ================================================================== *
 * PAGINATION
 *
 * The current page is marked with aria-current="page", which is the same
 * choice as aria-invalid on a field: the attribute a screen reader already
 * reads is the one the colour follows, so the two can never disagree. A
 * disabled arrow takes aria-disabled — a real `disabled` on an <a> does
 * nothing, and removing the href leaves nothing to announce.
 * ================================================================== */

.pagination {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
}

/* The count sits left and pushes the controls right — one auto margin instead
   of a spacer element, so a pager with no range still lines its arrows up. */
.pagination .range {
  margin-right: auto;
  color: var(--muted);
  font-size: 12px;
}

/* Beats `button, .btn` at (0,1,1) against its (0,0,1), so every part of that
   rule which would show has to be restated here. */
.pagination a,
.pagination button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 30px;
  height: 30px;
  padding: 0 8px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--card);
  color: var(--ink);
  font: inherit;
  font-size: 12px;
  text-decoration: none;
  cursor: pointer;
}

.pagination a:hover,
.pagination button:hover {
  background: var(--tint);
  text-decoration: none;
}

.pagination [aria-current="page"] {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}

.pagination [aria-disabled="true"],
.pagination :disabled {
  opacity: 0.45;
  pointer-events: none;
}

/* ================================================================== *
 * ALERT — a message ABOUT the page, after something happened.
 *
 * `.success`, `.warning` and `.danger` already existed but only ever as
 * COMPOUND modifiers (`.btn.danger`, `.badge.success`); none of them styles
 * anything on its own. So a form that saved, or failed to, had no block to say
 * so in — prompt.md asked for success and error alerts and there was nothing to
 * ask for. Same tone names, same *-soft tokens as the badge, so a page's alert
 * and its badges agree about what "danger" looks like.
 *
 * Default tone is the accent: a plain "here is something you should know" is
 * not a warning, and colouring every notice amber trains the reader to ignore
 * amber. It takes the border a badge does not, because an alert sits ON the
 * page rather than inside a row and needs an edge to be a block at all.
 * ================================================================== */

.alert {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 11px 14px;
  border: 1px solid transparent;
  border-radius: var(--radius);
  background: var(--accent-soft);
  color: var(--accent);
  font-size: 13px;
  line-height: 1.5;
}

.alert.success {
  background: var(--success-soft);
  color: var(--success);
}

.alert.warning {
  background: var(--warning-soft);
  color: var(--warning);
}

.alert.danger {
  background: var(--danger-soft);
  color: var(--danger);
}

/* Bold and links inherit the tone. Elsewhere a link takes --accent, which on a
   danger alert is the one word that would drop out of the message. */
.alert strong,
.alert b,
.alert a {
  color: inherit;
  font-weight: 600;
}

/* ================================================================== *
 * FORM VALIDATION
 *
 * Keyed on aria-invalid, NOT :invalid. `:invalid` matches an empty required
 * field before the reader has typed a character, so a form opens already
 * painted red — which is why so many systems style it and then quietly stop.
 * These apps render server-side and re-render the form after a failed POST,
 * which is exactly when the server knows a field is wrong: it sets
 * aria-invalid="true", the screen reader is told, and the colour follows the
 * same fact rather than a second guess at it.
 * ================================================================== */

input[aria-invalid="true"],
select[aria-invalid="true"],
textarea[aria-invalid="true"] {
  border-color: var(--danger);
}

/* The focus ring follows the state too — the accent ring on a field the server
   has just rejected says "this is fine" at the moment it is being corrected. */
input[aria-invalid="true"]:focus-visible,
select[aria-invalid="true"]:focus-visible,
textarea[aria-invalid="true"]:focus-visible {
  box-shadow: 0 0 0 3px var(--danger-soft);
}

/* The message under the field. 12px like the label above it, because it is the
   same kind of small print about the same control — and it is a FOOTNOTE the
   reader drops to, not the line they arrive on. */
.field-error {
  display: block;
  margin-top: 5px;
  font-size: 12px;
  color: var(--danger);
}

/* ================================================================== *
 * LOADING
 *
 * htmx already marks every in-flight request — it puts `htmx-request` on the
 * element and ships its own indicator CSS, injected into <head> at boot:
 *
 *     .htmx-indicator               { opacity: 0 }
 *     .htmx-request .htmx-indicator { opacity: 1; transition: … }
 *
 * `opacity: 0` HIDES the indicator without removing it, so its box keeps its
 * full height forever. That is what shipped: a permanently reserved empty
 * panel above a table, which filled with a spinner whenever the filter changed
 * and sat there empty the rest of the time (customer screenshot, 2026-08-13).
 * This stylesheet never styled either class, so htmx's default was simply what
 * ran.
 *
 * The override is on DISPLAY, not opacity, which also sidesteps a cascade
 * problem: htmx appends its <style> to the end of <head>, after our own <link>,
 * so at equal specificity its opacity rule wins. Ours sets a different property
 * and cannot be argued with.
 *
 * `display: revert` rather than `block`: an indicator may be a <span> beside a
 * button or a <div> over a table, and revert hands each element back its own
 * natural display instead of forcing one on both.
 * ================================================================== */

.htmx-indicator {
  display: none;
}

.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
  display: revert;
}

/* SKELETON — the shape of what is coming, rather than the word "Loading…".
   One class, sized to a line of text and full width, because prompt.md forbids
   inline styles: an agent cannot reach for `style="height:40px"` here, so the
   primitive has to be useful as-is and stack for anything bigger. Put several
   inside a .card or a .stack to draw a paragraph, a row, a card.

   The pulse is on OPACITY of a flat fill, not a moving gradient sweep. A real
   placeholder is several bars of different widths, and a sweep crosses each at
   its own speed — within a second they are out of phase and the whole thing
   reads as noise rather than as one surface waiting. */
.skeleton {
  display: block;
  width: 100%;
  height: 12px;
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--ink) 9%, transparent);
  animation: skeleton-pulse 1.4s var(--ease) infinite;
}

.skeleton + .skeleton {
  margin-top: 8px;
}

/* Half width, for the last line of a paragraph or a secondary field — a block
   of identical bars reads as a table, not as text. */
.skeleton.sm {
  width: 45%;
}

@keyframes skeleton-pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.45;
  }
}

/* The SHAPE is the point and the pulse is only the "still working" signal, so
   reduced motion keeps the first and drops the second. */
@media (prefers-reduced-motion: reduce) {
  .skeleton {
    animation: none;
  }
}

/* ================================================================== *
 * UTILITIES
 * ================================================================== */

.row {
  display: flex;
  align-items: center;
  gap: 10px;
}

/* The SAME declaration `.shell > *` carries, one level down (2026-08-13) — see
   the comment there for why a flex or grid item's default `min-width: auto`
   refuses to shrink below its content.
   What it costs here: a `<select>`'s min-content width is its WIDEST OPTION, so
   a filter row holding a "Investments" category blew its own column open and
   overlapped the field beside it — the select rendered wider than the track it
   was in and clipped its own label to "stments" (customer screenshot). A text
   input does the same with a long placeholder.
   Letting them shrink is the right answer rather than a floor: a field clips or
   ellipses its text and stays usable, and `.tablewrap` — which already carries
   overflow-x:auto — finally gets a narrow box to scroll inside, which is the
   second half of the `.shell > *` note.
   `.actions` is deliberately NOT included: it holds BUTTONS, which are
   `white-space: nowrap` on purpose, and squeezing a button below its label is
   not a graceful degradation of anything. */
.row > *,
.grid > * {
  min-width: 0;
}

/* .row is a CONTROL row — a brand, a spacer, some buttons — and centring is
   right for that. It is not a page layout, but there is no two-column layout
   primitive, so the generator reaches for it and gets two side-by-side cards
   CENTRED against each other: measured 15px of top-edge offset on a form,
   exactly half the height difference between them.
   `start` rather than `stretch`: stretch would also top-align, but it forces the
   shorter card to the taller one's height, and .card declares no internal
   layout, so the 30px it gains renders as dead space at the bottom. Natural
   heights, aligned tops. .row's own centring is untouched, so every control row
   in the fleet is unaffected. */
.row > .card {
  align-self: start;
}

.grow {
  flex: 1;
}

.right {
  text-align: right;
}

/* ================================================================== *
 * RESPONSIVE — the sidebar folds above the content on narrow screens.
 * ================================================================== */

@media (max-width: 860px) {
  .shell {
    grid-template-columns: 1fr;
    /* The rows have to be stated. .shell carries min-height:100vh, and a grid
       with implicit rows STRETCHES them to fill it — so a short first row is
       inflated to share the viewport with the second. With the sidebar in flow
       that was invisible, because it filled its own row. The moment the drawer
       took it out of flow and left a 56px bar behind, that bar's row grew to
       369px and opened 313px of blank page under it.
       auto for the bar, 1fr for everything else. Correct in BOTH states: the
       no-JS strip and the injected bar are each the only in-flow first child. */
    grid-template-rows: auto 1fr;
  }

  .sidebar {
    border-right: 0;
    border-bottom: 1px solid var(--border);
  }

  .sidebar .foot {
    margin-top: 0;
  }

  .hero {
    grid-template-columns: 1fr;
    gap: 30px;
  }

  /* MOBILE NAV, FLOOR STATE — no JavaScript involved.
     The sidebar becomes a horizontal strip: brand, links and the account foot
     on one scrolling line. Everything stays visible and Log out stays tappable,
     which is the property that matters — an earlier drawer-only design put the
     whole nav off-screen when its script failed to run, and a nav you cannot
     reach is worse than one that takes room.
     The height is the token, not a value, because the JS state below has to
     match it exactly. */
  .sidebar {
    flex-direction: row;
    align-items: center;
    gap: 12px;
    height: var(--navbar-h);
    padding: 0 12px;
    overflow-x: auto;
    overflow-y: hidden;
  }

  .sidebar .navgroup {
    display: flex;
    align-items: center;
    gap: 8px;
  }

  .sidebar .navgroup > .label {
    display: none;
  }

  .sidebar .foot {
    margin-top: 0;
    flex-direction: row;
    align-items: center;
    gap: 8px;
  }

  /* The account row is a .list in a column sidebar; on one line its row chrome
     is noise. */
  .sidebar .foot .list > li {
    min-height: 0;
    padding: 0;
    border-top: 0;
  }

  /* MOBILE NAV, UPGRADED STATE — the frozen behaviour script ran.
     The same sidebar becomes an off-canvas drawer and the injected .navbar
     takes its place in the flow, at the SAME token height. Nothing moves when
     the upgrade happens, however late the script arrives. */
  html[data-nav="drawer"] .navbar {
    display: flex;
  }

  /* The hamburger is the only way to the navigation on every screen where the
     drawer exists, so it takes the touch size across that whole range — not
     only below 560px, where the general button rule starts. Measured at 684px
     it was 38x28 before this. */
  html[data-nav="drawer"] .navtoggle {
    min-height: 44px;
    min-width: 44px;
    justify-content: center;
  }

  html[data-nav="drawer"] .sidebar {
    position: fixed;
    inset: 0 auto 0 0;
    width: 80%;
    max-width: 320px;
    height: auto;
    flex-direction: column;
    align-items: stretch;
    gap: 18px;
    padding: 14px 12px;
    overflow-x: hidden;
    overflow-y: auto;
    border-right: 1px solid var(--border);
    transform: translateX(-100%);
    transition: transform var(--dur) var(--ease);
    z-index: 40;
  }

  html[data-nav="drawer"] .sidebar .navgroup {
    display: block;
  }

  html[data-nav="drawer"] .sidebar .navgroup > .label {
    display: block;
  }

  html[data-nav="drawer"] .sidebar .foot {
    margin-top: auto;
    flex-direction: column;
    align-items: stretch;
  }

  html[data-nav="drawer"] .sidebar .foot .list > li {
    min-height: var(--row-h);
    padding: 10px 16px;
  }

  html[data-nav="drawer"][data-nav-open] .sidebar {
    transform: none;
  }

  html[data-nav="drawer"] .navscrim {
    display: block;
  }

  html[data-nav="drawer"][data-nav-open] .navscrim {
    opacity: 1;
    pointer-events: auto;
  }
}

/* Injected by the frozen behaviour script, never by the app. Both are
   display:none by default, so above the breakpoint they are not in the grid at
   all and the desktop shell is untouched — which is also what makes rotating
   past 860px safe: every rule that positions the drawer lives in the query, so
   crossing it puts the sidebar back in the flow with nothing left over. */
.navbar {
  display: none;
  align-items: center;
  gap: 10px;
  height: var(--navbar-h);
  padding: 0 12px;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
}

.navbar .brand {
  font-weight: 600;
}

.navscrim {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 39;
  background: color-mix(in srgb, var(--ink) 45%, transparent);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur) var(--ease);
}

/* The drawer is the first thing in this system that MOVES, so it is the first
   that has to offer a way out of the movement. Everything still works — the
   panel arrives and leaves, it simply does so at once.
   This also has a testing consequence worth knowing: a running transition
   outranks even !important, so an automated check that measures the panel's
   position has no way to read a settled value while one is in flight. With this
   here, --force-prefers-reduced-motion makes the measurement possible at all. */
@media (prefers-reduced-motion: reduce) {
  html[data-nav="drawer"] .sidebar,
  .navscrim {
    transition: none;
  }
}

/* PHONE. The 860px query above changes STRUCTURE — the sidebar folds, the hero
   stops being two columns. This one changes SCALE: the same layout, with the
   desktop's generosity taken out of it. 560px because it sits below the
   smallest tablet and above the largest phone, so nothing between the two
   queries is caught by both.
 * ================================================================== */

@media (max-width: 560px) {
  /* 22px of gutter on each side takes 12% of a 375px screen. */
  .content {
    padding: 16px;
  }

  /* A section's 72px of breathing room is a desktop measurement; on a phone it
     is most of a screen height between one paragraph and the next. Same
     selector as the resting rule, so it wins on source order rather than by
     inventing specificity. */
  body[data-genre="landing"] .band {
    padding: 44px 16px;
  }

  /* EVERY .row wraps here, not just the ones holding cards.
     That started as the narrower rule, on the evidence that a row of a brand,
     badges and buttons fitted on one line at 375px with zero overflow. It did —
     but that was ONE control-row shape, and it generalised badly. A gallery
     page put a five-item .segmented filter and a .search in the same row: 293px
     and 260px inside a 328px content box, and the page overflowed 229px. A row
     whose children cannot all shrink has to be allowed a second line. */
  .row {
    flex-wrap: wrap;
  }

  /* .grow gives a card flex-basis:0, so it shrinks forever and would never take
     the new line that flex-wrap offers it. */
  .row:has(> .card) > .card {
    flex-basis: 100%;
  }

  /* A search field wide enough to read a query in is a DESKTOP measurement.
     On a phone the field is the width of the screen, and the floor is the only
     thing stopping it from being that. */
  .search {
    min-width: 0;
  }

  /* Five filters on one line is 293px before the row's other child is counted.
     Wrapping keeps every option visible and reachable; scrolling the control
     would hide some of them behind a gesture with nothing to suggest it. */
  .segmented {
    flex-wrap: wrap;
  }

  /* Four real button labels measured 372px past a 360px screen. .row and
     .segmented were given a second line here; .actions is the same shape and
     was simply missed. */
  .actions {
    flex-wrap: wrap;
  }

  /* Same shape again, in the game genre. Note this one is NOT what its fixture
     needed: with overflow-wrap in place the six-slot HUD already fits, because
     its values can break. It is here because six slots sharing 360px are ~55px
     each whether or not they overflow, which the overflow check cannot see. */
  body[data-genre="game"] .hud {
    flex-wrap: wrap;
  }
}

/* Tap targets. 33px is a comfortable MOUSE target and too small for a finger;
   the usual floor is 44px. Applied on a coarse pointer as well as a narrow
   window, because a touch tablet is wide and still has no cursor — and NOT
   applied unconditionally, which would fatten every button on every desktop in
   the fleet to fix a problem desktops do not have. */
@media (max-width: 560px), (pointer: coarse) {
  button,
  .btn {
    min-height: 44px;
  }
}
