/* ENCLAVE — design system.
   No external fonts or assets: the site must render fully on a locked-down VPS
   with no third-party requests.

   The rules this file implements are written down in docs/UI_Guidelines.md.
   Two of them explain most of the structure below:

   1. The CSP sends `style-src 'self'` with no 'unsafe-inline', so a `style="…"`
      attribute is silently DISCARDED by the browser. Spacing therefore has to
      come from the container or from a named class — never from the element.
   2. Every length is a step on the spacing scale. No rule invents an 18px.

   Order: tokens · base · layout & rhythm · typography · components · utilities
   · responsive. Utilities come last so they can override container rhythm
   without either side needing !important. */

/* ------------------------------------------------------------------ tokens */

:root {
  /* surfaces, from furthest back to nearest front */
  --bg: #0b0c0f;
  --bg-2: #111318;
  --surface: #171a20;
  --surface-2: #1e222a;
  --surface-3: #292e38;
  --line: #343a46;
  --line-soft: #252a33;

  /* text, from loudest to quietest */
  --text: #f5f6f8;
  --text-dim: #b1b6c0;
  --text-faint: #7c838f;
  /* The mark on a select. A data URL is its own document and cannot read a
     token, so --text-dim is written out here by hand: change one, change both. */
  --chevron-img: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1 5 5 9 1' fill='none' stroke='%23b1b6c0' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");

  /* brand */
  --accent: #ff5b35;
  --accent-hover: #ff7354;
  --accent-soft: rgba(255, 91, 53, 0.14);
  --on-accent: #1a0e0a;

  /* points are their own colour and are never used for anything else */
  --pts: #70d69b;
  --pts-dim: rgba(112, 214, 155, 0.13);

  /* status */
  --ok: #4ade80;
  --warn: #fbbf24;
  --bad: #f87171;
  --info: #60a5fa;
  --ok-text: #86efac;
  --warn-text: #fcd34d;
  --bad-text: #fca5a5;
  --info-text: #93c5fd;
  --on-ok: #0b1710;
  --on-warn: #1a1305;
  --on-bad: #1c0c0c;
  --ok-soft: rgba(74, 222, 128, 0.12);
  --ok-hover: rgba(74, 222, 128, 0.20);
  --ok-border: rgba(74, 222, 128, 0.32);
  --warn-soft: rgba(251, 191, 36, 0.13);
  --warn-hover: rgba(251, 191, 36, 0.21);
  --warn-border: rgba(251, 191, 36, 0.32);
  --bad-soft: rgba(248, 113, 113, 0.12);
  --bad-hover: rgba(248, 113, 113, 0.20);
  --bad-border: rgba(248, 113, 113, 0.30);
  --info-soft: rgba(96, 165, 250, 0.08);
  --info-border: rgba(96, 165, 250, 0.30);

  /* The same three washes, resolved once onto the raised surface. A wash is a
     hole and shows what is behind it, which is the page itself for anything
     that floats over it rather than sitting on a panel. Keep each percentage
     equal to the alpha of the --*-soft above it: one tone, one weight. */
  --ok-solid: color-mix(in srgb, var(--ok) 12%, var(--bg-2));
  --warn-solid: color-mix(in srgb, var(--warn) 13%, var(--bg-2));
  --bad-solid: color-mix(in srgb, var(--bad) 12%, var(--bg-2));

  /* translucent overlays remain solid colours: none blend between stops */
  --overlay: rgba(6, 10, 8, 0.82);
  --overlay-quiet: rgba(6, 10, 8, 0.74);
  --overlay-border: rgba(255, 255, 255, 0.14);
  --pts-border: rgba(112, 214, 155, 0.32);
  --row-hover: rgba(255, 255, 255, 0.03);
  --qr-bg: #ffffff;

  /* spacing scale — 4px base. Every margin, padding and gap is one of these. */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 20px;
  --space-6: 24px;
  --space-7: 32px;
  --space-8: 40px;
  --space-9: 56px;

  /* the two rhythm constants every page depends on */
  --space-section: var(--space-5); /* between top-level blocks of a page */
  --space-inner: var(--space-3);   /* between blocks inside a panel */

  /* controls share two heights so fields and adjacent actions align exactly */
  --control: 40px;
  --control-sm: 32px;
  --chevron: 10px; /* the select mark, inset like any other control at --space-3 */

  /* type scale */
  --fs-2xs: 10px;
  --fs-xs: 11px;
  --fs-sm: 12px;
  --fs-md: 14px;
  --fs-base: 16px;
  --fs-lg: 20px;
  --fs-xl: 28px;
  --fs-2xl: 36px;

  --lh-tight: 1.2;
  --lh-body: 1.5;
  --lh-prose: 1.6;
  /* For a figure set in a band of fixed height. 16px of type times 1.25 is 20px
     exactly, and a line box on a whole pixel is centred where the arithmetic
     says it is; --lh-tight gives 19.2 and the browser's rounding of that leaves
     the figure half a pixel off its own box, which `--ink-lift` then cannot
     correct because the two are not the same error. */
  --lh-figure: 1.25;

  /* Not a spacing step, and not a length taken from the grid: it is half a
     pixel. Segoe UI — the system face on Windows, so most of the audience —
     carries more ascent than descent, so the line box it builds holds more air
     above the capitals than below them, and centring that line box still
     leaves the ink low. Nothing in the cascade reaches it: `align-items:
     center` centres the line box, and the line box is what is crooked.
     The residue is not proportional to the type, because the browser rounds
     the font box to whole pixels before laying it out and it is the rounding
     that survives. Measured against the middle of the box drawn around it, the
     ink sat exactly half a pixel low in most of the bordered boxes on the site
     — at 10px as at 16px — and already dead centre in the rest. So this is a
     length rather than a ratio, and it is not applied blind: `.badge` and
     `.stepper-num` were centred to begin with and do not take it, and
     `.checklist-mark` leans the other way and takes it reversed. All three say
     so at their own rules. An em-based value was tried first and is what
     pushed those three off; see HISTORY.md.
     A box grown from its padding takes this off the top and adds it to the
     bottom. A box whose size is fixed instead pads a single edge, which moves
     the content half as far, so it pads by twice this. Either way the height
     is unchanged and only the ink moves. */
  --ink-lift: 0.5px;

  /* one tracking value per job, so uppercase labels always match */
  --track-label: 0.08em;
  --track-nav: 0.06em;
  --track-tight: -0.02em;

  --weight-strong: 650;
  --weight-bold: 700;

  --radius: 10px;
  --radius-sm: 8px;
  --radius-lg: 14px;
  --radius-pill: 999px;

  --shadow: 0 20px 48px -24px rgba(0, 0, 0, 0.92);
  --ring: 0 0 0 3px var(--accent-soft);

  --dur: 0.13s;
  --dur-slow: 0.2s;

  /* One page width. The topbar, the content, the footer and the admin shell all
     stop at the same edge, so moving between tabs never resizes the page. */
  --shell: 1400px;

  /* The bar's own height, and so the offset of everything pinned under it: the
     admin sidebar, the phone drawer and the flash. */
  --topbar-h: 58px;

  /* Not a prose cap: text fills its container everywhere. This is the width the
     hero sentence asks for before it drops to its own line. */
  --measure: 660px;
  --card-min: 210px;
  --action-min: 280px;
  --tile-min: 190px;
  --fact-min: 160px;
  --col-min: 320px;

  --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
}

/* -------------------------------------------------------------------- base */

* { box-sizing: border-box; }

html, body { height: 100%; }
/* A short page has no scrollbar and a long one does, and without a reserved
   gutter every tab switch between the two shifts the whole layout sideways. */
html { scrollbar-gutter: stable; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font);
  font-size: var(--fs-base);
  line-height: var(--lh-body);
  -webkit-font-smoothing: antialiased;
}
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

a { color: inherit; text-decoration: none; }
a:hover { color: var(--text); }

/* A list is a block like any other, and the browser's own margin on one is a
   margin the block set for itself. Neutralised here, in base, so the container
   rules below can still space a list that sits on a page — a reset written
   further down the file wins on source order and silently eats the page gap. */
ul, ol { margin: 0; }

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

/* The CSP forbids inline style, so `style="display:none"` never applies and a
   placeholder would sit on top of loaded art. Hiding is done with [hidden]. */
[hidden] { display: none !important; }

/* -------------------------------------------------------- layout & rhythm */

/* Both authenticated shells are a column: the topbar pins, the content grows,
   and the footer sits at the bottom of a short page instead of floating in the
   middle of the viewport. */
body.app { display: flex; flex-direction: column; min-height: 100%; }
body.app > .main { flex: 1 0 auto; }
/* A flex item with an auto cross-axis margin opts out of `align-items: stretch`,
   so a shell that centres itself with `margin: 0 auto` shrink-wraps to its own
   text rather than filling the window — a form page ends up as wide as its
   heading. Each shell asks for the full width and lets its own max-width clamp. */
body.app > .main,
body.app > .footer,
body.app > .adminshell { width: 100%; }
/* The admin shell has no footer, and its sidebar rule must run the full height
   of the page rather than stopping under the last nav item. */
.admin-body > .adminshell { flex: 1 0 auto; }

/* Vertical rhythm is owned by the container. A page is a stack of top-level
   blocks separated by exactly one section gap — no block sets its own margin,
   which is what keeps every page identical and is why a dropped inline style
   can no longer leave two sections touching. */
.main {
  max-width: var(--shell);
  margin: 0 auto;
  padding: var(--space-6) var(--space-5) var(--space-9);
}
/* The admin content column carries the storefront's padding so its first line
   starts under the logo, exactly as a storefront page does. */
.adminmain { padding: var(--space-6) var(--space-5) var(--space-9); min-width: 0; }

.main > * + *,
.adminmain > * + * { margin-top: var(--space-section); }

/* Inside a panel the same rule applies one step tighter. `.stack` opts out:
   it is already a gap-spaced grid and would otherwise double up. */
.panel:not(.stack) > * + *,
.authcard:not(.stack) > * + * { margin-top: var(--space-inner); }

.panel {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: var(--space-4);
  min-width: 0;
}
.panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  flex-wrap: wrap;
}
.panel-head > :first-child { min-width: 0; }
.panel-head > :last-child { flex: 0 0 auto; }
.panel-title {
  font-size: var(--fs-sm);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-faint);
  font-weight: var(--weight-bold);
}
/* Takes the width of the panel it sits in. */
.panel-note {
  margin: 0;
  font-size: var(--fs-md);
  color: var(--text-faint);
  line-height: var(--lh-prose);
}

/* No margin here. `.next-actions` is a top-level block and `.main > * + *`
   already spaces it; a margin of its own would sit under that gap and leave one
   panel on the page further from the next than every other. */
/* Side by side, not stacked. This panel sits above the filters and the grid on
   the busiest page on the site, and a full-width row per item pushed the first
   game card off the screen for anyone with more than two things on the go. Five
   is the most this list can ever hold, so they fit across. */
.next-action-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(var(--action-min), 1fr));
  gap: var(--space-3);
}
/* The whole card is the link — there is one destination per item, so a separate
   button next to it was a second way to do the same thing. */
.next-action {
  --side: var(--line);
  position: relative;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding: var(--space-3);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--text);
  text-decoration: none;
  transition: border-color 0.12s ease;
}
.next-action:hover { border-color: var(--side); }
.next-action:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Which side of the marketplace this item is on, said twice. The stripe rides
   the left edge inside the radius the way `.bigwait-bar` rides the top, and the
   role word above the title carries the same meaning for anyone the colour
   never reaches. `.is-owning` is the one item here that is not shopping.
   `--side` is that one colour: the stripe and the hover border read from it, so
   pointing at a card lights up the edge it is already striped with. The focus
   ring stays `--accent` — it is the same ring on every focusable thing on the
   site and it should not shift meaning per card. */
.next-action::before {
  content: "";
  position: absolute;
  inset: 0 auto 0 0;
  width: 2px;
  background: var(--side);
}
.next-action.is-buying { --side: var(--accent); }
.next-action.is-owning { --side: var(--info); }
.next-action-role {
  font-size: var(--fs-2xs);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-faint);
}
.next-action.is-owning .next-action-role { color: var(--info-text); }
.next-action-title { font-weight: var(--weight-strong); font-size: var(--fs-md); }
/* How far through the checklist this exchange is, which only the buying side
   has: the same bar and the same `done/total · next step` line a game card
   carries, so the rail and the grid say it identically. */
.next-action-progress { margin-top: var(--space-1); }
.next-action-step { display: block; margin-top: var(--space-1); font-size: var(--fs-xs); color: var(--text-dim); }
.next-action-cta { margin-top: auto; padding-top: var(--space-2); font-size: var(--fs-sm); color: var(--accent); }

.pagehead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  flex-wrap: wrap;
}
.pagehead > .stack-sm { flex: 1 1 auto; min-width: 0; }
.pagehead-actions { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); flex: 0 0 auto; flex-wrap: wrap; }
.pagehead-sub {
  color: var(--text-dim);
  font-size: var(--fs-md);
  margin: 0;
}

/* A heading and the thing it names, held together. `.main > * + *` spaces the
   section from its neighbours; the section spaces its own head from its body,
   which is the only way a heading stays nearer what it labels than what comes
   after it. */
.section { display: grid; gap: var(--space-3); }
.sectionhead {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
  flex-wrap: wrap;
}
.sectionhead-note { font-size: var(--fs-sm); color: var(--text-faint); }

/* Layout primitives. These are the only ways to put things side by side. */
.stack { display: grid; gap: var(--space-3); }
.stack-sm { display: grid; gap: var(--space-2); }
/* A gap-spaced container owns the spacing, so a paragraph inside one must not
   also carry its own bottom margin. */
.stack > p, .stack-sm > p, .row > p { margin-bottom: 0; }
.row { display: flex; gap: var(--space-3); align-items: center; flex-wrap: nowrap; }
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(var(--col-min), 1fr)); gap: var(--space-4); }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--space-4); }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(var(--tile-min), 1fr)); gap: var(--space-4); }
.grid-2 > *, .grid-3 > *, .stats > * { min-width: 0; }

.footer {
  max-width: var(--shell);
  margin: 0 auto;
  padding: var(--space-5);
  display: flex;
  justify-content: space-between;
  gap: var(--space-4);
  border-top: 1px solid var(--line-soft);
  color: var(--text-faint);
  font-size: var(--fs-sm);
}
.footer-links { display: flex; gap: var(--space-4); }

/* -------------------------------------------------------------- typography */

h1, h2, h3 {
  margin: 0;
  line-height: var(--lh-tight);
  letter-spacing: var(--track-tight);
  font-weight: var(--weight-strong);
}
h1 { font-size: var(--fs-xl); }
h2 { font-size: var(--fs-lg); }
h3 { font-size: var(--fs-base); }
p { margin: 0 0 var(--space-4); }
p:last-child { margin-bottom: 0; }
hr { border: 0; border-top: 1px solid var(--line-soft); margin: var(--space-6) 0; }
code, .mono { font-family: var(--mono); font-size: 0.92em; }

.muted { color: var(--text-dim); }
.faint { color: var(--text-faint); font-size: var(--fs-sm); }
.eyebrow {
  font-size: var(--fs-xs);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-faint);
  font-weight: var(--weight-bold);
}

/* Long-form copy: legal documents, FAQ answers. */
.doc { color: var(--text-dim); font-size: var(--fs-base); line-height: var(--lh-prose); }
.doc h2, .doc h3 { color: var(--text); margin-top: var(--space-6); }
.doc :first-child { margin-top: 0; }

/* Compact ordered/unordered lists inside a panel. */
.tight { margin: 0; padding-left: 1.25em; display: grid; gap: var(--space-1); color: var(--text-dim); font-size: var(--fs-md); }
.tight li { line-height: var(--lh-prose); }

/* ------------------------------------------------------------------ points */

/* An amount is one object: `inline-flex` so the number and its mark can never be
   split across a line break, and so the mark is centred against the number
   rather than sat on its baseline, which an SVG does not have. The box takes its
   own baseline from the number, so it still sits in a sentence like text. */
.pts {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  color: var(--pts);
  font-weight: var(--weight-bold);
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
}
.pts-neg { color: var(--bad); }
/* The one length here that is not a token: the mark is sized in `em` so it
   tracks whatever type it is set beside, from a table cell to the hero price.
   `align-items: center` centres it on the box while the digits beside it hang
   off a baseline half a pixel lower, so it is nudged down by the same amount
   the digits are: without this the mark reads high wherever an amount is set,
   and correcting the box only moves the pair together. Paint, not layout — the
   height of the line is untouched. */
.pts-mark {
  flex: 0 0 auto;
  width: 0.9em;
  height: 0.9em;
  position: relative;
  top: var(--ink-lift);
}

/* ----------------------------------------------------------------- buttons */

/* One button. Variants change colour only — never size, weight or radius.
   Size is `.btn-sm` or nothing. */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: var(--control);
  padding: calc(var(--space-2) - var(--ink-lift)) var(--space-4) calc(var(--space-2) + var(--ink-lift));
  border-radius: var(--radius-sm);
  border: 1px solid transparent;
  background: var(--surface-2);
  color: var(--text);
  font: inherit;
  font-size: var(--fs-md);
  font-weight: var(--weight-strong);
  letter-spacing: 0.04em;
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--dur), border-color var(--dur), transform 0.06s;
}
.btn:hover { background: var(--surface-3); color: var(--text); }
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--on-accent); }
.btn-ghost { background: transparent; border-color: var(--line); color: var(--text-dim); }
.btn-ghost:hover { border-color: var(--text-faint); background: var(--surface); }
.btn-ok { background: var(--ok-soft); border-color: var(--ok-border); color: var(--ok-text); }
.btn-ok:hover { background: var(--ok-hover); color: var(--ok-text); }
.btn-warn { background: var(--warn-soft); border-color: var(--warn-border); color: var(--warn-text); }
.btn-warn:hover { background: var(--warn-hover); color: var(--warn-text); }
.btn-danger { background: var(--bad-soft); border-color: var(--bad-border); color: var(--bad-text); }
.btn-danger:hover { background: var(--bad-hover); color: var(--bad-text); }
.btn-sm {
  min-height: var(--control-sm);
  padding: calc(var(--space-1) - var(--ink-lift)) var(--space-3) calc(var(--space-1) + var(--ink-lift));
  font-size: var(--fs-sm);
}
.btn-block { width: 100%; }
/* A mark inside a button sits beside the word, never instead of it — the wordless
   one is `.iconbtn`. `currentColor` in the markup, size from the scale here. */
.btn > svg { flex: 0 0 auto; display: block; width: var(--space-4); height: var(--space-4); }
/* A disabled button still has to be read. Fading it to 45% put its label under
   every contrast floor there is, so unavailability is said with a flat surface,
   a soft border and the dimmest text colour that still passes — not with a
   ghost of the enabled button. */
.btn[disabled], .btn.is-disabled {
  background: var(--surface-2);
  border-color: var(--line-soft);
  color: var(--text-faint);
  cursor: not-allowed;
  pointer-events: none;
  transform: none;
}
.btn.is-on { border-color: var(--accent); color: var(--accent); }
/* Held for a moment after a copy, so the confirmation is seen rather than
   inferred from a word that flickered. */
.btn.is-copied { background: var(--ok-soft); border-color: var(--ok-border); color: var(--ok-text); }
.btn.is-copyfailed { background: var(--bad-soft); border-color: var(--bad-border); color: var(--bad-text); }

/* The field `public/app.js` copies from where `navigator.clipboard` is absent.
   Moved off-screen rather than hidden: a field that is not rendered is not
   selectable, and an unselectable field copies nothing. */
.copy-shim {
  position: fixed;
  top: 0;
  left: -9999px;
  width: 1px;
  height: 1px;
  padding: 0;
  border: 0;
  opacity: 0;
}
.inline-form { display: inline-flex; }
.linklike { background: none; border: 0; font: inherit; cursor: pointer; }

/* The spinner is part of the button from the start and simply has no width
   until the button is working, so nothing reflows when it appears. */
.spin { display: none; }
[aria-busy='true'] > .spin {
  display: inline-block;
  width: 12px;
  height: 12px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: var(--radius-pill);
  animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ------------------------------------------------------------------- forms */

.field { display: grid; gap: var(--space-2); }
.field-label {
  font-size: var(--fs-sm);
  font-weight: var(--weight-strong);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-dim);
}
.field-help { font-size: var(--fs-sm); color: var(--text-faint); }
input, select, textarea {
  width: 100%;
  min-height: var(--control);
  padding: var(--space-2) var(--space-3);
  background: var(--bg-2);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  font: inherit;
  font-size: var(--fs-md);
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
textarea { resize: vertical; min-height: 84px; }
/* Each browser puts the native mark somewhere else — flush to the border on one,
   a wider glyph a few pixels in on the next — so a row of three selects ends in
   three marks at three insets. Draw one instead, on the row's centre line and
   --space-3 from the edge, the inset every other control keeps. The text stops
   before it: padding is the inset twice over, once for the gap and once for the
   edge, plus the mark itself. */
select {
  color-scheme: dark;
  appearance: none;
  -webkit-appearance: none;
  padding-right: calc(var(--space-3) * 2 + var(--chevron));
  background-image: var(--chevron-img);
  background-repeat: no-repeat;
  background-position: right var(--space-3) center;
  background-size: var(--chevron) auto;
}
label.check { display: flex; align-items: flex-start; gap: var(--space-2); font-size: var(--fs-md); color: var(--text-dim); }
label.check input { width: auto; margin-top: var(--space-1); }
.form-actions { display: flex; gap: var(--space-2); align-items: center; justify-content: flex-end; flex-wrap: nowrap; }

/* A refusal that belongs to one field, not to the page: a flash would survive
   the redirect this form does not do. */
.form-error { color: var(--bad); font-size: var(--fs-md); }

/* A form laid out as a plain block still needs the field rhythm. */
form:not(.stack):not(.row):not(.inline-form) > .field + .field { margin-top: var(--space-4); }
form:not(.stack):not(.row):not(.inline-form) > .field + .form-actions,
form:not(.stack):not(.row):not(.inline-form) > .check + .form-actions { margin-top: var(--space-4); }

/* ------------------------------------------------------------- entry / auth */

.entry {
  min-height: 100vh;
  min-height: 100dvh;
  display: grid;
  place-items: center;
  padding: var(--space-6);
  text-align: center;
  background: var(--bg);
  position: relative;
  overflow: hidden;
  grid-template-columns: minmax(0, 1fr);
}
.entry::after {
  content: "";
  position: absolute;
  width: 420px;
  height: 420px;
  right: -180px;
  top: -180px;
  border: 80px solid var(--accent);
  border-radius: 50%;
  opacity: 0.07;
  pointer-events: none;
}
.entry-inner { position: relative; z-index: 1; width: 100%; max-width: 440px; min-width: 0; justify-self: center; }
.entry .logo { justify-content: center; }
.entry .logo-word { font-size: var(--fs-xl); letter-spacing: 0.24em; }
.entry-tagline {
  margin: var(--space-6) 0 var(--space-7);
  font-size: var(--fs-sm);
  letter-spacing: 0.42em;
  text-transform: uppercase;
  color: var(--text-faint);
}
.entry-actions { display: grid; gap: var(--space-3); }
.entry-how {
  display: grid;
  gap: var(--space-2);
  margin-top: var(--space-5);
  text-align: left;
  color: var(--text-dim);
  font-size: var(--fs-sm);
}
.entry-foot {
  margin-top: var(--space-7);
  font-size: var(--fs-sm);
  color: var(--text-faint);
  display: flex;
  gap: var(--space-4);
  justify-content: center;
}
.entry-foot a:hover { color: var(--text-dim); }

.notification-unread {
  border-color: var(--accent);
  background: var(--accent-soft);
}

.authwrap {
  min-height: 100vh;
  min-height: 100dvh;
  display: grid;
  place-items: center;
  padding: var(--space-6);
  background: var(--bg);
  grid-template-columns: minmax(0, 1fr);
}
.authcard {
  width: 100%;
  max-width: 430px;
  min-width: 0;
  justify-self: center;
  background: var(--surface);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-lg);
  padding: var(--space-7);
  box-shadow: var(--shadow);
}
.authcard-wide { max-width: 680px; }
.authcard h1 { font-size: var(--fs-lg); }
.authcard .sub { color: var(--text-dim); font-size: var(--fs-md); }
/* A card is three groups, not one flat run: the mark, the heading with the line
   that names it, and what the page is asking for. At one 12px step they read as
   six equal parts of a list, so the pair that belongs together closes up and the
   seam before the form opens. The card gives every gap: a `p` keeps no bottom
   margin of its own, which collapses against the next block and sets a spacing
   the source never states. */
.authcard:not(.stack) > p { margin-bottom: 0; }
.authcard:not(.stack) > .logo + h1 { margin-top: var(--space-5); }
.authcard:not(.stack) > h1 + .sub { margin-top: var(--space-2); }
.authcard:not(.stack) > .sub + * { margin-top: var(--space-6); }
/* A form on a card keeps the field rhythm every other form has. */
.authcard > form.stack { gap: var(--space-4); }
/* The extra air above the rule is asked of the container, not taken by the
   element: `.authcard:not(.stack) > * + *` is the more specific selector, so a
   `margin-top` written here loses to it silently and the source reads as though
   a gap exists that the browser never draws. */
.authcard:not(.stack) > .authfoot,
.authcard:not(.stack) > .authcard-foot { margin-top: var(--space-5); }
.authfoot,
.authcard-foot {
  padding-top: var(--space-4);
  border-top: 1px solid var(--line-soft);
  font-size: var(--fs-md);
  color: var(--text-faint);
  text-align: center;
}
/* A foot with two ways out stacks them, and a link sitting on a button is one
   smudge: the container spaces them as everywhere else. A foot holding one link
   has no `+` to match and is untouched. */
.authfoot > * + *,
.authcard-foot > * + * { margin-top: var(--space-3); }
.authfoot a,
.authcard-foot a { color: var(--text-dim); text-decoration: underline; text-underline-offset: 3px; }

/* ------------------------------------------------------------ second factor */

/* The QR stays black on white in both themes. A camera reading a code is not
   looking at our palette, and an inverted one is a code a lot of phones refuse. */
.qr-frame {
  display: grid;
  place-items: center;
  padding: var(--space-4);
  background: var(--qr-bg);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
}
/* Drawn at the size the SVG asks for, which is a whole number of pixels per
   module. The code is `shape-rendering="crispEdges"`, so nothing is smoothed: a
   fractional scale makes some modules a pixel wider than their neighbours, and a
   camera sampling on an even grid walks off the code. A phone narrower than the
   code still gets it, smaller and uneven, but a phone cannot photograph its own
   screen and the key underneath is the path it takes. */
.qr-frame svg { width: auto; max-width: 100%; height: auto; display: block; }

/* The typed-in fallback. Wide letter spacing because it is read a group at a
   time off a screen and typed into a phone. It is the other half of a row, so it
   is one --control tall and centres what it holds; the padding is there for the
   narrow window where the key runs to a second line. */
.secret-key {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: var(--control);
  padding: var(--space-1) var(--space-3);
  background: var(--bg-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  font-family: var(--mono);
  font-size: var(--fs-md);
  letter-spacing: 0.12em;
  word-break: break-all;
}

.codegrid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: var(--space-2);
  margin: 0;
  padding: 0;
  list-style: none;
  counter-reset: recovery;
}
.codegrid li {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  background: var(--bg-2);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
}
.codegrid li::before {
  counter-increment: recovery;
  content: counter(recovery);
  min-width: 1.4em;
  color: var(--text-faint);
  font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;
}
.codegrid code { font-family: var(--mono); font-size: var(--fs-md); letter-spacing: 0.06em; }

.gate { min-height: 100vh; min-height: 100dvh; display: grid; grid-template-columns: minmax(0, 1fr); place-items: center; padding: var(--space-6); text-align: center; }
.gate-inner { width: 100%; max-width: 520px; min-width: 0; }
.gate .steps { text-align: left; display: grid; gap: var(--space-3); margin: var(--space-6) 0; }
.gate-context { text-align: left; margin-bottom: var(--space-4); }

.step {
  display: flex;
  gap: var(--space-3);
  align-items: flex-start;
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  background: var(--surface);
}
/* Fixed size, so the ink is lifted from the bottom by twice `--ink-lift` — see
   the token. */
.step-num {
  width: 23px;
  height: 23px;
  flex: 0 0 23px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  padding-bottom: calc(var(--ink-lift) * 2);
  font-size: var(--fs-sm);
  font-weight: var(--weight-bold);
  background: var(--surface-3);
  color: var(--text-dim);
}
.step.is-done .step-num { background: var(--ok-soft); color: var(--ok); }
.step.is-current { border-color: var(--accent); }
.step.is-current .step-num { background: var(--accent); color: var(--on-accent); }
.step-body { flex: 1; text-align: left; display: grid; gap: var(--space-1); }
.step-title { font-weight: var(--weight-strong); font-size: var(--fs-md); }
.step-sub { font-size: var(--fs-sm); color: var(--text-faint); }

/* -------------------------------------------------------------------- logo */

.logo { display: inline-flex; align-items: center; gap: var(--space-3); }
.logo svg { display: block; }
.logo-word { font-weight: var(--weight-bold); letter-spacing: 0.18em; font-size: var(--fs-base); }

/* ------------------------------------------------------------------ topbar */

.topbar {
  position: sticky;
  top: 0;
  z-index: 40;
  background: var(--bg-2);
  border-bottom: 1px solid var(--line-soft);
}
.topbar-admin { border-bottom-color: var(--line); }
.topbar-inner {
  max-width: var(--shell);
  margin: 0 auto;
  padding: 0 var(--space-5);
  height: var(--topbar-h);
  display: flex;
  align-items: center;
  gap: var(--space-5);
}
.topbar-logo { display: flex; align-items: center; gap: var(--space-2); }
.admin-tag {
  font-size: var(--fs-2xs);
  letter-spacing: 0.2em;
  font-weight: var(--weight-bold);
  background: var(--accent);
  color: var(--on-accent);
  padding: calc(var(--space-1) - var(--ink-lift)) var(--space-2) calc(var(--space-1) + var(--ink-lift));
  border-radius: var(--space-1);
}
.spacer { flex: 1; }
.nav { display: flex; gap: var(--space-1); flex: 1; }
.nav-link {
  padding: calc(var(--space-2) - var(--ink-lift)) var(--space-3) calc(var(--space-2) + var(--ink-lift));
  border-radius: var(--radius-sm);
  font-size: var(--fs-md);
  font-weight: var(--weight-strong);
  letter-spacing: var(--track-nav);
  text-transform: uppercase;
  color: var(--text-dim);
}
.nav-link:hover { background: var(--surface); color: var(--text); }
.nav-link.is-active { color: var(--text); background: var(--surface-2); box-shadow: inset 0 -2px var(--accent); }

.balance {
  display: inline-flex;
  align-items: center;
  padding: calc(var(--space-2) - var(--ink-lift)) var(--space-3) calc(var(--space-2) + var(--ink-lift));
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  background: var(--pts-dim);
  white-space: nowrap;
}
.balance:hover { border-color: var(--pts); }

.usermenu { position: relative; }
.usermenu summary { list-style: none; cursor: pointer; display: block; position: relative; }
.usermenu summary::-webkit-details-marker { display: none; }
/* Unread activity has to be legible without opening the menu: the count itself
   lives inside, but something on the bar has to change or the whole notification
   system is invisible to anyone not already looking for it. The count is on the
   summary's aria-label, so the dot is decoration and stays out of the tree. */
.usermenu.has-unread > summary::after {
  content: '';
  position: absolute;
  top: -1px;
  right: -1px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--bg-2);
}
.avatar {
  width: 30px;
  height: 30px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  object-fit: cover;
  border: 1px solid var(--line);
}
.avatar-fallback { background: var(--surface-3); font-size: var(--fs-md); font-weight: var(--weight-bold); color: var(--text-dim); }
.avatar-lg { width: 56px; height: 56px; font-size: var(--fs-lg); flex: 0 0 56px; }
.usermenu-panel {
  position: absolute;
  right: 0;
  top: calc(100% + var(--space-3));
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  min-width: 210px;
  padding: var(--space-3);
  display: grid;
  gap: var(--space-1);
  z-index: 50;
}
.usermenu-panel a, .usermenu-panel .linklike {
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  font-size: var(--fs-md);
  color: var(--text-dim);
  text-align: left;
  width: 100%;
}
.usermenu-panel a:hover, .usermenu-panel .linklike:hover { background: var(--surface-2); color: var(--text); }
.usermenu-name { padding: var(--space-1) var(--space-3); font-weight: var(--weight-strong); }
.usermenu-sub {
  padding: 0 var(--space-3) var(--space-2);
  font-size: var(--fs-sm);
  color: var(--text-faint);
  border-bottom: 1px solid var(--line-soft);
  margin-bottom: var(--space-2);
}
.navtoggle {
  display: none;
  place-items: center;
  flex: 0 0 var(--control);
  width: var(--control);
  height: var(--control);
  padding: 0;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  color: var(--text-dim);
  font-size: var(--fs-lg);
  cursor: pointer;
}
.navtoggle:hover { background: var(--surface-3); color: var(--text); }
.topbar-admin .navtoggle { display: none; }

/* --------------------------------------------------------------------- tabs */

.tabs {
  display: flex;
  gap: var(--space-1);
  flex-wrap: wrap;
  border-bottom: 1px solid var(--line-soft);
  padding-bottom: var(--space-2);
}
.tab {
  padding: calc(var(--space-2) - var(--ink-lift)) var(--space-3) calc(var(--space-2) + var(--ink-lift));
  border-radius: var(--radius-sm);
  font-size: var(--fs-md);
  font-weight: var(--weight-strong);
  color: var(--text-dim);
}
.tab:hover { background: var(--surface); color: var(--text); }
.tab.is-active { background: var(--surface-2); color: var(--text); box-shadow: inset 0 -2px var(--accent); }

/* ------------------------------------------------- badges, flashes, empties */

/* No `--ink-lift`: measured, this one is already centred, and the correction
   only tips it the other way. Its line box lands on a whole pixel where a
   `.pill` of the same padding does not. */
.badge {
  display: inline-flex;
  align-items: center;
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-pill);
  font-size: var(--fs-2xs);
  font-weight: var(--weight-bold);
  letter-spacing: 0.07em;
  text-transform: uppercase;
  border: 1px solid;
  white-space: nowrap;
}
/* A status light, not a decoration with its own colour: it takes the badge's,
   so a tone is defined once and the dot can never disagree with the word. */
.badge-dot {
  width: 6px;
  height: 6px;
  flex: 0 0 auto;
  margin-right: var(--space-1);
  border-radius: var(--radius-pill);
  background: currentColor;
}
.badge-neutral { color: var(--text-faint); border-color: var(--line); background: var(--surface-2); }
.badge-ok { color: var(--ok-text); border-color: var(--ok-border); background: var(--ok-soft); }
.badge-warn { color: var(--warn-text); border-color: var(--warn-border); background: var(--warn-soft); }
.badge-bad { color: var(--bad-text); border-color: var(--bad-border); background: var(--bad-soft); }
.badge-info { color: var(--info-text); border-color: var(--info-border); background: var(--info-soft); }
.pill {
  background: var(--accent);
  color: var(--on-accent);
  border-radius: var(--radius-pill);
  padding: calc(var(--space-1) - var(--ink-lift)) var(--space-2) calc(var(--space-1) + var(--ink-lift));
  font-size: var(--fs-xs);
  font-weight: var(--weight-bold);
}
.tag {
  font-size: var(--fs-xs);
  padding: calc(var(--space-1) - var(--ink-lift)) var(--space-2) calc(var(--space-1) + var(--ink-lift));
  border-radius: var(--radius-pill);
  background: var(--surface-2);
  border: 1px solid var(--line);
  color: var(--text-dim);
}
.taglist { display: flex; gap: var(--space-2); flex-wrap: wrap; }

/* One sentence in the tone of what it reports. Most of them belong to the page
   that holds them — a negative balance, the reason a listing came off — and sit
   in the flow like any other block. The shell's own banner adds `.flash-top`. */
.flash {
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-sm);
  font-size: var(--fs-md);
  border: 1px solid;
  transition: opacity var(--dur-slow) ease;
}
/* Set by app.js on its way out; the element is removed once the fade is over. */
.flash.is-going { opacity: 0; }
.flash-ok { background: var(--ok-soft); border-color: var(--ok-border); color: var(--ok-text); }
.flash-warn { background: var(--warn-soft); border-color: var(--warn-border); color: var(--warn-text); }
.flash-bad { background: var(--bad-soft); border-color: var(--bad-border); color: var(--bad-text); }

/* What the last action did, laid over the page rather than inserted into it. The
   page itself is the answer and the banner only names it, so it must not push the
   page down on its way in nor pull it back up five seconds later: the row that
   was changed would move under the pointer that changed it. Pinned to the top
   right, clear of the bar, and as wide as it has to be.
   z-index 30 leaves it over the page and under the two things a person opens on
   purpose — the account menu, and the phone drawer, which paints inside the bar. */
.flash-top {
  position: fixed;
  top: calc(var(--topbar-h) + var(--space-4));
  right: var(--space-5);
  z-index: 30;
  width: max-content;
  max-width: calc(100% - 2 * var(--space-5));
  box-shadow: var(--shadow);
}
/* The solid tone, not the wash: a wash is a hole and shows what is behind it,
   which for this one is the page and not a panel. */
.flash-top.flash-ok { background: var(--ok-solid); }
.flash-top.flash-warn { background: var(--warn-solid); }
.flash-top.flash-bad { background: var(--bad-solid); }

.empty { padding: var(--space-8) var(--space-5); text-align: center; border: 1px dashed var(--line); border-radius: var(--radius); }
.empty-title { font-weight: var(--weight-strong); color: var(--text-dim); }
.empty-sub { font-size: var(--fs-md); color: var(--text-faint); margin-top: var(--space-1); }

.notecard {
  padding: var(--space-3) var(--space-4);
  background: var(--bg-2);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
  font-size: var(--fs-md);
  color: var(--text-dim);
  line-height: var(--lh-prose);
}
.notecard > * + * { margin-top: var(--space-2); }
.notecard strong { color: var(--text); }

.devhint {
  padding: var(--space-3) var(--space-4);
  border: 1px dashed var(--line);
  border-radius: var(--radius-sm);
  background: var(--info-soft);
  font-size: var(--fs-sm);
  color: var(--text-dim);
  text-align: left;
}
.devhint b { color: var(--info); letter-spacing: var(--track-nav); font-size: var(--fs-xs); text-transform: uppercase; }
/* A developer control standing on a page an owner uses. It keeps the dashed box
   so it never reads as part of the flow, and stacks its label, its warning and
   its button so none of the three can be skimmed past. */
.devtool { display: grid; gap: var(--space-2); justify-items: start; }
.devhint code { color: var(--text); background: var(--bg-2); padding: var(--space-1) var(--space-2); border-radius: var(--space-1); }

/* --------------------------------------------------------------- storefront */

.filters {
  display: flex;
  gap: var(--space-2);
  flex-wrap: wrap;
  align-items: flex-end;
  background: var(--surface);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  padding: var(--space-3);
}
.filters .field { min-width: 136px; }
.filters .field:first-child { flex: 1 1 220px; }
.filters > .field { flex: 1 1 160px; }
.filters > .btn, .filters > .inline-form { flex: 0 0 auto; }

.gamegrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--card-min), 1fr)); gap: var(--space-4); }

/* One frame for both grids. A key on the shelf and a listing on the market are
   the same act — shopping for a game by its artwork — so they are the same
   card, and only what sits on and under the art differs. */
.gamecard, .keycard {
  position: relative;
  display: flex;
  flex-direction: column;
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--surface);
  border: 1px solid var(--line-soft);
  transition: transform 0.14s ease, border-color 0.14s ease, box-shadow 0.14s ease;
}
.gamecard.is-shortlisted { border-color: var(--accent); }
.gamecard:hover, .keycard:hover { transform: translateY(-4px); border-color: var(--surface-3); box-shadow: var(--shadow); }
.gamecard-cover, .keycard-cover {
  position: relative;
  display: block;
  aspect-ratio: 600 / 900;
  background: var(--surface-2);
  overflow: hidden;
  flex: 0 0 auto;
}
.gamecard-cover img, .keycard-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.gamecard-cover .fallback, .gamehero-cover .fallback, .keycard-cover .fallback {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: var(--space-4);
  text-align: center;
  font-weight: var(--weight-bold);
  font-size: var(--fs-base);
  color: var(--text-dim);
  letter-spacing: 0.02em;
}
/* A title standing in for artwork has to survive a name with no spaces in it. */
.gamecard-cover .fallback, .keycard-cover .fallback { overflow-wrap: anywhere; }
/* What a card is worth, on the art: what the listing pays, what the key costs,
   or how many members are waiting for it. Same pill, same corner, so the number
   a card is judged by is found in one place on all three. */
.gamecard-reward, .keycard-cost, .keycard-demand {
  position: absolute;
  left: var(--space-3);
  bottom: var(--space-3);
  background: var(--overlay);
  backdrop-filter: blur(6px);
  border: 1px solid var(--pts-border);
  padding: calc(var(--space-2) - var(--ink-lift)) var(--space-3) calc(var(--space-2) + var(--ink-lift));
  border-radius: var(--radius-pill);
  font-size: var(--fs-md);
}
.gamecard-body { padding: var(--space-3); flex: 1; display: flex; flex-direction: column; }
.gamecard-name, .keycard-name {
  font-weight: var(--weight-strong);
  font-size: var(--fs-md);
  line-height: 1.3;
}
/* A badge is taller than the bare genre text beside it, and a flex row stretches
   its items by default — which is what left the two sitting on different lines
   through the middle. Centring the row is what puts them on one. */
.gamecard-meta {
  margin-top: var(--space-2);
  font-size: var(--fs-xs);
  color: var(--text-faint);
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
}
.gamecard-progress { margin-top: var(--space-2); }
.gamecard-step {
  margin-top: var(--space-1);
  font-size: var(--fs-xs);
  color: var(--text-faint);
}

/* The whole card is the link, laid over the content, so the shortlist form can
   sit above it — a form cannot be nested inside an anchor. */
.gamecard-hit { position: absolute; inset: 0; z-index: 1; border-radius: var(--radius); }
.gamecard-hit:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.gamecard-star { position: absolute; top: var(--space-2); right: var(--space-2); z-index: 2; display: block; }
.starbtn {
  width: 30px;
  height: 30px;
  padding: 0;
  line-height: 1;
  font-size: var(--fs-base);
  cursor: pointer;
  border-radius: var(--radius-pill);
  border: 1px solid var(--overlay-border);
  background: var(--overlay-quiet);
  color: var(--text-faint);
  backdrop-filter: blur(6px);
  transition: color 0.12s ease, border-color 0.12s ease, transform 0.12s ease;
}
.starbtn:hover { color: var(--accent); border-color: var(--accent); transform: scale(1.08); }
.starbtn.is-on { color: var(--accent); border-color: var(--accent); }
.starbtn.is-static { cursor: default; }

/* The shelf browses at the same width as the market: the two grids are the same
   card, so a member moving between the tabs is not asked to relearn the page.
   The frame, the cover and the name are shared with `.gamecard` above. */
.keygrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--card-min), 1fr)); gap: var(--space-4); }
/* The cover is a link out to Steam, so it needs a focus ring of its own — it is
   the only control on the card that is not a button. */
.keycard-cover:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.keycard-body { padding: var(--space-3); flex: 1; display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
/* The quietest line on the card: a count on the left and, where there is one, a
   second fact on the right. */
.keycard-meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2);
  font-size: var(--fs-xs);
  color: var(--text-faint);
  min-width: 0;
}
.keycard-stock { flex: 0 0 auto; font-variant-numeric: tabular-nums; }
.keycard-foot { margin-top: auto; }
/* Not affording a key is a state of the whole card, not a note beside the
   button: a disabled button on its own read as broken rather than as too
   expensive. The art greys out and the frame takes a red hairline; the button
   below already says why, so nothing is written across the artwork. */
.keycard.is-locked { border-color: var(--bad-border); }
.keycard.is-locked:hover { border-color: var(--bad); }
.keycard.is-locked .keycard-cover img { filter: grayscale(1); opacity: 0.4; }
.keycard.is-locked .keycard-cover .fallback { opacity: 0.4; }
.keycard.is-locked .keycard-cost { border-color: var(--bad-border); }

/* A request is the same card as a key, because it is the same act: reading a
   wall of games and picking one. Only what sits on and under the art differs. */
/* A count of members is not an amount of points, so the pill drops the points
   hairline for the plain overlay one, and it carries the whole sentence rather
   than a bare number — "7" on a picture is not a fact anybody can read. It wraps
   instead of stretching, so a long count cannot run off the artwork. */
.keycard-demand {
  border-color: var(--overlay-border);
  max-width: calc(100% - var(--space-6));
  font-size: var(--fs-sm);
  line-height: var(--lh-body);
  color: var(--text);
}
/* The one thing that outranks the count: the shelf can answer this request now.
   Top-left, clear of the pill, on the corner of the art nothing else uses. */
.keycard-flag { position: absolute; top: var(--space-2); left: var(--space-2); }
/* When it was last asked for, and which game it is. Same two-ended line as the
   shelf's stock and donor: the left fact is fixed and the right one wraps. */
.keycard-asked { flex: 0 0 auto; }
.keycard-appid {
  min-width: 0;
  text-align: right;
  font-variant-numeric: tabular-nums;
}
/* Up to two: redeem it, and add or withdraw your voice. Full width and stacked,
   because a card is too narrow to put two buttons beside each other. */
.keycard-acts { margin-top: auto; display: grid; gap: var(--space-2); }
/* Your own voice on the board reads like your own shortlisted card. A request
   the shelf can already answer outranks it — the green frame is the one that
   means there is something to do. */
.keycard.is-mine { border-color: var(--accent); }
.keycard.is-here { border-color: var(--ok-border); }

/* The listing, said once: artwork, what it is, what it pays, what to press.
   One surface rather than a panel inside a hero inside a page — the reward and
   the action sit on the page itself, so nothing is boxed twice. */
/* The artwork owns its 180px column. Making that column a ceiling around an
   image fitted to the shorter copy left an invisible strip between the visible
   poster edge and the documented gap. */
.gamehero { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: var(--space-6); align-items: start; }
/* The owner's side of the same listing says less: no description, no genres,
   nothing to buy — a backlink, a title, two badges and a line of provenance.
   A portrait cover is one and a half times as tall as it is wide, so at full
   size it holds the header open for a third of a screen of nothing while the
   work waits underneath it. Here the artwork is a thumbnail sized to the text
   beside it: it says which game this is, and the page starts.
   `align-self: start` because a grid item with `height: auto` is stretched to
   the row, and a stretched cover would take the height back and crop the art
   to fill it. */
.gamehero.is-compact { grid-template-columns: 96px minmax(0, 1fr); gap: var(--space-4); }
.gamehero.is-compact .gamehero-cover { align-self: start; }
/* The portrait frame sets the row at the storefront artwork's own proportion.
   The copy column stretches to that height, keeping the identity at the top and
   its reward/action row against the lower edge. */
.gamehero-cover {
  position: relative;
  width: 100%;
  aspect-ratio: 600 / 900;
  overflow: hidden;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background: var(--surface-2);
}
/* Every verified cover fills that stable frame. `object-fit` protects a source
   image whose crop is a pixel or two away from the expected ratio without
   distorting it. */
.gamehero-cover img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
/* The title fallback occupies the exact same frame as artwork. */
.gamehero-cover .fallback {
  background: var(--surface-2);
}
.gamehero-main {
  align-self: stretch;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  min-width: 0;
}
.gamehero-top { display: flex; align-items: center; gap: var(--space-3); }
/* The way back out of a detail page. Quiet until pointed at — it is a way out,
   not an invitation to leave. */
.backlink {
  font-size: var(--fs-sm);
  color: var(--text-faint);
  text-decoration: none;
}
.backlink:hover { color: var(--text-dim); }
.gamehero-id { display: grid; gap: var(--space-3); }
.gamehero-tools { display: flex; align-items: center; gap: var(--space-2); }
.gamehero-title { margin: 0; }
.gamehero-desc { margin: 0; color: var(--text-dim); line-height: var(--lh-prose); }
/* Provenance, not headline: one dim line, the parts told apart by a separator
   rather than by four different weights. */
.gamehero-meta { display: flex; flex-wrap: wrap; gap: var(--space-2); font-size: var(--fs-sm); color: var(--text-faint); }
.gamehero-meta > * + *::before { content: '·'; margin-right: var(--space-2); }
/* The last line of the hero: what the listing pays, the two tools that belong
   to it — where it lives on Steam, and the bookmark — and where the listing
   stands. The marks are beside the fact, not actions the page is for; the one
   action the page is for is the first step of the checklist below. Bottom
   rather than under the title because it is what the reader should be left
   holding, and it is what the artwork's lower edge now lines up with. */
.gamehero-cta {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  width: 100%;
  margin-top: auto;
}
/* The state is a fact about that same reward, so it joins its line instead of
   taking a band of its own above it — stacked, it pushed the row it describes a
   block further down and the hero read as two announcements about one number.
   It asks for a measure and takes whatever the reward and the two marks leave;
   below the width where a sentence can sit beside them it wraps to its own line
   and gets all of it, rather than being squeezed into a column of two words. */
.gamehero-cta > .herostate { flex: 1 1 var(--measure); min-width: 0; }
/* One band the height of a control, not a card: label and number on the same
   line so the reward is as tall as the two squares beside it and the three read
   as one row. A stacked label over a big number was a third of the hero's height
   for six characters of information. */
.gamehero-reward {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-3);
  min-height: var(--control);
  padding: calc(var(--space-2) - var(--ink-lift)) var(--space-4) calc(var(--space-2) + var(--ink-lift));
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
}
/* The band centres each of these against itself, so they only read as one line
   if each is centred in the same place. Both therefore state a line-height that
   lands on a whole pixel — 10px of type at 1.2 is 12, 16px at 1.25 is 20 —
   because a line box on a fraction is rounded somewhere the other one is not,
   and the label and the number then sit a pixel apart inside a band that
   measures as correct. */
.gamehero-reward-label {
  font-size: var(--fs-2xs);
  line-height: var(--lh-tight);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-faint);
}
.gamehero-reward-value { font-size: var(--fs-base); font-weight: var(--weight-bold); line-height: var(--lh-figure); }

/* A square holding one drawn mark, for an action whose target is the thing it
   sits beside and needs no word repeating it. Sized and coloured like a button
   but not one of its variants: `.btn` changes colour only, never shape. */
.iconbtn {
  display: grid;
  place-items: center;
  flex: 0 0 auto;
  width: var(--control);
  height: var(--control);
  padding: 0;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--text-dim);
  cursor: pointer;
  transition: color var(--dur), border-color var(--dur), background var(--dur);
}
.iconbtn:hover { color: var(--text); border-color: var(--surface-3); background: var(--surface-3); }
.iconbtn svg { display: block; width: var(--space-5); height: var(--space-5); }
/* On is a filled star in the accent, so the state reads from the mark itself and
   not only from the border around it. */
.iconbtn.is-on { color: var(--accent); border-color: var(--accent); }

/* One sentence about where this listing stands, tinted by role, and whatever
   undoes it. A band at `--control`, the same height as the reward beside it and
   the two marks beside that: it is one item on that row and a row has one
   height.
   The band therefore has no vertical padding of its own to give. A padded band
   is its tallest child plus that padding, so 8px around a 32px button is 50px
   standing next to 40px neighbours, and no `align-items` hides which box is the
   odd one. Height comes from `min-height` instead, which a child does not add
   to: a line of type is 18px and `.btn-sm` is 32, both under the 38 the band
   has to give, so the band measures exactly `--control` either way. The breathing
   room a wrapped sentence needs is on the sentence — see `.herostate-text` — so
   it arrives only when there is a second line to need it. */
.herostate {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin: 0;
  min-height: var(--control);
  padding: 0 var(--space-4);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
  background: var(--surface);
  font-size: var(--fs-sm);
  color: var(--text-dim);
  line-height: var(--lh-body);
}
.herostate-mark { flex: 0 0 auto; }
/* The band's padding, moved onto the thing that is allowed to be more than one
   line. At one line it costs the band nothing — 18 and 16 is 34, still under
   `--control` — and at two it is the clearance that stops the words touching the
   border the band drew. Split by `--ink-lift` for the same reason every other
   band here splits it. */
.herostate-text {
  margin: 0;
  min-width: 0;
  flex: 1 1 auto;
  padding-block: calc(var(--space-2) - var(--ink-lift)) calc(var(--space-2) + var(--ink-lift));
}
/* Undoing the state the sentence describes — handing a reservation back — sits
   inside the band with the sentence, because there is nowhere else it belongs,
   and at the far end of it. */
.herostate-action { flex: 0 0 auto; margin-left: auto; }
.herostate.is-ok { border-color: var(--ok-border); background: var(--ok-soft); color: var(--ok-text); }
.herostate.is-warn { border-color: var(--warn-border); background: var(--warn-soft); color: var(--warn-text); }
.herostate.is-info { border-color: var(--info-border); background: var(--info-soft); color: var(--info-text); }

/* A row in a list of the member's own things: cover, detail, worth, actions.
   The whole row is the link, so it is a card that reacts to the pointer rather
   than a line of text with one word in it that does. */
.listrow {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-3);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  background: var(--surface-2);
  transition: border-color var(--dur), background var(--dur);
}
.listrow:hover { border-color: var(--surface-3); background: var(--surface-3); }
.listrow-hit { position: absolute; inset: 0; z-index: 1; border-radius: var(--radius); }
.listrow-hit:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.listrow-cover {
  width: 52px;
  flex: 0 0 52px;
  aspect-ratio: 600 / 900;
  object-fit: cover;
  border-radius: var(--radius-sm);
  background: var(--surface-3);
  display: block;
}
.listrow-main { flex: 1; min-width: 0; }
.listrow-name {
  display: block;
  font-weight: var(--weight-strong);
  font-size: var(--fs-md);
}
.listrow:hover .listrow-name { color: var(--accent); }
.listrow-meta {
  margin-top: var(--space-1);
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  font-size: var(--fs-xs);
}
/* What the listing is doing, right-aligned against the actions beside it. */
.listrow-note {
  flex: 0 0 auto;
  text-align: right;
  font-size: var(--fs-xs);
  color: var(--text-faint);
}
/* Above the whole-row link, or the buttons would not be clickable. */
.listrow-actions {
  position: relative;
  z-index: 2;
  display: flex;
  gap: var(--space-2);
  align-items: center;
  flex-wrap: wrap;
  justify-content: flex-end;
}

/* -------------------------------------------------------- progress & checks */

.progressbar { height: 4px; border-radius: var(--radius-pill); background: var(--surface-3); overflow: hidden; }
.progressbar-fill {
  display: block;
  height: 100%;
  border-radius: var(--radius-pill);
  background: var(--accent);
  transition: width var(--dur-slow) ease;
}
/* Widths are classes, not inline styles — the CSP allows no style attribute.
   Five per cent apart rather than a fifth: a bar out of four steps has to be
   able to say a quarter, and one out of three a third, without either landing
   on a mark that means something else. */
.progressbar-fill.is-0 { width: 0; }
.progressbar-fill.is-5 { width: 5%; }
.progressbar-fill.is-10 { width: 10%; }
.progressbar-fill.is-15 { width: 15%; }
.progressbar-fill.is-20 { width: 20%; }
.progressbar-fill.is-25 { width: 25%; }
.progressbar-fill.is-30 { width: 30%; }
.progressbar-fill.is-35 { width: 35%; }
.progressbar-fill.is-40 { width: 40%; }
.progressbar-fill.is-45 { width: 45%; }
.progressbar-fill.is-50 { width: 50%; }
.progressbar-fill.is-55 { width: 55%; }
.progressbar-fill.is-60 { width: 60%; }
.progressbar-fill.is-65 { width: 65%; }
.progressbar-fill.is-70 { width: 70%; }
.progressbar-fill.is-75 { width: 75%; }
.progressbar-fill.is-80 { width: 80%; }
.progressbar-fill.is-85 { width: 85%; }
.progressbar-fill.is-90 { width: 90%; }
.progressbar-fill.is-95 { width: 95%; }
.progressbar-fill.is-100 { width: 100%; }

/* The whole step, when the step is "wait": one block, one line, at most one way out. */
.bigwait {
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  background: var(--surface);
  padding: var(--space-7) var(--space-5) var(--space-6);
  text-align: center;
  overflow: hidden;
  position: relative;
}
.bigwait > * + * { margin-top: var(--space-3); }
/* The bar rides the top edge, so it clears the padding and the radius. */
.bigwait-bar {
  position: absolute;
  inset: 0 0 auto;
  margin-top: 0;
  height: 3px;
  background: var(--surface-3);
  overflow: hidden;
}
.bigwait-sweep {
  display: block;
  width: 40%;
  height: 100%;
  background: var(--accent);
  animation: bigwait-sweep 1.4s ease-in-out infinite;
}
@keyframes bigwait-sweep {
  from { transform: translateX(-100%); }
  to { transform: translateX(350%); }
}
.bigwait-title { font-size: var(--fs-lg); line-height: var(--lh-tight); font-weight: var(--weight-strong); }
.bigwait-detail { font-size: var(--fs-md); color: var(--text-dim); font-variant-numeric: tabular-nums; }
.bigwait-note { font-size: var(--fs-sm); color: var(--text-faint); }
/* The bar is out of flow and the title leads, so neither takes the block rhythm. */
.bigwait > .bigwait-bar,
.bigwait > .bigwait-title { margin-top: 0; }
.bigwait > .bigwait-action { margin-top: var(--space-5); }

.checklist-count { font-size: var(--fs-sm); color: var(--text-dim); white-space: nowrap; font-variant-numeric: tabular-nums; }
.checklist { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-1); }
.checklist-item {
  display: grid;
  grid-template-columns: 26px 1fr auto;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3);
  border-radius: var(--radius-sm);
  border: 1px solid var(--line-soft);
  background: var(--surface-2);
}
.checklist-item + .checklist-item { border-top: 1px solid var(--line-soft); border-radius: 0; }
/* The one box that leans the other way. 11px of type in a ring 18px across the
   inside sits a quarter-pixel high to begin with, so this is the lift reversed:
   padding at the top, and a single one of it rather than two, because padding
   on one edge of a fixed box moves the content half as far. Lifting it — what
   the boxes that are grown from their padding do — drives it three quarters of
   a pixel out of the ring. */
.checklist-mark {
  width: 20px;
  height: 20px;
  margin-top: 0;
  border-radius: var(--radius-pill);
  display: grid;
  place-items: center;
  padding-top: var(--ink-lift);
  font-size: var(--fs-xs);
  border: 1px solid var(--line);
  color: var(--text-faint);
  background: var(--surface-2);
}
.checklist-item.is-done .checklist-mark { color: var(--on-ok); background: var(--ok); border-color: transparent; }
.checklist-item.is-waiting .checklist-mark { color: var(--on-warn); background: var(--warn); border-color: transparent; }
/* Done, and not counting. The mark is the only one that carries the bad role:
   the step is finished work that earns nothing as it stands, so it must not be
   dimmed like a step nobody has reached and must not be ticked like one that
   paid. The detail line beside it says what would change it. */
.checklist-item.is-attention .checklist-mark { color: var(--on-bad); background: var(--bad); border-color: transparent; }
.checklist-item.is-blocked { opacity: 0.55; }
/* Its turn has not come: the step above it is still open. Same dimming as a
   step that does not apply, because the answer to both is "not this one, yet". */
.checklist-item.is-locked { opacity: 0.55; }
.checklist-body { min-width: 0; }
.checklist-label { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; font-weight: var(--weight-strong); font-size: var(--fs-md); }
.checklist-item.is-done .checklist-label { color: var(--text-dim); }
/* One line, and only ever one: the standing sentence when nothing has been
   checked, what the check found once something has. Never both, and never a
   timestamp under them — the button carries how long the cooldown has left. */
.checklist-help { margin-top: var(--space-1); font-size: var(--fs-sm); color: var(--text-faint); }
.checklist-detail { margin-top: var(--space-1); font-size: var(--fs-sm); color: var(--text-dim); }
.checklist-action { display: flex; align-items: center; }

/* ------------------------------------------------------------ tables & data */

.tablewrap { max-width: 100%; overflow-x: auto; border: 1px solid var(--line-soft); border-radius: var(--radius); }
.table { width: 100%; border-collapse: collapse; font-size: var(--fs-md); min-width: 560px; }
.table th {
  text-align: left;
  padding: var(--space-2) var(--space-3);
  background: var(--surface-2);
  font-size: var(--fs-xs);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-faint);
  font-weight: var(--weight-bold);
  border-bottom: 1px solid var(--line);
}
.table td { padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
.table tbody tr:last-child td { border-bottom: 0; }
.table tbody tr:hover { background: var(--row-hover); }
.table .actions { display: flex; gap: var(--space-2); flex-wrap: nowrap; justify-content: flex-end; }

.stat { background: var(--surface); border: 1px solid var(--line-soft); border-radius: var(--radius); padding: var(--space-3); }
.stat-value { font-size: var(--fs-xl); font-weight: var(--weight-bold); letter-spacing: var(--track-tight); font-variant-numeric: tabular-nums; }
.stat-label {
  font-size: var(--fs-xs);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-faint);
  margin-top: var(--space-1);
  font-weight: var(--weight-strong);
}
.stat-sub { font-size: var(--fs-sm); color: var(--text-dim); margin-top: var(--space-2); }

/* Key/value detail list. `.detail`/`.detail-grid` are the same component under
   their older names. */
.kv, .detail { margin: 0; display: grid; gap: var(--space-3); }
.kv-cols, .detail-grid { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
.kv dt, .detail dt {
  font-size: var(--fs-xs);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-faint);
  font-weight: var(--weight-bold);
}
.kv dd, .detail dd { margin: var(--space-1) 0 0; font-size: var(--fs-md); overflow-wrap: anywhere; }

/* The same pairs as `.kv`, but each one boxed. Use it where the values are short
   and want reading one at a time — an ID, a price, a date — rather than scanned
   as a list. */
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(var(--fact-min), 1fr)); gap: var(--space-3); }
.fact {
  background: var(--surface-2);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
  padding: var(--space-3) var(--space-4);
}
.fact-label {
  font-size: var(--fs-xs);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-faint);
  font-weight: var(--weight-bold);
}
.fact-value {
  margin-top: var(--space-1);
  font-size: var(--fs-base);
  font-weight: var(--weight-strong);
  font-variant-numeric: tabular-nums;
  overflow-wrap: anywhere;
}

/* Dashboard queue shortcuts reuse the fact shape but behave as full-card links. */
.admin-task { display: block; color: var(--text); }
.admin-task:hover { background: var(--surface-3); border-color: var(--accent); }

.ledgerrow { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-3) 0; border-bottom: 1px solid var(--line-soft); }
.ledgerrow > :first-child { min-width: 0; }
.ledgerrow > :last-child { flex: 0 0 auto; white-space: nowrap; }
.ledgerrow:last-child { border-bottom: 0; }

/* Three levels, said once each: what the game is, who wants it, why. Anything
   flatter than this and the count reads as loudly as the title, which is the
   one thing the board exists to rank by. */
.wantrow-main { display: grid; gap: var(--space-1); min-width: 0; }
/* The badge is a fixed part of the line; only the name gives way, so a long
   title cannot push the one word that says where the request stands off the
   end of the row. */
.wantrow-title { display: flex; align-items: center; gap: var(--space-2); min-width: 0; font-weight: var(--weight-strong); }
.wantrow-name { min-width: 0; }
.wantrow-title > .badge { flex: 0 0 auto; }
.wantrow-meta { font-size: var(--fs-sm); color: var(--text-dim); font-variant-numeric: tabular-nums; }

/* The ask, on the shelf, as one line. It says what the shelf is missing; every
   control for it lives on the board the chips link to. */
.wantstrip {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2);
  padding: var(--space-3);
  background: var(--bg-2);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  font-size: var(--fs-sm);
}
.wantstrip-label {
  flex: 0 0 auto;
  font-size: var(--fs-xs);
  font-weight: var(--weight-strong);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-faint);
}
.wantchip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  max-width: 100%;
  min-width: 0;
  padding: var(--space-1) var(--space-3);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  color: var(--text-dim);
  text-decoration: none;
}
.wantchip:hover { border-color: var(--accent); color: var(--text); }
.wantchip-name { min-width: 0; }
.wantchip-count { flex: 0 0 auto; color: var(--text-faint); font-variant-numeric: tabular-nums; }
.wantstrip-more { margin-left: auto; flex: 0 0 auto; color: var(--text-faint); white-space: nowrap; }
.wantrow-note { font-size: var(--fs-sm); color: var(--text-faint); line-height: var(--lh-body); }
.ledger-total {
  display: flex;
  justify-content: space-between;
  padding-top: var(--space-3);
  margin-top: var(--space-2);
  border-top: 2px solid var(--line);
  font-weight: var(--weight-bold);
}

.keyreveal {
  font-family: var(--mono);
  font-size: var(--fs-lg);
  letter-spacing: 0.09em;
  background: var(--bg-2);
  border: 1px dashed var(--pts);
  color: var(--pts);
  padding: var(--space-4);
  border-radius: var(--radius);
  text-align: center;
  user-select: all;
  word-break: break-all;
}
.token {
  font-family: var(--mono);
  font-size: var(--fs-lg);
  letter-spacing: 0.14em;
  font-weight: var(--weight-bold);
  color: var(--accent);
  background: var(--bg-2);
  border: 1px dashed var(--accent);
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius);
  display: inline-block;
  max-width: 100%;
  overflow-wrap: anywhere;
  user-select: all;
}

/* Ownership check: the store description before and after the challenge
   sentence. Both columns scroll independently — a store description can be
   thousands of words and must not stretch the page. */
.descdiff { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
.descdiff-col { min-width: 0; }
.descdiff-head {
  font-size: var(--fs-xs);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-faint);
  margin-bottom: var(--space-2);
}
.descbody {
  margin: 0;
  max-height: 320px;
  overflow: auto;
  padding: var(--space-3) var(--space-4);
  background: var(--bg-2);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
  color: var(--text-dim);
  font-family: var(--font);
  font-size: var(--fs-md);
  line-height: var(--lh-prose);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
/* Neither of these holds store text, so they drop the `pre-wrap` they inherit —
   otherwise the indentation of the markup itself lands on the page. */
.descbody-error { white-space: normal; display: grid; gap: var(--space-3); align-content: start; }
.descbody-error p { margin: 0; }
.descbody-error strong { color: var(--bad-text); }
.descbody mark {
  background: var(--accent-soft);
  color: var(--text);
  border-bottom: 1px solid var(--accent);
  border-radius: var(--space-1);
  padding: 0 var(--space-1);
}
/* The sentence on its way out, in the left-hand column. Struck rather than
   merely tinted, so the two columns read as a replacement and not as two
   highlights that happen to differ. */
.descbody mark.was {
  background: var(--warn-soft);
  border-bottom-color: var(--warn);
  color: var(--text-dim);
  text-decoration: line-through;
  text-decoration-color: var(--warn);
}

/* The ownership check as one line: four steps, two ours and two theirs. A rail,
   not a card — it describes the block below it rather than containing anything,
   so it sits on the page background with a hairline and no surface of its own.
   Wrapping is expected on a narrow screen and needs no breakpoint. */
.stepper {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-4);
  padding: var(--space-3) var(--space-4);
  list-style: none;
  background: var(--bg-2);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
}
.stepper-step { display: flex; align-items: center; gap: var(--space-2); font-size: var(--fs-sm); min-width: 0; }
.stepper-label { min-width: 0; }
.stepper-step + .stepper-step::before {
  content: '›';
  margin-right: var(--space-2);
  color: var(--line);
}
/* No `--ink-lift`: 10px of type in a 20px circle is already centred, and the
   correction only tips it the other way. Compare `.step-num`, a pixel wider
   with a pixel more type, which does need it. */
.stepper-num {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  border-radius: var(--radius-pill);
  border: 1px solid var(--line);
  background: var(--surface-2);
  color: var(--text-faint);
  font-size: var(--fs-2xs);
  font-weight: var(--weight-bold);
  font-variant-numeric: tabular-nums;
}
/* Three states, and never by colour alone: done carries a tick, the current one
   is the only bold thing on the rail, and each is named for a screen reader. */
.stepper-step.is-done { color: var(--text-dim); }
.stepper-step.is-done .stepper-num { border-color: var(--ok-border); background: var(--ok-soft); color: var(--ok-text); }
.stepper-step.is-now { color: var(--text); font-weight: var(--weight-strong); }
.stepper-step.is-now .stepper-num { border-color: var(--accent); background: var(--accent); color: var(--on-accent); }
.stepper-step.is-todo { color: var(--text-faint); }

/* The sentence is the instruction, so it is the largest thing in the panel and
   the descriptions it came from are folded away under it. Two boxes when there
   is a line to replace, one when there is not — the grid does not reserve a
   column for a pane that is not there. */
.sentencepair { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--space-4); }
/* Capped, so the additive case — one box, no line to replace — does not put a
   single sentence gets a column of its own. */
.sentencebox { display: grid; gap: var(--space-2); align-content: start; min-width: 0; }
/* The label and the one action on this box share a row, both from the left edge:
   the button belongs to the sentence under it, and pushing it to the far side of
   a column that is only a sentence wide reads as belonging to neither. Held at
   control height so the two boxes' sentences still start on the same line. */
.sentencebox-head {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-height: var(--control-sm);
}
.sentencebox-label {
  font-size: var(--fs-xs);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-faint);
}
.sentencebox-text {
  margin: 0;
  padding: var(--space-3) var(--space-4);
  background: var(--bg-2);
  border: 1px solid var(--line-soft);
  border-left: 3px solid var(--line);
  border-radius: var(--radius-sm);
  color: var(--text);
  line-height: var(--lh-prose);
  overflow-wrap: anywhere;
}
/* Amber on the way out, orange on the way in — the same two tones the diff below
   uses on the same two sentences, so the pair and the context agree. */
.sentencebox-text.is-was {
  border-left-color: var(--warn);
  color: var(--text-dim);
  text-decoration: line-through;
  text-decoration-color: var(--warn);
}
.sentencebox-text.is-new { border-left-color: var(--accent); }

/* Evidence, one disclosure down. The rule above it is what separates the ask
   from the proof without adding another box around either. */
.context { border-top: 1px solid var(--line-soft); padding-top: var(--space-3); }
.context-summary {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  cursor: pointer;
  font-size: var(--fs-sm);
  color: var(--text-dim);
  list-style: none;
}
.context-summary::-webkit-details-marker { display: none; }
.context-summary::before { content: '▸'; color: var(--text-faint); }
.context[open] .context-summary::before { content: '▾'; }
.context-summary:hover { color: var(--text); }
.context-summary:focus-visible { outline: none; box-shadow: var(--ring); border-radius: var(--radius-sm); }
.context-body { margin-top: var(--space-3); }

/* The one action on the page and the sentence that says what pressing it does.
   Not `.form-actions`: that right-aligns a Cancel/Save pair, and this is the end
   of an instruction — the button leads and the explanation trails it. */
.publishbar { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
/* Uncapped, unlike prose: this is one aside beside the button it qualifies, and
   it takes what the row has left and drops to its own full-width line before it
   wraps. */
.publishbar-note {
  flex: 1 1 auto;
  min-width: 0;
  font-size: var(--fs-sm);
  color: var(--text-faint);
  line-height: var(--lh-body);
}

/* The check is over and it passed. Loud enough to replace the workflow it stands
   where, quiet enough not to be a banner: one surface, one mark, no shadow. */
.donepanel {
  display: flex;
  align-items: flex-start;
  gap: var(--space-4);
  padding: var(--space-5);
  background: var(--ok-soft);
  border: 1px solid var(--ok-border);
  border-radius: var(--radius);
}
/* Fixed size, so the ink is lifted from the bottom by twice `--ink-lift` — see
   the token. */
.donepanel-mark {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  padding-bottom: calc(var(--ink-lift) * 2);
  width: 32px;
  height: 32px;
  border-radius: var(--radius-pill);
  background: var(--ok);
  color: var(--on-ok);
  font-weight: var(--weight-bold);
}
.donepanel-body { display: grid; gap: var(--space-2); min-width: 0; }
.donepanel-title {
  font-size: var(--fs-lg);
  line-height: var(--lh-tight);
  font-weight: var(--weight-strong);
  color: var(--ok-text);
}
.donepanel p { margin: 0; color: var(--text-dim); line-height: var(--lh-prose); }

/* The FAQ is the only child of `.main`, so it has to carry the page rhythm
   itself. It runs the full shell like every other page, and so does the answer
   inside it. */
.faq { min-width: 0; }
.faq > * + * { margin-top: var(--space-section); }
.faq-item { border-top: 1px solid var(--line-soft); padding-top: var(--space-4); }
.faq-item > * + * { margin-top: var(--space-3); }
.faq-item h2 {
  margin: 0;
  font-size: var(--fs-lg);
  line-height: var(--lh-tight);
  letter-spacing: var(--track-tight);
}
.faq-item p { margin: 0; color: var(--text-dim); line-height: var(--lh-prose); }

/* ------------------------------------------------------------------- admin */

.adminshell { display: grid; grid-template-columns: 232px minmax(0, 1fr); max-width: var(--shell); min-width: 0; margin: 0 auto; }
.adminnav {
  border-right: 1px solid var(--line-soft);
  padding: var(--space-4) var(--space-3);
  display: grid;
  gap: var(--space-1);
  align-content: start;
  position: sticky;
  top: var(--topbar-h);
  height: calc(100vh - var(--topbar-h));
  overflow-y: auto;
}
.adminnav-group { display: grid; gap: var(--space-1); }
.adminnav-group + .adminnav-group { margin-top: var(--space-3); }
.adminnav-heading {
  padding: var(--space-2) var(--space-3) var(--space-1);
  color: var(--text-faint);
  font-size: var(--fs-xs);
  font-weight: var(--weight-strong);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
}
.admin-search { width: min(360px, 36vw); margin-left: var(--space-5); }
.admin-search input { min-height: 34px; padding-block: var(--space-1); }
.adminnav-link {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  font-size: var(--fs-md);
  color: var(--text-dim);
}
.adminnav-link:hover { background: var(--surface); color: var(--text); }
.adminnav-link.is-active { background: var(--surface-2); color: var(--text); font-weight: var(--weight-strong); }
.pagination {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-3);
  margin-top: var(--space-4);
}

/* ------------------------------------------------- private profile notice */

/* The panel is as wide as the shell, and so is the prose inside it. */
.notice h1 { font-size: var(--fs-xl); margin-bottom: var(--space-3); }
.notice p { margin-bottom: var(--space-3); }
.notice-steps { display: grid; gap: var(--space-3); margin: var(--space-5) 0; }
.notice-step {
  display: flex;
  gap: var(--space-3);
  align-items: flex-start;
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  background: var(--bg-2);
}
.notice-step-body { flex: 1; }
.notice-step-title { display: block; font-weight: var(--weight-strong); font-size: var(--fs-md); }
.notice-step-sub { display: block; font-size: var(--fs-sm); color: var(--text-faint); margin-top: var(--space-1); }
.notice-actions { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.notice-foot { margin-top: var(--space-4); }

/* --------------------------------------------------------------- utilities */

/* The escape hatch for the cases a component cannot express. Every value is a
   step on the scale, and these come last so they win over container rhythm
   instead of adding to it. Reach for a component first. */
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: var(--space-1); }
.mt-2 { margin-top: var(--space-2); }
.mt-3 { margin-top: var(--space-3); }
.mt-4 { margin-top: var(--space-4); }
.mt-6 { margin-top: var(--space-6); }
.mb-0 { margin-bottom: 0; }
.mb-4 { margin-bottom: var(--space-4); }

.row-between { justify-content: space-between; }
.row-top { align-items: flex-start; }
.row-tight { gap: var(--space-2); }
.row-nowrap { flex-wrap: nowrap; }
.grow { flex: 1 1 auto; min-width: 0; }
.grow-wide { flex: 1 1 260px; min-width: 260px; }

.center { margin-inline: auto; }
.block { display: block; }
.right { text-align: right; }
.nowrap { white-space: nowrap; }
.pre { max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.input-compact { width: 110px; }

/* ------------------------------------------------------------------ mobile */

@media (max-width: 900px) {
  .adminshell { grid-template-columns: minmax(0, 1fr); overflow-x: clip; }
  .adminnav {
    position: static;
    height: auto;
    border-right: 0;
    border-bottom: 1px solid var(--line-soft);
    grid-auto-flow: column;
    grid-auto-columns: max-content;
    min-width: 0;
    max-width: 100%;
    overflow-x: auto;
  }
  .adminnav-link { white-space: nowrap; }
  /* The full hero drops to one column; the compact one is already narrow enough
     to stay a row on a phone, and its own two-class rule keeps it one. */
  .gamehero { grid-template-columns: 1fr; gap: var(--space-4); }
  /* One column, so there is no longer a column beside it to be the height of:
     the artwork goes back to its own proportions rather than to nothing. */
  .gamehero-cover { max-width: 200px; aspect-ratio: 600 / 900; }
  .gamehero-main { align-self: auto; }
  .descdiff { grid-template-columns: 1fr; }
  .descbody { max-height: 220px; }
  /* Original, then the replacement with its copy button, then publish — the
     order the owner performs them in, which is already the source order. */
  .sentencepair { grid-template-columns: 1fr; }
}

@media (max-width: 720px) {
  .topbar-admin .logo-word { display: none; }
  .topbar-admin .admin-search { display: none; }
  /* An open-ended `inset` heights the drawer by its contents, and a phone held
     sideways is about 390px tall: the links run past the bottom of the screen
     and, being fixed, take no part in the page's scrolling, so the last two are
     unreachable. Bounded by what is left below the bar instead, and told to
     scroll inside that when it does not fit. */
  .nav {
    position: fixed;
    inset: var(--topbar-h) 0 auto;
    max-height: calc(100dvh - var(--topbar-h));
    overflow-y: auto;
    overscroll-behavior: contain;
    flex-direction: column;
    background: var(--bg-2);
    border-bottom: 1px solid var(--line);
    padding: var(--space-3);
    display: none;
  }
  body.nav-open .nav { display: flex; }
  .navtoggle { display: grid; }
  .topbar-inner { padding-inline: var(--space-3); gap: var(--space-3); }
  .main { padding: var(--space-4) var(--space-3) var(--space-8); }
  .adminmain { padding: var(--space-4) var(--space-3) var(--space-8); }
  /* Held off the edges by the same margin the page uses at this width. */
  .flash-top {
    top: calc(var(--topbar-h) + var(--space-3));
    right: var(--space-3);
    max-width: calc(100% - 2 * var(--space-3));
  }
  .authwrap, .gate, .entry { padding: var(--space-4); }
  .authcard { padding: var(--space-5); }
  .pagehead { align-items: flex-start; }
  .pagehead-actions { flex: 1 1 100%; justify-content: flex-start; }
  .tabs {
    flex-wrap: nowrap;
    overflow-x: auto;
    padding-bottom: var(--space-2);
    scrollbar-width: none;
  }
  .tabs::-webkit-scrollbar { display: none; }
  .tab { flex: 0 0 auto; white-space: nowrap; }
  .gamegrid, .keygrid { grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); gap: var(--space-3); }
  .footer { flex-direction: column; gap: var(--space-2); }
  .listrow { flex-wrap: wrap; }
  .ledgerrow { flex-wrap: wrap; }
  .row:not(.row-nowrap) { flex-wrap: wrap; }
  .checklist-item { grid-template-columns: 26px minmax(0, 1fr); align-items: start; }
  .checklist-action { grid-column: 2; justify-content: flex-start; }
  /* The chevrons only read as a sequence while the rail is one line. Wrapped,
     they land at the start of a line pointing at nothing — so from here down the
     numbered discs carry the order on their own. */
  .stepper-step + .stepper-step::before { content: none; }
  /* The one action, at thumb width. The same treatment the game page gives its
     CTA, so the two pages do not disagree about what a primary button looks like. */
  .publishbar > form { flex: 1 1 100%; }
  .publishbar .btn { width: 100%; }
}

@media (max-width: 520px) {
  .form-actions { flex-wrap: wrap; }
  .pagehead-actions > .btn,
  .pagehead-actions > .inline-form,
  .pagehead-actions > .inline-form > .btn { flex: 1 1 auto; }
  .filters > .btn, .filters > .inline-form { flex: 1 1 auto; }
  .filters > .inline-form > .btn { width: 100%; }
  .listrow-actions { flex: 1 0 100%; justify-content: flex-start; }
}

/* A touch keyboard, not a narrow screen — so this is asked as a pointer question
   and not as a width one. Safari on iOS zooms the whole page in whenever a field
   it is focusing is set below 16px, and it does not zoom back out: the member
   taps the 2FA box on the first page they ever see, the layout jumps to about
   1.15x, and every page after that is too wide and scrolls sideways. Nothing in
   the stylesheet looks wrong, and on a desktop nothing is.

   `--fs-base` is that 16px. The line-height comes down with it so the box is the
   same height it was: 16px of type at --lh-body is 42px tall in a --control row,
   which leaves a field 2px taller than the button beside it, and at --lh-tight it
   is 37 and `min-height` holds the row at 40 exactly. Not applied to a textarea:
   it is 84px tall, it never stands beside anything, and 1.2 in a box that wraps
   is cramped. */
@media (pointer: coarse) {
  input, select, textarea { font-size: var(--fs-base); }
  input, select { line-height: var(--lh-tight); }
}

@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; animation: none !important; }
}
