/* ============================================================
   Season Chronicles Shared Styles
   Replaces cdn.tailwindcss.com. Three layers:
     1. :root custom properties
     2. Base reset (Tailwind v3 Preflight, copied from GPW)
     3. Semantic components
   Each component comment records the Tailwind utilities it
   replaces, so equivalence can be checked rather than trusted.
   ============================================================ */


/* ---------- 1. Custom properties ---------- */

:root {
  --bg: #0f172a;                     /* slate-900 */
  --surface: rgba(15, 23, 42, 0.4);  /* the glass panel fill */
  --border: rgba(255, 255, 255, 0.5);
  --ink: #ffffff;
  --ink-dim: #cbd5e1;                /* slate-300 */
  --ink-faint: #94a3b8;              /* slate-400 */
  --accent: #fbbf24;                 /* amber-400 */
  --accent-hover: #fcd34d;           /* amber-300 */
  --campaign: #4f46e5;               /* indigo-600 */
  --cat-1: #fde047;
  --cat-2: #93c5fd;
  --cat-3: #86efac;
  --cat-4: #fdba74;
  --cat-5: #cbd5e1;
  --radius: 0.75rem;                 /* rounded-xl */
  --radius-sm: 0.5rem;               /* rounded-lg */
}


/* ---------- 2. Base reset (Tailwind v3 Preflight) ----------
   Copied verbatim from Gig Pig World's Layout macro
   (417d4100-.../resources/1de3d153-....ftl). SC's markup is
   written against exactly this reset — do not substitute
   another one or pixel-identical output is lost.            */

*,::before,::after{box-sizing:border-box;border-width:0;border-style:solid;border-color:currentColor}
html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";-webkit-tap-highlight-color:transparent}
body{margin:0;line-height:inherit}
hr{height:0;color:inherit;border-top-width:1px}
abbr:where([title]){text-decoration:underline dotted}
h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}
a{color:inherit;text-decoration:inherit}
b,strong{font-weight:bolder}
code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}
small{font-size:80%}
sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}
sub{bottom:-0.25em}sup{top:-0.5em}
table{text-indent:0;border-color:inherit;border-collapse:collapse}
button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}
button,select{text-transform:none}
button,[type='button'],[type='reset'],[type='submit']{-webkit-appearance:button;background-color:transparent;background-image:none}
:-moz-focusring{outline:auto}
progress{vertical-align:baseline}
::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}
[type='search']{-webkit-appearance:textfield;outline-offset:-2px}
::-webkit-search-decoration{-webkit-appearance:none}
::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}
summary{display:list-item}
blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}
fieldset{margin:0;padding:0}legend{padding:0}
ol,ul,menu{list-style:none;margin:0;padding:0}
textarea{resize:vertical}
input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}
button,[role="button"]{cursor:pointer}
:disabled{cursor:default}
img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}
img,video{max-width:100%;height:auto}
[hidden]{display:none}


/* ---------- 3. Runtime utilities ----------
   These are applied and removed by JavaScript at runtime
   (classList.add/remove/toggle), so they can never be replaced by
   rewriting markup — the class names live in .js files and inline
   scripts. Gig Pig World keeps the same residue for the same reason.
   Removing any of these silently breaks behaviour: without .hidden,
   every error banner and dropdown renders permanently visible.
   Values are Tailwind v3 defaults. Do not "tidy" these into
   semantic names without rewriting the JS that toggles them.      */

/* !important, and it has to stay. Under Tailwind these utilities were emitted
   in the utilities layer, i.e. after every component, so .hidden beat any
   component's own display as a matter of source order. Here they sit at the
   top of one flat file, so .btn { display: flex } and 50-odd other single-class
   rules below outrank .hidden on equal specificity and it does nothing at all.
   That is what put two Continue buttons on /enter (then /startlogin). Anything JS hides is
   meant to be gone, so winning outright is the correct behaviour, not a hack.
   runner-hidden-utility.html asserts no class in this file outranks it.       */
.hidden { display: none !important; }
.invisible { visibility: hidden; }
.visible { visibility: visible; }

.opacity-0 { opacity: 0; }
.opacity-50 { opacity: 0.5; }
.opacity-60 { opacity: 0.6; }
.opacity-70 { opacity: 0.7; }
.opacity-100 { opacity: 1; }

.cursor-not-allowed { cursor: not-allowed; }
.cursor-default { cursor: default; }
.pointer-events-none { pointer-events: none; }
.overflow-hidden { overflow: hidden; }

.absolute { position: absolute; }
.top-0 { top: 0; }
.right-0 { right: 0; }
.z-20 { z-index: 20; }
.h-full { height: 100%; }
.w-1 { width: 0.25rem; }

.max-h-0 { max-height: 0; }
.max-h-32 { max-height: 8rem; }

/* transform composition — Tailwind v3 stacks translate/rotate/scale
   through custom properties so toggling one does not clobber another.
   The defaults below must stay on the universal selector for that to work. */
*, ::before, ::after {
  --tw-translate-x: 0;
  --tw-translate-y: 0;
  --tw-rotate: 0;
  --tw-skew-x: 0;
  --tw-skew-y: 0;
  --tw-scale-x: 1;
  --tw-scale-y: 1;
}

.translate-y-0,
.translate-y-2,
.-translate-y-2,
.rotate-45,
.-rotate-45,
.scale-95,
.scale-100,
.scale-110 {
  transform: translate(var(--tw-translate-x), var(--tw-translate-y))
             rotate(var(--tw-rotate))
             skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y))
             scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}

.translate-y-0 { --tw-translate-y: 0px; }
.translate-y-2 { --tw-translate-y: 0.5rem; }
.-translate-y-2 { --tw-translate-y: -0.5rem; }
.translate-y-\[-10px\] {
  --tw-translate-y: -10px;
  transform: translate(var(--tw-translate-x), var(--tw-translate-y))
             rotate(var(--tw-rotate))
             skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y))
             scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.rotate-45 { --tw-rotate: 45deg; }
.-rotate-45 { --tw-rotate: -45deg; }
.scale-95 { --tw-scale-x: 0.95; --tw-scale-y: 0.95; }
.scale-100 { --tw-scale-x: 1; --tw-scale-y: 1; }
.scale-110 { --tw-scale-x: 1.1; --tw-scale-y: 1.1; }

/* colour swaps toggled by JS */
.bg-green-500 { background-color: #22c55e; }
.bg-amber-400 { background-color: #fbbf24; }
.hover\:bg-green-500:hover { background-color: #22c55e; }
.hover\:bg-amber-300:hover { background-color: #fcd34d; }


/* ---------- 4. Semantic components ---------- */

/* page shell — was: min-h-screen bg-slate-900 text-white antialiased flex flex-col */
.app-body {
  min-height: 100vh;
  background: var(--bg);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  display: flex;
  flex-direction: column;
}

/* was: relative min-h-screen */
.app-main {
  position: relative;
  min-height: 100vh;
}

/* was: relative z-10 max-w-4xl mx-auto px-6 py-16 */
.page {
  position: relative;
  z-index: 10;
  max-width: 56rem;
  margin-left: auto;
  margin-right: auto;
  padding: 4rem 1.5rem;
}

/* was: relative text-center p-4 mb-12 */
.page-header {
  position: relative;
  text-align: center;
  padding: 1rem;
  margin-bottom: 3rem;
}

/* Every page header sits directly on the seasonal artwork - .page-bg is fixed
   and full bleed, with no scrim anywhere - and the artwork is not a neutral
   ground. Measured on the Join page over logo_4 (Summer Siege, the gold-lit
   castle): the title's ground was p99 0.741, i.e. 1.33:1, and the subtitle
   1.88:1. Andy reported both as unclear before this was measured.

   A text shadow is not enough at that brightness; on the Skirmish level
   headings even three stacked shadows only reached 2.36:1 over the same
   artwork. So the header takes the same pool of shadow the home page's chapter
   plate uses, in the same vocabulary and with the same geometry.

   The radii obey the no-clip rule - (last stop) x (radius) <= 50%, here
   0.82 x 55% = 45% - or the ellipse runs past its box and Chrome clips it into
   a straight dark bar across the artwork.

   SIDE INSETS ARE ZERO, unlike the chapter plate's -1.5rem. The plate lives
   only inside .page, whose 1.5rem padding those insets are cut for. This rule
   is global, and .page-header does NOT always sit in .page: Completed Games
   puts it straight into .app-main, which has no horizontal padding at all, so
   a negative side inset there would overhang the viewport - real horizontal
   scroll. The header is full width in its container either way, so a scrim at
   inset 0 still covers the type; only the pool's reach changes.

   THE SUBTITLE IS THE RESIDUAL, and the sweep is recorded so it is not redone.
   Being last in the header it sits at the bottom of this ellipse, in the fade,
   and at phone width it wraps to two lines. On the Join page over logo_4 it
   reaches 3.83:1 against the title's 6.98:1, and nothing legal moves it:
     - growing the insets:   3.65 at -2rem, 3.83 at -3rem, 3.98 at -4rem
     - a TALLER ellipse:     WORSE - 3.14 at 62% 70%/stop 70, 2.81 at 62% 80%
       /stop 62, because the same alpha spread over more area reaches the
       subtitle's band sooner
     - pushing the mid stop out: 4.35, but it leaves the gradient opaque at the
       box edge and Chrome clips it - edge step 0.011 -> 0.028, a visible bar
     - a second pool on .page-subtitle itself: clipped, and at 390px it moved
       the number by 0.02 because the two-line box is wider than the pool
   -3rem with these stops is the best of the legal options. If the subtitle
   ever has to clear 4.5:1 at phone width, the fix is copy or layout - one
   line instead of two, or move it into a panel - not another gradient. */
.page-header::before {
  content: "";
  position: absolute;
  top: -3rem;
  right: 0;
  bottom: -3rem;
  left: 0;
  z-index: 0;
  pointer-events: none;
  background: radial-gradient(62% 55% at 50% 50%,
                              rgba(2, 6, 23, 0.88) 0%,
                              rgba(2, 6, 23, 0.64) 46%,
                              rgba(2, 6, 23, 0) 82%);
}
.page-header > * { position: relative; z-index: 1; }

/* was: p-6 rounded-xl backdrop-blur-md bg-slate-900/40 border border-white/50 */
.panel {
  padding: 1.5rem;
  border-radius: var(--radius);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  background: var(--surface);
  border: 1px solid var(--border);
}

/* MUST sit after .panel: same specificity, so source order decides, and
   .panel's own background would otherwise win. Cost one measurement round.

   var(--surface) is 0.4 alpha, which is cut for a panel over the page
   background rather than over a bright hero. Over the seasonal artwork it
   leaves type inside it at around 3.3-3.8:1 - measured on the Join page. This
   is the fill the login card had to use for the same reason: the blur does as
   much work as the darkness, because the failure is busyness as much as
   brightness. Same material, so the two pages match. */
.panel-solid {
  background: rgba(15, 23, 42, 0.72);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
}


/* buttons — was: flex items-center justify-center gap-2 px-4 py-3 rounded-xl
   font-semibold transition shadow-md */
.btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  padding: 0.75rem 1rem;
  border-radius: var(--radius);
  font-weight: 600;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
  transition: all 0.15s ease;
  cursor: pointer;
  text-align: center;
}

/* The press, on the shared shape so every .btn responds the same way.
   Buttons had hover and nothing else, which on a touch screen is nothing at
   all. The scale is the part that works whatever colour the variant is; the
   variants darken their own fill below, since one fill cannot suit amber,
   dark glass and white alike.
   0s duration presses instantly and lets the base transition ease the release. */
.btn:active {
  transform: scale(0.97);
  transition-duration: 0s;
}

/* was: px-10 py-4 text-lg */
.btn-lg {
  padding: 1rem 2.5rem;
  font-size: 1.125rem;
}

/* was: flex-1 py-4 text-lg — the Home action pair

   Home only (.btn-flex and .icon-5 appear on no other page), so the finish
   below is scoped to this pair and the shared button system everywhere else is
   untouched.

   Slightly shorter and a firmer label than the original py-4/text-lg slab.
   Deliberately still sentence case while the season line and section headings
   went uppercase: uppercase now means "this is a label", and words you click
   stay in the register you would say them in. */
.btn-flex {
  flex: 1 1 0%;
  font-size: 1.0625rem;
  font-weight: 700;
  padding-top: 0.875rem;
  padding-bottom: 0.875rem;
}

/* was: bg-amber-400 text-black hover:bg-amber-300 */
.btn-primary {
  background: var(--accent);
  color: #000000;
}
.btn-primary:hover {
  background: var(--accent-hover);
}
/* Pressed - amber-500, a shade down from the resting amber-400. */
.btn-primary:active {
  background: #f59e0b;
}

/* was: bg-white/20 border border-white/30 text-white hover:bg-white/30

   That white/20 fill was the last of the translucent-white treatment on a
   button: sat next to the solid amber Start Game it read as half-there, and
   the seasonal artwork came through it. Now the same dark-glass secondary as
   .btn-leave in the lobby - its own fill, a light hairline, white label - so
   the pair reads as one strong action and one quieter one rather than one
   real button and one ghost. Home is the only page using it. */
.btn-secondary {
  background: rgba(15, 23, 42, 0.7);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border: 1px solid rgba(255, 255, 255, 0.35);
  color: var(--ink);
}
/* Gated, unlike the other button hovers: this one now flips dark to light, so
   a tap that left it stuck would be obvious. See .list-row. */
@media (hover: hover) {
  .btn-secondary:hover {
    background: rgba(30, 41, 59, 0.85);
    border-color: rgba(255, 255, 255, 0.55);
  }
}
/* After the hover block - equal specificity, so source order decides. */
.btn-secondary:active {
  background: rgba(2, 6, 23, 0.9);
}

.btn-danger {
  background: #e11d48;
  color: var(--ink);
}
.btn-danger:hover {
  background: #be123c;
}

/* the marketing call-to-action — was, on top of the primary/lg treatment:
   relative  text-slate-900 (not text-black)  hover:shadow-lg  active:scale-[0.98] */
.btn-cta {
  position: relative;
  color: #0f172a;
}
.btn-cta:hover {
  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
}
.btn-cta:active {
  transform: scale(0.98);
}

/* was: flex flex-col sm:flex-row gap-3 max-w-xl mx-auto */
.action-row {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  max-width: 36rem;
  margin-left: auto;
  margin-right: auto;
}
@media (min-width: 640px) {
  .action-row { flex-direction: row; }
}

/* was: text-4xl md:text-5xl font-extrabold tracking-tight drop-shadow-sm mb-4 */
.page-title {
  font-size: 2.25rem;
  font-weight: 800;
  letter-spacing: -0.025em;
  /* Was drop-shadow(0 1px 1px rgba(0,0,0,0.05)) - 5% alpha, which is nothing.
     The same shadow .section-title carries, for the same reason. */
  text-shadow: 0 1px 4px rgba(2, 6, 23, 0.9);
  margin-bottom: 1rem;
}
@media (min-width: 768px) {
  .page-title { font-size: 3rem; }
}

/* was: text-xl md:text-2xl font-semibold drop-shadow-sm mb-2 */
.page-subtitle {
  font-size: 1.25rem;
  font-weight: 600;
  text-shadow: 0 1px 4px rgba(2, 6, 23, 0.9);
  margin-bottom: 0.5rem;
}

@media (min-width: 768px) {
  .page-subtitle { font-size: 1.5rem; }
}

/* was: text-2xl font-semibold mb-6

   The same chapter-plate vocabulary a level down: the amber mark, then the
   label, then a rule running out to the edge of the section it opens. The
   plate's rule is centred and symmetrical because it sits under a title; these
   are left-anchored and run one way, because they head a list.

   NO LONGER HOME ONLY. The Skirmish board's level headings use it too, so this
   rule now has two callers - see .level-title in the Skirmish stylesheet,
   which adds a trailing count and nothing else. .games-section is still home
   only.

   Dropped from 1.5rem semibold to a tracked uppercase label. The rule is what
   carries the width now, so the type does not have to, and it puts a clear
   step between the wordmark and the sections rather than two headings of
   nearly the same weight. Ink, not amber - amber stays the hero's and the
   row links'.

   The text-shadow is not decoration: these sit low on the page over the busy
   part of the seasonal artwork, with no scrim of their own. */
.section-title {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  font-size: 1rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.13em;
  color: var(--ink);
  text-shadow: 0 1px 4px rgba(2, 6, 23, 0.9);
  margin-bottom: 1.5rem;
}

/* Same mark as .chronicle-mark, at the same size - a rotated square rather
   than a glyph. Kept as its own declaration instead of sharing a class: the
   markup here is a bare <h2> with a text node, and a pseudo-element is what
   lets the shape exist without wrapping the heading in extra spans. */
.section-title::before {
  content: "";
  flex: none;
  width: 0.4375rem;
  height: 0.4375rem;
  background: var(--accent);
  transform: rotate(45deg);
  box-shadow: 0 0 10px rgba(251, 191, 36, 0.7);
}

.section-title::after {
  content: "";
  flex: 1 1 0%;
  height: 1px;

  /* Three stops, not two: a straight fade to transparent gives up in the first
     third and leaves what looks like a stub rather than a rule running out.
     The long shallow tail is what carries it to the edge of the section. */
  background: linear-gradient(90deg,
                              rgba(251, 191, 36, 0.6) 0%,
                              rgba(251, 191, 36, 0.32) 30%,
                              rgba(251, 191, 36, 0.12) 65%,
                              rgba(251, 191, 36, 0) 100%);
}

@media (min-width: 768px) {
  .section-title {
    font-size: 1.125rem;
    letter-spacing: 0.15em;
  }
}

/* was: block p-4 rounded-xl backdrop-blur-md bg-slate-900/40 border border-white/50
   hover:bg-white/35 transition */
.list-row {
  display: block;
  padding: 1rem;
  border-radius: var(--radius);
  /* The same solid material as .panel-solid, and for the same reason:
     var(--surface)'s 0.4 alpha is cut for a panel on the page background, not
     one sitting on the seasonal artwork. At 0.4 the artwork came through the
     row and took the dim body text with it - Andy: "grey and insipid ... too
     see through". Login, Join and the Lobby were all moved to 0.72 before
     this; Home's rows were the last ones left behind. */
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  background: rgba(15, 23, 42, 0.72);
  border: 1px solid var(--border);
  transition: transform 0.18s ease, background 0.15s ease;
}
/* Hover is gated on a device that actually has a pointer.
   A tap leaves the element in :hover until something else is tapped, so on a
   phone this row stayed pale long after you had come back to it - and its text
   is white, so a white fill left it barely readable. The rule is worth keeping
   for a mouse; it just has no business firing on touch. Every hover below that
   flips a fill or moves the element carries the same gate. */
@media (hover: hover) {
  /* Lifts rather than lightens. This was rgba(255,255,255,0.35) - a pale fill
     under white text, which the comment above already called out as barely
     readable. Against a 0.72 dark row it would also have been a hard flip from
     dark to light on hover. Same dark-glass lift as .btn-leave:hover. */
  .list-row:hover {
    background: rgba(30, 41, 59, 0.85);
  }
}

/* The press.
   These rows navigate, so there is a wait before anything else confirms the
   tap - without this they feel dead until the next page paints. :active is the
   right tool where :hover was not: it lasts exactly as long as the finger is
   down and cannot stick.

   MUST stay after the hover block: a media query adds no specificity, so
   .list-row:active and .list-row:hover are both (0,2,0) and source order is
   the only thing that lets a press win over a hover on desktop.

   transition-duration: 0s makes the press land immediately while the release
   still eases back through the base rule - pressing should feel instant,
   letting go should not snap. Scaling rather than moving means a mis-tap
   costs nothing: the row never leaves its own footprint.

   The fill deepens rather than lightens. Lightening washed the row's own white
   text out - the same fault the hover had - and pressing into a surface is what
   a press should look like anyway. */
.list-row:active {
  transform: scale(0.98);
  /* Deepened from 0.75, which was lighter than the row's own 0.72 fill by too
     little to read as a press once the base went solid. */
  background: rgba(2, 6, 23, 0.92);
  transition-duration: 0s;
}

/* A lobby the host closed while the player was looking at the home page. The row
   stays as a record of what happened and goes on the next render, but it is no
   longer a way in - closeWaitingLobby strips its href.

   MUST stay after the hover and press blocks, and MUST carry both class names:
   .list-row-closed alone is (0,1,0) and would lose to .list-row:active at
   (0,2,0), leaving a dead row that still shrank and darkened under a finger.
   Doubling up gives (0,3,0) on the pseudo-class rules and wins outright. */
.list-row.list-row-closed {
  opacity: 0.55;
  cursor: default;
}
.list-row.list-row-closed:hover,
.list-row.list-row-closed:active {
  transform: none;
  background: var(--surface);
}

/* form controls — these are LIGHT inputs on a dark page, not dark ones.
   was: block text-sm font-medium mb-1 [text-slate-200] */
.field-label {
  display: block;
  font-size: 0.875rem;
  font-weight: 500;
  margin-bottom: 0.25rem;
}
/* the text-slate-200 variant (User Profile) */
.field-label-dim { color: #e2e8f0; }

/* was: w-full px-3 py-2 rounded-lg bg-white/80 text-black border border-white/40
        focus:outline-none focus:ring-2 focus:ring-amber-400 */
.field-input,
.field-select {
  width: 100%;
  padding: 0.5rem 0.75rem;
  border-radius: var(--radius-sm);
  background: rgba(255, 255, 255, 0.8);
  color: #000000;
  border: 1px solid rgba(255, 255, 255, 0.4);
}
.field-input:focus,
.field-select:focus {
  outline: none;
  box-shadow: 0 0 0 2px var(--accent);
}
/* User Profile uses bg-white/90 rather than /80 */
.field-input-strong { background: rgba(255, 255, 255, 0.9); }

/* was: mx-auto max-w-md bg-black/40 backdrop-blur-md rounded-2xl p-6 text-left space-y-4 */
.form-panel {
  margin-left: auto;
  margin-right: auto;
  max-width: 28rem;
  background: rgba(0, 0, 0, 0.4);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border-radius: 1rem;
  padding: 1.5rem;
  text-align: left;
}
.form-panel > * + * { margin-top: 1rem; }   /* space-y-4 */

/* was: p-4 rounded-lg bg-red-500/90 text-white text-sm font-medium */
.alert-error {
  padding: 1rem;
  border-radius: var(--radius-sm);
  background: rgba(239, 68, 68, 0.9);
  color: var(--ink);
  font-size: 0.875rem;
  font-weight: 500;
}

/* was: w-full mt-4 py-3 text-lg (on top of the primary button treatment) */
.btn-block {
  width: 100%;
  margin-top: 1rem;
  font-size: 1.125rem;
}

/* was: text-center text-sm text-gray-500 py-4 border-t border-gray-200 */
.site-footer {
  text-align: center;
  font-size: 0.875rem;
  color: #6b7280;
  padding-top: 1rem;
  padding-bottom: 1rem;
  border-top: 1px solid #e5e7eb;
}

/* was: text-xs font-medium px-2 py-1 rounded-full */
.badge {
  display: inline-flex;
  align-items: center;
  font-size: 0.75rem;
  font-weight: 500;
  padding: 0.25rem 0.5rem;
  border-radius: 9999px;
}

/* was: rounded-xl p-6 bg-slate-900/40 border border-white/50 text-slate-300 */
.empty-state {
  border-radius: var(--radius);
  padding: 1.5rem;
  /* Matches .list-row, since it stands in for one. */
  background: rgba(15, 23, 42, 0.72);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid var(--border);
  color: var(--ink-dim);
}

/* card cost chips — was: flex items-center justify-center w-8 h-8
   ${categoryColors[n].category_bg} + conditional /50 + text/border utilities.
   The runtime string composition becomes a base class plus modifiers. */
.cat-chip {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  font-size: 0.875rem;
  font-weight: 500;
  border-radius: 9999px;
  border: 1px solid currentColor;
  color: #0f172a;
}
.cat-1 { background: var(--cat-1); }
.cat-2 { background: var(--cat-2); }
.cat-3 { background: var(--cat-3); }
.cat-4 { background: var(--cat-4); }
.cat-5 { background: var(--cat-5); }

/* was: the /50 opacity suffix on a zero-cost chip */
.is-dim { opacity: 0.5; }

/* completed-games grid — was: grid gap-4 sm:grid-cols-2 */
.card-grid {
  display: grid;
  gap: 1rem;
}
@media (min-width: 640px) {
  .card-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* was: flex items-center gap-4 bg-white rounded-xl shadow hover:shadow-md p-4
        transition transform hover:-translate-y-0.5 */
.game-card {
  display: flex;
  align-items: center;
  gap: 1rem;
  background: #ffffff;
  border-radius: var(--radius);
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
  padding: 1rem;
  transition: all 0.15s ease;
}
/* Pointer only - a tapped card would otherwise stay lifted. See .list-row. */
@media (hover: hover) {
  .game-card:hover {
    box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
    transform: translateY(-0.125rem);
  }
}

/* The press - see .list-row for the reasoning. After the hover block so it
   wins on desktop, where hovering and pressing happen together. This card is
   white rather than glass, so it presses into a slate tint instead of
   lightening. */
.game-card:active {
  transform: scale(0.98);
  background: #f1f5f9;
  box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
  transition-duration: 0s;
}

/* Someone who has asked their device for less motion still needs to know the
   tap registered, so the fill change stays and only the movement goes. Placed
   after both press rules, since it has to override them. */
@media (prefers-reduced-motion: reduce) {
  .list-row:active,
  .game-card:active,
  .btn:active {
    transform: none;
  }
}

/* was: w-16 h-16 rounded-full border-2 border-indigo-600 shadow-sm object-cover */
.game-card-logo {
  width: 4rem;
  height: 4rem;
  border-radius: 9999px;
  border: 2px solid #4f46e5;
  box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
  object-fit: cover;
}

/* was: text-lg font-semibold text-indigo-700 */
.game-card-name {
  font-size: 1.125rem;
  font-weight: 600;
  color: #4338ca;
}

/* was: text-sm text-gray-500 */
.game-card-players {
  font-size: 0.875rem;
  color: #6b7280;
}

/* was: text-center text-gray-600 text-lg italic py-10
   NOTE: distinct from .empty-state, which is the glass-panel variant on Home. */
.empty-message {
  text-align: center;
  color: #4b5563;
  font-size: 1.125rem;
  font-style: italic;
  padding-top: 2.5rem;
  padding-bottom: 2.5rem;
}

/* ----- shared text + spacing roles -----
   Named by role, not by value, so they don't read as utilities.
   (.dim etc. deliberately avoid a `text-` prefix — that would collide
   with the residual-utility verification greps.) */

.dim { color: var(--ink-dim); }                       /* text-slate-300 */
.faint { color: var(--ink-faint); }                   /* text-slate-400 */
/* Full ink, not the dimmed variant. .dim-sm carries the line you actually
   scan for - the game's name on Home, the invite code on a waiting lobby - and
   at slate-300 it read grey against the white section heading right above it.
   Same call, and the same reasoning, as .friend-pick-name in the lobby.

   The hierarchy is now three steps rather than two: white for the value,
   .dim-sm-top's slate-300 for the second line, .faint's slate-400 for the
   labels in front of them. */
.dim-sm { font-size: 0.875rem; color: var(--ink); }
.lead { color: var(--ink-dim); margin-bottom: 1rem; }
.lead-wide { color: var(--ink-dim); margin-bottom: 1.5rem; }

/* was: text-xl font-semibold mb-2 */
.card-title { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
/* was: text-3xl font-bold mb-4 */
.block-title { font-size: 1.875rem; font-weight: 700; margin-bottom: 1rem; }

/* vertical rhythm — was: space-y-3 / space-y-4 / space-y-6 */
.stack-3 > * + * { margin-top: 0.75rem; }
.stack-4 > * + * { margin-top: 1rem; }
.stack-6 > * + * { margin-top: 1.5rem; }
.stack-4-top { padding-top: 1rem; }
.stack-4-top > * + * { margin-top: 1rem; }

/* was: p-6 rounded-xl bg-white/5 border border-white/10 */
.panel-subtle {
  padding: 1.5rem;
  border-radius: var(--radius);
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid rgba(255, 255, 255, 0.1);
}

/* was: w-full py-2 (on top of the primary button treatment) */
.btn-wide { width: 100%; padding-top: 0.5rem; padding-bottom: 0.5rem; }


/* ----- login ----- */

/* was: relative min-h-screen flex items-center justify-center bg-cover bg-center */
.login-shell,
.hero-shell {
  position: relative;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background-size: cover;
  background-position: center;
}

/* was: relative z-10 max-w-4xl w-full px-6 text-center */
.login-inner {
  position: relative;
  z-index: 10;
  max-width: 56rem;
  width: 100%;
  padding-left: 1.5rem;
  padding-right: 1.5rem;
  text-align: center;
}

/* was: w-full max-w-md mx-auto rounded-2xl p-6 space-y-6

   Login only - .login-card appears on no other page - so this is safe to
   restyle in place.

   It used to have no fill at all, which left every label and hint inside it
   sitting on bare seasonal artwork. Measured over logo.jpg at 1200px, the
   ground under "Email address" was mean 0.147 / sdev 0.132 / p99 0.450, and
   under the "We'll email you a passcode" hint 0.219 / 0.272 / p99 0.853 -
   near-white patches behind pale type. For scale, the CTA tagline Andy called
   "grey and blurred" was p99 0.231, and the season line on the plate above
   measures 0.029. This was the worst type on the page by a wide margin.

   The fix is the panel treatment this stylesheet already uses for a form on a
   dark page (.form-panel, .panel) rather than a new invention: a glass fill
   over a blur. The blur is what does the real work here - the problem is
   busyness as much as brightness (sdev 0.272), and blurring the backdrop
   collapses the variance that no amount of darkening would.

   The fill is deliberately heavier than var(--surface)'s 0.4. That token is
   cut for panels over the page background, not over a bright, high-contrast
   hero. Measured at 1200px (mean / sdev / p99):

                          .field-label            .login-hint
       no fill            0.1465/0.1318/0.4496    0.2192/0.2716/0.8533
       --surface 0.40     0.0438/0.0153/0.0748    0.0728/0.0664/0.2218
       0.50 + blur12      0.0362/0.0113/0.0589    0.0566/0.0479/0.1632
       0.62 + blur16      0.0247/0.0048/0.0344    0.0378/0.0263/0.0970
       0.72 + blur16      0.0197/0.0031/0.0257    0.0275/0.0160/0.0627

   It shipped at 0.62 first, which already puts the labels on the same footing
   as the season line on the plate above (0.0092/0.0053/0.0291). 0.72 is what
   is here now, and the reason is .privacy-note at the foot of the card: 12px
   type needs 4.5:1 to meet AA, and over the bottom of this artwork 0.62 gave
   it only 3.98:1. 0.72 takes it to 5.42:1. Dropping the white cookie panel
   also took the one bright element out of the card, so the heavier fill costs
   less here than it would have.

   Anything at the BOTTOM of this card is the hard case, and not for the
   reason it looks like. It is NOT the backdrop-filter being clamped at the
   boundary - tested, and 0.75rem of extra bottom padding moved the ground by
   0.002, i.e. nothing. It is simply what is behind it: the foot of the card
   lies over the sunlit foreground, the brightest and busiest part of the
   artwork, where the raw ground is 0.853 p99 against 0.450 up in the canopy.
   Put small type down there and measure it; do not assume the card covers it.

   Everything inside comes along: the hint, and the red .field-error, which is
   an error state and was the least legible thing on the page. */
.login-card {
  width: 100%;
  max-width: 28rem;
  margin-left: auto;
  margin-right: auto;
  border-radius: 1rem;
  padding: 1.5rem;
  background: rgba(15, 23, 42, 0.72);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid var(--border);
}
.login-card > * + * { margin-top: 1.5rem; }

/* The cookie note. Login only.

   This replaces .info-card / .info-card-title / .info-card-body, a white
   panel that sat between the email field and the Continue button. Three
   things made it read as terms and conditions rather than as the
   reassurance it is meant to be: it occupied the position of a consent gate,
   a titled box is the visual language of a notice, and white-on-dark made it
   the highest-contrast element on the page - brighter than the amber button
   it was standing in front of.

   So: no box, no title, last in the card, and the quietest type in it. The
   only weight it carries is the padlock, which does the reassuring at a
   glance and lets the words be small.

   Left-aligned rather than centred like .login-hint above it: three lines of
   centred text reads as a statement being made, and this is a footnote. The
   icon is pulled out of the text column so the wrap aligns under itself.

   --ink-dim, NOT --ink-faint, which is what a footnote wants and what this
   had first. Measured over the foot of the card, --ink-faint gives 2.30:1 at
   1200px - it is a slate grey meant for type on the page background, and this
   sits over the brightest part of the artwork. --ink-dim gives 5.42:1 at the
   0.72 card fill, clear of the 4.5:1 that 12px type needs for AA. Separation
   from the hint above comes from size, not colour. */
.privacy-note {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  text-align: left;
  font-size: 0.75rem;
  line-height: 1.5;
  color: var(--ink-dim);
}

/* 0.875rem, not .icon-5's 1.25rem, which would out-weigh 12px type. The
   0.1em nudge sits it on the first line's cap height rather than its box. */
.privacy-note-icon {
  flex: none;
  width: 0.875rem;
  height: 0.875rem;
  margin-top: 0.1em;
}

/* The way back off the login page. Quiet by design: it is an escape hatch, so
   it must be findable without competing with Continue.

   Sits outside .login-card, so it takes no ground from the card's glass and
   needs its own legibility over the artwork - hence the shadow, and a warm
   off-white rather than a slate grey, which reads as dirty against warm
   artwork (see the marketing hero). */
.login-back {
  margin-top: 1rem;
  text-align: center;
  font-size: 0.875rem;
  line-height: 1.25rem;
}
/* A pill, not bare type. Measured on the seasonal artwork the bare link sat on
   ground p99 0.344 - 2.10:1 at 14px, well under the 4.5:1 it needs - because
   unlike everything else here it is OUTSIDE the card and has no glass under it.
   The same material as the card, at a smaller radius, gives it its own ground
   and takes the tap target past 44px at the same time. */
.login-back a {
  display: inline-block;
  padding: 0.625rem 1rem;
  border-radius: 9999px;
  color: #efe3cf;
  font-weight: 500;
  text-decoration: none;
  background: rgba(15, 23, 42, 0.72);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid rgba(255, 255, 255, 0.18);
}
@media (hover: hover) {
  .login-back a:hover { color: #ffffff; border-color: rgba(255, 255, 255, 0.3); }
}

/* was: text-center text-sm font-medium text-slate-300 drop-shadow-sm */
.login-hint {
  text-align: center;
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--ink-dim);
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.05));
}

/* was: text-sm text-red-600 mt-1 */
.field-error { font-size: 0.875rem; color: #dc2626; margin-top: 0.25rem; }


/* ============================================================
   Colour tokens for the Skirmish game view and Card
   ------------------------------------------------------------
   The templates used to hold Tailwind class-name STRINGS in
   FreeMarker data (playerColors / categoryColors / labelColors)
   and do string surgery on them, e.g.
       color.tab?replace("text-", "bg-")
   NOT YET IN USE (2026-08-02). These rules are prepared for the
   Task 6 conversion of Card + Skirmish Game View, which is not
   done: those colour objects are ALSO serialised into JavaScript
   (8b60a037-...html lines ~461-495) and composed at runtime by
   createCardElement() and the ownership-stripe handler, so the
   templates and the JS must be converted together. Until that
   happens these classes are unused dead weight - do not delete
   them, and do not assume the game view is converted.
   ============================================================ */

/* category colours — c1 yellow, c2 blue, c3 green, c4 orange, c5 slate */
.cat-border-c1 { border-color: #854d0e; }
.cat-border-c2 { border-color: #1e40af; }
.cat-border-c3 { border-color: #166534; }
.cat-border-c4 { border-color: #9a3412; }
.cat-border-c5 { border-color: #1e293b; }

.cat-text-c1 { color: #713f12; }
.cat-text-c2 { color: #1e3a8a; }
.cat-text-c3 { color: #14532d; }
.cat-text-c4 { color: #7c2d12; }
.cat-text-c5 { color: #0f172a; }

.cat-bg-c1 { background-color: #fde047; }
.cat-bg-c2 { background-color: #93c5fd; }
.cat-bg-c3 { background-color: #86efac; }
.cat-bg-c4 { background-color: #fdba74; }
.cat-bg-c5 { background-color: #cbd5e1; }

.cat-btn-c1 { background-color: #854d0e; }
.cat-btn-c2 { background-color: #1e40af; }
.cat-btn-c3 { background-color: #166534; }
.cat-btn-c4 { background-color: #9a3412; }
.cat-btn-c5 { background-color: #1e293b; }

/* The zero-cost badge on a card. The Tailwind markup appended an opacity
   suffix to the class name itself - "${cat.category_bg}/50" - which has no
   equivalent once the class is semantic, so the dimmed variant has to exist as
   its own class. Same five hues as .cat-bg-c*, at half alpha. */
.cat-bg-dim-c1 { background-color: rgba(253, 224, 71, 0.5); }
.cat-bg-dim-c2 { background-color: rgba(147, 197, 253, 0.5); }
.cat-bg-dim-c3 { background-color: rgba(134, 239, 172, 0.5); }
.cat-bg-dim-c4 { background-color: rgba(253, 186, 116, 0.5); }
.cat-bg-dim-c5 { background-color: rgba(203, 213, 225, 0.5); }

.cat-btn-off-c1 { background-color: rgba(254, 240, 138, 0.6); }
.cat-btn-off-c2 { background-color: rgba(191, 219, 254, 0.6); }
.cat-btn-off-c3 { background-color: rgba(187, 247, 208, 0.6); }
.cat-btn-off-c4 { background-color: rgba(254, 215, 170, 0.6); }
.cat-btn-off-c5 { background-color: rgba(226, 232, 240, 0.6); }

/* player colours — p1 yellow, p2 blue, p3 purple, p4 orange, p5 red */
.pl-border-p1 { border-color: #facc15; }
.pl-border-p2 { border-color: #60a5fa; }
.pl-border-p3 { border-color: #c084fc; }
.pl-border-p4 { border-color: #fb923c; }
.pl-border-p5 { border-color: #f87171; }

.pl-tab-p1 { color: #ca8a04; }
.pl-tab-p2 { color: #2563eb; }
.pl-tab-p3 { color: #9333ea; }
.pl-tab-p4 { color: #ea580c; }
.pl-tab-p5 { color: #dc2626; }

.pl-bg-p1 { background-color: rgba(254, 240, 138, 0.8); }
.pl-bg-p2 { background-color: rgba(191, 219, 254, 0.8); }
.pl-bg-p3 { background-color: rgba(233, 213, 255, 0.8); }
.pl-bg-p4 { background-color: rgba(254, 215, 170, 0.8); }
.pl-bg-p5 { background-color: rgba(254, 202, 202, 0.8); }

/* was: color.tab?replace("text-","bg-") — the player chip fill */
.pl-chip-p1 { background-color: #ca8a04; }
.pl-chip-p2 { background-color: #2563eb; }
.pl-chip-p3 { background-color: #9333ea; }
.pl-chip-p4 { background-color: #ea580c; }
.pl-chip-p5 { background-color: #dc2626; }
.pl-chip-none { background-color: #d97706; }   /* was: bg-amber-600 fallback */

/* was: playerColors[..].border?replace("border-","bg-") — ownership stripe */
.pl-stripe-p1 { background-color: #facc15; }
.pl-stripe-p2 { background-color: #60a5fa; }
.pl-stripe-p3 { background-color: #c084fc; }
.pl-stripe-p4 { background-color: #fb923c; }
.pl-stripe-p5 { background-color: #f87171; }

/* label chips — l1 amber, l2 violet, l3 deep violet (all white text) */
.lbl-l1 { background-color: #f59e0b; border-color: #b45309; color: #ffffff; }
.lbl-l2 { background-color: #7c3aed; border-color: #5b21b6; color: #ffffff; }
.lbl-l3 { background-color: #5b21b6; border-color: #4c1d95; color: #ffffff; }


/* ----- animations (Tailwind v3 keyframes, used by the lobby) ----- */
@keyframes bounce {
  0%, 100% { transform: translateY(-25%); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); }
  50% { transform: none; animation-timing-function: cubic-bezier(0, 0, 0.2, 1); }
}
@keyframes pulse {
  50% { opacity: 0.5; }
}
.animate-bounce { animation: bounce 1s infinite; }
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }


/* ----- home ----- */

.rel { position: relative; }
.center { text-align: center; }
.bold { font-weight: 700; }
.inline-b { display: inline-block; }
/* was: w-5 h-5 */
.icon-5 { width: 1.25rem; height: 1.25rem; }
/* was: hidden mx-auto max-w-xl mb-8 - plus a top margin, which it now needs.

   Both prompt cards live at the BOTTOM of Home, below the games. The top
   margin used to sit on .nudge-prompt alone, because only the nudge had
   anything above it; now both do, and either can be hidden independently. With
   the margin on the nudge only, a player who had already dismissed the nudge
   got the install card butting straight against the last game row - measured
   at a 0px gap.

   4rem rather than the old 2rem: at 2rem the card sat inside the games list's
   own rhythm and read as another game row. Adjacent margins collapse, so two
   visible cards sit 4rem apart too - and .games-section now uses the same
   4rem, so the whole page runs on one interval. The section breaks do not need
   more air than this to read as breaks: .section-title carries an amber mark,
   tracked uppercase and a rule, which is plenty of signal on its own. */
.pwa-prompt {
  margin-left: auto;
  margin-right: auto;
  max-width: 36rem;
  margin-top: 4rem;
  margin-bottom: 2rem;
}
/* The nudge card's heading, with its emoji sitting ON the title line.

   These cards used to be .row-start - an icon COLUMN beside a .grow column,
   both classes now deleted since nothing else ever used them. The icon was
   2.25rem and the column gap 1rem, so it took ~52px, and at 390px that left
   the text about 230px - enough to wrap the body to two lines and, worse, to
   wrap BOTH buttons onto two lines each, while the space under the icon stayed
   empty. The card ended up taller and visibly lop-sided.

   Inline in the header, the emoji costs one line's height it was going to
   spend anyway, and the body and buttons get the full width. Scaled down to
   1.5rem to sit with the 1.25rem title rather than tower over it.

   The 0.5rem bottom margin is the one .panel-title-sp used to supply - the
   title inside this header must NOT also carry it, or the gap doubles. */
.nudge-header {
  display: flex;
  /* baseline, not center. The install card's title wraps to two lines at phone
     widths, and centring floats the emoji between them - measured 16px below
     the first line's middle at 390px. On the baseline it lands within 1px of
     it, and a single-line title is unaffected either way. */
  align-items: baseline;
  gap: 0.625rem;
  margin-bottom: 0.5rem;
}
.nudge-header-icon {
  flex-shrink: 0;
  font-size: 1.5rem;
  line-height: 1;
}

/* The nudge card's action row. Dismiss sits left, the committing action right -
   the house rule for dialogs, applied here so the two cards that can share the
   Home page do not contradict each other. The PWA install card uses this too. */
.nudge-actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
}

/* The "you can change this later" footnote. Present in EVERY state of the nudge
   card including the success state - it is the promise that the choice is
   reversible, so it must not be something a player only sees if they read the
   offer carefully. */
.nudge-note {
  margin-top: 0.75rem;
  font-size: 0.8125rem;
  color: var(--ink-dim);
}
.nudge-note a {
  color: var(--accent-hover);
  text-decoration: underline;
}

/* was: px-6 py-3 (on top of the primary button treatment) */
.btn-md { padding: 0.75rem 1.5rem; }
/* was: px-6 py-3 rounded-xl bg-white text-slate-900 font-medium hover:bg-white/70 transition */
.btn-white {
  padding: 0.75rem 1.5rem;
  border-radius: var(--radius);
  background: #ffffff;
  color: #0f172a;
  font-weight: 500;
  transition: background 0.15s ease;
}
.btn-white:hover { background: rgba(255, 255, 255, 0.7); }

/* was: max-w-5xl mx-auto mt-32 */
/* 36rem, matching .action-row and .pwa-prompt, so everything on Home sits in
   one column.

   It was 64rem, which could never actually apply: .page caps at 56rem and
   spends 1.5rem a side on padding, so the real ceiling is 53rem and the 64rem
   just meant "as wide as the page allows". That made the game lists 720px
   against the buttons' 576px at 768px wide, and 848px against 576px at
   1024px - a gap that grows with the viewport, which is why it read as odd on
   a desktop and looked fine on a phone, where every one of these is simply
   the full width anyway. */
.games-section { max-width: 36rem; margin-left: auto; margin-right: auto; margin-top: 4rem; }

/* was: text-slate-300 mb-2 text-center / mb-8 text-center / mb-3 */
.dim-center { color: var(--ink-dim); margin-bottom: 0.5rem; text-align: center; }
.dim-center-lg { color: var(--ink-dim); margin-bottom: 2rem; text-align: center; }
.lead-tight { color: var(--ink-dim); margin-bottom: 0.75rem; }

/* was: text-center text-sm text-slate-300 py-4 border-t border-slate-300 */
.page-footer {
  text-align: center;
  font-size: 0.875rem;
  color: var(--ink-dim);
  padding-top: 1rem;
  padding-bottom: 1rem;
  border-top: 1px solid var(--ink-dim);
}

/* was: text-slate-300 text-sm space-y-1 mb-4 list-decimal list-inside */
.steps-list {
  color: var(--ink-dim);
  font-size: 0.875rem;
  margin-bottom: 1rem;
  list-style-type: decimal;
  list-style-position: inside;
}
.steps-list > * + * { margin-top: 0.25rem; }


/* ----- game lobby ----- */

/* was: max-w-2xl mx-auto mt-8 space-y-6 */
.lobby-wrap { max-width: 42rem; margin-left: auto; margin-right: auto; margin-top: 2rem; }
.lobby-wrap > * + * { margin-top: 1.5rem; }

/* The share code is the fallback way into a lobby, for people who are not on
   your friends list. It sits under the players list with no panel chrome so it
   reads as an option, not a step you are required to complete. Overrides the
   1.5rem from `.lobby-wrap > * + *` above to sit a little further off. */
.code-share {
  margin-top: 2rem;
  text-align: center;
  /* The strip sits straight on the seasonal photo, which on busy artwork left
     the text hard to read. It takes the same blurred scrim the panels use, but
     without their border and fill weight so it still reads as a footnote
     rather than a second panel. Hierarchy comes from size and position here,
     not from dimming the text. */
  padding: 0.875rem 1rem;
  border-radius: var(--radius);
  background: rgba(15, 23, 42, 0.55);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
}

.code-share-lead {
  font-size: 0.875rem;
  color: var(--ink);
  margin-bottom: 0.5rem;
}

.code-share-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.625rem;
}

/* One block per route in - code for the app, link for a browser. */
.code-share-block + .code-share-block {
  margin-top: 0.875rem;
  padding-top: 0.875rem;
  border-top: 1px solid rgba(255, 255, 255, 0.12);
}

.code-share-link {
  font-size: 0.8125rem;
  color: var(--ink);
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  /* The URL is long; let it wrap rather than shove the button off the row. */
  overflow-wrap: anywhere;
  min-width: 0;
}

.code-share-code {
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--ink);
  letter-spacing: 0.1em;
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  background: rgba(255, 255, 255, 0.1);
  padding: 0.25rem 0.75rem;
  border-radius: var(--radius-sm);
}

/* was: px-4 py-2 bg-amber-400 hover:bg-amber-300 text-black font-semibold
        rounded-lg transition shadow-md - scaled down with the code itself */
.btn-copy {
  padding: 0.375rem 0.75rem;
  background: var(--accent);
  color: #000000;
  font-size: 0.875rem;
  font-weight: 600;
  border-radius: var(--radius-sm);
  transition: background 0.15s ease;
  /* Holds its width when the label flips to "Copied!", so the row
     does not twitch on tap. */
  min-width: 6.5rem;
  flex-shrink: 0;
}
.btn-copy:hover { background: var(--accent-hover); }

.code-share-hint {
  font-size: 0.8125rem;
  color: var(--ink-dim);
  margin-top: 0.375rem;
  /* break-word, not break-all: if this ever has to break inside a word it
     does so at a sensible point rather than mid-syllable. */
  overflow-wrap: break-word;
}

/* was: px-3 py-1 bg-slate-900 text-white text-sm font-semibold rounded-full */
.count-badge {
  padding: 0.25rem 0.75rem;
  background: var(--bg);
  color: var(--ink);
  font-size: 0.875rem;
  font-weight: 600;
  border-radius: 9999px;
}

/* was: flex-shrink-0 w-12 h-12 rounded-full bg-gradient-to-br from-slate-400
        to-slate-600 flex items-center justify-center text-white font-bold text-lg shadow-md */
.player-avatar {
  flex-shrink: 0;
  width: 3rem;
  height: 3rem;
  border-radius: 9999px;
  background-image: linear-gradient(to bottom right, #94a3b8, #475569);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--ink);
  font-weight: 700;
  font-size: 1.125rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
}

/* was: px-3 py-1 bg-amber-400 text-black text-xs font-bold rounded-full */
.host-badge {
  padding: 0.25rem 0.75rem;
  background: var(--accent);
  color: #000000;
  font-size: 0.75rem;
  font-weight: 700;
  border-radius: 9999px;
}

/* was: flex items-center gap-3 p-4 rounded-xl bg-white/20 border-2 border-dashed border-white/40 */
/* was: flex-shrink-0 w-12 h-12 rounded-full bg-slate-300/50 flex items-center justify-center */
/* was: text-slate-400 text-2xl */
/* was: text-slate-600 italic */

/* was: flex items-center justify-between gap-4 */
.row-between-gap { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }

/* was: px-6 py-3 rounded-xl bg-white/30 border border-white/40 text-slate-900
        font-semibold hover:bg-white/40 transition shadow-md

   Leave/Close/Back is the secondary of the pair, but the white/30 fill it
   started with was see-through enough that the artwork behind it fought the
   dark label. It is now the dark-glass counterpart to the solid amber primary
   beside it: its own fill, a light hairline, white label. Secondary now reads
   from weight and colour rather than from being faded out. */
.btn-leave {
  padding: 0.75rem 1.5rem;
  border-radius: var(--radius);
  background: rgba(15, 23, 42, 0.7);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border: 1px solid rgba(255, 255, 255, 0.35);
  color: var(--ink);
  font-weight: 600;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.25), 0 2px 4px -2px rgba(0, 0, 0, 0.2);
  transition: background 0.15s ease, border-color 0.15s ease;
}
.btn-leave:hover {
  background: rgba(30, 41, 59, 0.85);
  border-color: rgba(255, 255, 255, 0.55);
}

/* was: px-8 py-3 rounded-xl bg-amber-400 text-black font-bold hover:bg-amber-300
        transition shadow-lg */
.btn-start {
  padding: 0.75rem 2rem;
  border-radius: var(--radius);
  background: var(--accent);
  color: #000000;
  font-weight: 700;
  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
  transition: background 0.15s ease;
}
.btn-start:hover { background: var(--accent-hover); }

/* was: px-8 py-3 rounded-xl bg-slate-300 text-slate-500 font-bold
        cursor-not-allowed shadow-md opacity-50

   "Need 2+ Players" is an instruction, so it has to be readable while still
   looking unavailable. Grey-on-grey at 50% opacity managed neither over the
   artwork. Unavailability now comes from the dark, flat, unhighlighted fill -
   nothing like the amber primary - not from fading the text away. */
.btn-start-disabled {
  padding: 0.75rem 2rem;
  border-radius: var(--radius);
  background: rgba(15, 23, 42, 0.7);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border: 1px dashed rgba(255, 255, 255, 0.25);
  color: var(--ink-faint);
  font-weight: 700;
  cursor: not-allowed;
  box-shadow: none;
}

/* was: flex justify-center items-center gap-2 [py-4] */
/* .dots-row is still used by the "Game Starting..." overlay built in JS. */
.dots-row { display: flex; justify-content: center; align-items: center; gap: 0.5rem; }
/* was: w-3 h-3 bg-amber-400 rounded-full animate-bounce */
.dot { width: 0.75rem; height: 0.75rem; background: var(--accent); border-radius: 9999px; }
/* was: text-4xl font-bold text-amber-400 mb-4 animate-pulse */
.countdown { font-size: 2.25rem; font-weight: 700; color: var(--accent); margin-bottom: 1rem; }

/* Explains a closed lobby above the way out of it. */
.lobby-closed-note {
  color: var(--ink);
  max-width: 22rem;
  margin: 0 auto 1.5rem;
  line-height: 1.4;
}


/* ----- how to play ----- */

/* was: list-disc list-inside text-sm text-slate-800 space-y-1 mt-3 */
.rule-list {
  list-style-type: disc;
  list-style-position: inside;
  font-size: 0.875rem;
  color: #1e293b;
  margin-top: 0.75rem;
}
.rule-list > * + * { margin-top: 0.25rem; }
/* was: list-disc list-inside [rule-text] space-y-1 mt-2 */
.rule-bullets { list-style-type: disc; list-style-position: inside; }
.rule-bullets-sp { margin-top: 0.5rem; }
.rule-bullets-sp > * + * { margin-top: 0.25rem; }

/* was: flex justify-center items-end gap-6 mt-1 mb-2 */
.demo-row {
  display: flex;
  justify-content: center;
  align-items: flex-end;
  gap: 1.5rem;
  margin-top: 0.25rem;
  margin-bottom: 0.5rem;
}
/* was: flex flex-col items-center gap-2 */
.demo-col { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
/* was: flex items-center justify-between px-2 */
.demo-between { display: flex; align-items: center; justify-content: space-between; padding-left: 0.5rem; padding-right: 0.5rem; }
/* was: flex gap-1 justify-center flex-1 */
.demo-chips { display: flex; gap: 0.25rem; justify-content: center; flex: 1 1 0%; }
/* was: flex flex-1 items-center pb-4 justify-center gap-2 */
.demo-row-b { display: flex; flex: 1 1 0%; align-items: center; padding-bottom: 1rem; justify-content: center; gap: 0.5rem; }
/* was: flex justify-center items-center gap-2 text-xs text-slate-600 mt-2 */
.demo-footer {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.75rem;
  color: #475569;
  margin-top: 0.5rem;
}
/* was: text-xs text-slate-600 */
.demo-label { font-size: 0.75rem; color: #475569; }
/* was: text-sm font-semibold text-amber-900 w-14 */
.demo-cost-label { font-size: 0.875rem; font-weight: 600; color: #78350f; width: 3.5rem; }
/* was: ml-2 w-12 */
.demo-input-wrap { margin-left: 0.5rem; width: 3rem; }
/* was: ml-2 text-sm w-12 py-1.5 px-2 font-medium text-gray-900 bg-white
        rounded-lg border border-gray-300 text-center */
.demo-input {
  margin-left: 0.5rem;
  font-size: 0.875rem;
  width: 3rem;
  padding: 0.375rem 0.5rem;
  font-weight: 500;
  color: #111827;
  background: #ffffff;
  border-radius: var(--radius-sm);
  border: 1px solid #d1d5db;
  text-align: center;
}

/* the demo player chips on How To Play — was: player-icon-chip bg-yellow-500 etc.
   player-icon-chip itself lives in the Skirmish Game View stylesheet. */
.chip-yellow { background-color: #eab308; }
.chip-blue { background-color: #3b82f6; }
.chip-purple { background-color: #a855f7; }

/* the larger card-cost chip variant — was: w-8 h-8 md:w-12 md:h-12 text-sm md:text-lg */
.cat-chip-lg { border-width: 2px; }
@media (min-width: 768px) {
  .cat-chip-lg { width: 3rem; height: 3rem; font-size: 1.125rem; }
}


/* ----- join game ----- */

/* was: text-xl text-center font-bold text-slate-300 mb-2 */
.join-title { font-size: 1.25rem; text-align: center; font-weight: 700; color: var(--ink-dim); margin-bottom: 0.5rem; }

/* was: flex flex-col sm:flex-row items-center justify-center gap-3 */
.join-row { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.75rem; }
@media (min-width: 640px) { .join-row { flex-direction: row; align-items: stretch; } }

/* The game-code field. It keeps the monospace, the tracking and the centring -
   it is a code being read back off a phone screen - but everything else now
   comes from .field-input, the app's own input vocabulary.

   Three things Andy called out, all of them here:
   - the amber ring. A 2px amber border made the resting state look like a
     validation error. Amber belongs on FOCUS, which is what .field-input does,
     so the ring now appears when you are typing in it.
   - "unappealing grey". The fill was white at 0.5 alpha, which over the dark
     card is not white, it is grey. .field-input uses 0.8 and reads as paper.
   - chunky. 1.875rem rising to 3rem is nearly three times the 1.0625rem the
     home page's buttons use. 1.5rem is still unmistakably a code field. */
.code-input {
  width: 100%;
  min-width: 0;
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  font-size: 1.5rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-align: center;
  color: #000000;
  background: rgba(255, 255, 255, 0.8);
  padding: 0.625rem 1rem;
  border-radius: var(--radius-sm);
  border: 1px solid rgba(255, 255, 255, 0.4);
}
.code-input::placeholder { color: rgba(15, 23, 42, 0.35); }
.code-input:focus {
  outline: none;
  box-shadow: 0 0 0 2px var(--accent);
}
@media (min-width: 640px) { .code-input { width: auto; } }

/* Join Game is the same kind of thing as Start Game on the home page - the one
   amber call to action - so it takes the same finish rather than a flat fill:
   a lit gradient, a top highlight, a hard bottom edge, and amber bled into the
   shadow. See .btn-primary.btn-flex, which this deliberately mirrors.

   Sized to match too. It was 1.875rem rising to 3rem; the home pair is
   1.0625rem, and a button three times the size of the page's other buttons is
   what "chunky" meant. */
.code-submit {
  width: 100%;
  font-size: 1.0625rem;
  font-weight: 700;
  line-height: 1;
  padding: 0.875rem 1.5rem;
  border: 0;
  border-radius: var(--radius);
  cursor: pointer;
  color: #1c1207;
  background-color: var(--accent);
  background-image: linear-gradient(180deg, #fcd34d 0%, var(--accent) 55%, #f0a91c 100%);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45),
              0 1px 0 rgba(0, 0, 0, 0.35),
              0 8px 20px -8px rgba(251, 191, 36, 0.55);
  transition: all 0.15s ease;
}
@media (hover: hover) {
  .code-submit:hover {
    background-image: linear-gradient(180deg, #fde68a 0%, var(--accent-hover) 55%, #f5b528 100%);
  }
}
/* :active after :hover - equal specificity, so source order decides. */
.code-submit:active {
  background-image: linear-gradient(180deg, #e59a10 0%, #f0a91c 100%);
  box-shadow: inset 0 2px 5px rgba(69, 39, 3, 0.5);
}
@media (min-width: 640px) { .code-submit { width: auto; } }

/* was: text-center text-sm text-slate-300 mt-3 */
.join-hint { text-align: center; font-size: 0.875rem; color: var(--ink-dim); margin-top: 0.75rem; }

/* was: p-6 rounded-xl backdrop-blur-md bg-slate-900/40 border border-white/50 shadow-lg */
.panel-raised { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); }

/* was: flex items-center justify-between mb-4 */
.row-between { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
/* was: flex items-center justify-between pt-4 */
.row-between-top { display: flex; align-items: center; justify-content: space-between; padding-top: 1rem; }

/* was: text-xl font-bold text-slate-300 [mb-2 | mb-4] */
.panel-title { font-size: 1.25rem; font-weight: 700; color: var(--ink-dim); }
.panel-title-sp { margin-bottom: 0.5rem; }
.panel-title-sp-lg { margin-bottom: 1rem; }

/* was: grid gap-4 */
.grid-rows { display: grid; gap: 1rem; }

/* was: flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 */
.row-responsive { display: flex; flex-direction: column; gap: 0.75rem; }
@media (min-width: 640px) {
  .row-responsive { flex-direction: row; align-items: center; justify-content: space-between; }
}

/* was: mt-1 text-sm text-slate-300 */
.dim-sm-top { margin-top: 0.25rem; font-size: 0.875rem; color: var(--ink-dim); }
/* was: text-amber-300 font-medium text-sm shrink-0 */
.row-action { color: var(--accent-hover); font-weight: 500; font-size: 0.875rem; flex-shrink: 0; }
/* was: w-full px-6 py-3 (on top of the primary button treatment) */
.btn-block-lg { width: 100%; padding: 0.75rem 1.5rem; }


/* ----- new game ----- */

/* was: relative py-4 */
.divider-wrap { position: relative; padding-top: 1rem; padding-bottom: 1rem; }
/* was: absolute inset-0 flex items-center */
.divider-line-wrap { position: absolute; top: 0; right: 0; bottom: 0; left: 0; display: flex; align-items: center; }
/* was: w-full border-t border-white/40 */
.divider-line { width: 100%; border-top: 1px solid rgba(255, 255, 255, 0.4); }
/* was: relative flex justify-center */
.divider-label-wrap { position: relative; display: flex; justify-content: center; }
/* was: px-4 text-sm font-semibold text-slate-900
        bg-gradient-to-b from-transparent via-slate-100/50 to-transparent */
.divider-label {
  padding-left: 1rem;
  padding-right: 1rem;
  font-size: 0.875rem;
  font-weight: 600;
  color: #0f172a;
  background-image: linear-gradient(to bottom, transparent, rgba(241, 245, 249, 0.5), transparent);
}

/* was: flex items-center gap-3 p-4 rounded-xl bg-white/40 border border-white/40
        shadow-sm [cursor-pointer]

   The row shape is shared by the lobby's players list, which is not clickable,
   and the invite dialog's friend rows, which are. It deliberately sets no
   cursor: the clickable ones are <button>s and pick up cursor: pointer from
   the base button rule, so a plain row no longer promises a click that does
   not exist.

   The translucent white fill it inherited from Tailwind was the odd one out in
   a dark-glass UI: the seasonal artwork read straight through it and the name
   on top washed out. It is now a dark inset of the panel it sits in - its own
   fill rather than a tint of whatever is behind - so the row holds its shape
   over any artwork and white text sits crisply on it. */
.friend-pick {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 1rem;
  border-radius: var(--radius);
  background: rgba(15, 23, 42, 0.6);
  border: 1px solid rgba(255, 255, 255, 0.22);
  box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.06),
              0 1px 2px 0 rgba(0, 0, 0, 0.25);
}

/* Only the invite dialog's rows are clickable, so only they light up - and
   only while they can actually be acted on.

   Two guards, both load-bearing:

   @media (hover: hover) keeps this off touch screens entirely. A tap leaves the
   element in :hover until something else is tapped, so without it the row you
   just ticked wears the hover fill and only turns amber once you tick another.

   :not(.is-selected) because :not(:disabled) counts toward specificity - this
   selector is (0,4,0) against .is-selected's (0,2,0), so it would win on a
   mouse too. Excluding the state outright is clearer than fighting the cascade
   with source order. */
@media (hover: hover) {
  .invite-friend:not(:disabled):not(.is-selected):hover {
    background: rgba(30, 41, 59, 0.75);
    border-color: rgba(255, 255, 255, 0.35);
  }
}
/* was: w-4 h-4 accent-amber-400 */
.checkbox { width: 1rem; height: 1rem; accent-color: var(--accent); }
/* was: flex-1 */
.grow { flex: 1 1 0%; }
/* was: font-semibold text-slate-300 text-lg - a player's name is the point of
   the row, so it takes full ink rather than the dimmed variant. */
.friend-pick-name { font-weight: 600; color: var(--ink); font-size: 1.125rem; }

/* was: p-4 rounded-xl bg-white/30 border border-white/40 text-slate-900 text-lg leading-tight */
.info-box {
  padding: 1rem;
  border-radius: var(--radius);
  background: rgba(255, 255, 255, 0.3);
  border: 1px solid rgba(255, 255, 255, 0.4);
  color: #0f172a;
  font-size: 1.125rem;
  line-height: 1.25;
}

/* was: text-slate-900 hover:text-white transition bg-white/30 border border-white/40
        px-6 py-2 rounded-xl */
.btn-ghost {
  color: #0f172a;
  background: rgba(255, 255, 255, 0.3);
  border: 1px solid rgba(255, 255, 255, 0.4);
  padding: 0.5rem 1.5rem;
  border-radius: var(--radius);
  transition: color 0.15s ease;
}
.btn-ghost:hover { color: var(--ink); }
/* was: px-6 py-2 (on top of the primary button treatment) */
.btn-sm { padding: 0.5rem 1.5rem; }


/* ----- marketing CTA ----- */

/* was: absolute inset-0 bg-black/55 backdrop-blur-[2px] */
.hero-veil {
  position: absolute;
  top: 0; right: 0; bottom: 0; left: 0;
  background: rgba(0, 0, 0, 0.55);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
}

/* was: flex flex-col sm:flex-row gap-4 justify-center */
.cta-row { display: flex; flex-direction: column; gap: 1rem; justify-content: center; }
@media (min-width: 640px) { .cta-row { flex-direction: row; } }

/* was: px-6 py-3 rounded-lg bg-amber-400 text-slate-900 font-semibold
        hover:bg-amber-300 transition */
.btn-cta-sm {
  padding: 0.75rem 1.5rem;
  border-radius: var(--radius-sm);
  background: var(--accent);
  color: #0f172a;
  font-weight: 600;
  transition: background 0.15s ease;
}
.btn-cta-sm:hover { background: var(--accent-hover); }

/* was: inline-block px-8 py-4 rounded-xl bg-amber-400 text-slate-900
        font-semibold text-lg hover:bg-amber-300 transition */
.btn-cta-lg {
  display: inline-block;
  padding: 1rem 2rem;
  border-radius: var(--radius);
  background: var(--accent);
  color: #0f172a;
  font-weight: 600;
  font-size: 1.125rem;
  transition: background 0.15s ease;
}
.btn-cta-lg:hover { background: var(--accent-hover); }

/* marketing bands — was: py-20 bg-slate-900 / bg-slate-800 /
   bg-gradient-to-t from-slate-900 to-slate-800 text-center */
.band { padding-top: 5rem; padding-bottom: 5rem; background: var(--bg); }
.band-alt { padding-top: 5rem; padding-bottom: 5rem; background: #1e293b; }
.band-final {
  padding-top: 5rem;
  padding-bottom: 5rem;
  background-image: linear-gradient(to top, #0f172a, #1e293b);
  text-align: center;
}

/* was: max-w-6xl mx-auto px-6 grid md:grid-cols-2 gap-10 items-center */
.band-grid-2 {
  max-width: 72rem;
  margin-left: auto;
  margin-right: auto;
  padding-left: 1.5rem;
  padding-right: 1.5rem;
  display: grid;
  gap: 2.5rem;
  align-items: center;
}
@media (min-width: 768px) { .band-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }

/* was: max-w-6xl mx-auto px-6 grid md:grid-cols-3 gap-8 text-center */
.band-grid-3 {
  max-width: 72rem;
  margin-left: auto;
  margin-right: auto;
  padding-left: 1.5rem;
  padding-right: 1.5rem;
  display: grid;
  gap: 2rem;
  text-align: center;
}
@media (min-width: 768px) { .band-grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } }

/* was: text-3xl font-bold mb-6 */
.band-title { font-size: 1.875rem; font-weight: 700; margin-bottom: 1.5rem; }


/* ----- finished-game results ----- */

/* was: relative overflow-hidden rounded-2xl border border-white/10 backdrop-blur-md
        bg-slate-900/60 shadow-md transition-all duration-300
        hover:-translate-y-0.5 hover:shadow-lg flex items-center gap-4 px-4 py-3 */
/* What each player finished holding, hung under their standings row.
   ------------------------------------------------------------------------
   .result-row is a flex row, so this cannot live inside it - .result-entry
   wraps the pair and becomes the child .stack-3 spaces.

   The collapse is the Skirmish rail panel pattern, deliberately: .collapsed on
   the container, the chevron FIRST in the header, the base polyline pointing
   down and .collapsed rotating it to point right. Anyone who has read that code
   already knows this one. */
.result-entry { position: relative; }

.result-holdings {
  margin-top: 0.5rem;
  border-radius: 1rem;
  border: 1px solid rgba(255, 255, 255, 0.1);
  background: rgba(15, 23, 42, 0.45);
  overflow: hidden;
}

/* A real button, and 44px tall. The Apple HIG audit of 2026-08-28 found 21
   targets under 44x44 - three of them panel headers exactly like this one - so
   this starts where those need to end up rather than adding a fourth. */
.holdings-header {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  width: 100%;
  min-height: 44px;
  padding: 0.5rem 1rem;
  background: none;
  border: 0;
  color: inherit;
  font: inherit;
  font-size: 0.8125rem;
  text-align: left;
  cursor: pointer;
}
.holdings-chevron {
  flex-shrink: 0;
  transition: transform 0.2s ease;
}
.result-holdings.collapsed .holdings-chevron { transform: rotate(-90deg); }
.result-holdings.collapsed .holdings-body    { display: none; }

.holdings-summary { color: rgba(255, 255, 255, 0.75); }

.holdings-body { padding: 0 1rem 1rem; }

/* .cards-grid is one column, going to two at 768px: it was written for the game
   view's narrow rail. This page is a 56rem column with no rail, so the cards
   would be absurdly wide left alone. auto-fill rather than a fixed count, so any
   hand size lays out without a breakpoint each.

   ⚠️ Written as a DESCENDANT selector on purpose. .holdings-grid alone has the
   same specificity as .cards-grid, and the game view's stylesheet is linked
   AFTER the shared one on this page - so an equal-specificity rule here loses on
   source order and silently does nothing. Measured before the fix: 2 columns at
   1024px, which was the game view's own 768px rule winning, not this one. */
.result-holdings .cards-grid {
  grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
  padding-bottom: 0;
}

/* The friend request offered from a standings row.
   ------------------------------------------------------------------------
   Lives in .result-main, the flexible column, rather than as a fourth child of
   the row: .result-row is a flex row and a fifth item would crowd the name and
   the score boxes at 390px.

   .btn-pill is 0.875rem text with 0.375rem of padding - about 32px tall, under
   the 44pt minimum the HIG audit measured against. min-height fixes that without
   touching the pill used on the Friends pages. */
.result-invite {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  margin-top: 0.5rem;
  cursor: pointer;
  border: 0;
}
.result-invite:disabled { cursor: default; }
.result-invite.is-sent {
  background: rgba(255, 255, 255, 0.14);
  color: rgba(255, 255, 255, 0.75);
}

/* A label for the locations group inside a player's block.
   ------------------------------------------------------------------------
   Same family as .section-title - the amber diamond, uppercase, letter-spaced -
   but smaller, muted and without the rule running out. Two reasons it is a label
   rather than a chapter opening: the block already has its own header row right
   above it, and this repeats once per player, so a full .section-title (1rem
   with a rule and 1.5rem beneath) would dominate the cards it is introducing.

   No conditional needed in CSS - the template only emits it inside the
   "has any locations" branch. */
.holdings-subheading {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 1rem 0 0.5rem;
  font-size: 0.75rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.13em;
  color: rgba(255, 255, 255, 0.75);
}
.holdings-subheading::before {
  content: "";
  flex: none;
  width: 0.3125rem;
  height: 0.3125rem;
  background: var(--accent);
  transform: rotate(45deg);
  box-shadow: 0 0 8px rgba(251, 191, 36, 0.6);
}

/* Locations lay out on the same grid as the cards - they carry .cards-grid too,
   so the .result-holdings override above gives them the same tracks. Only the
   separation from the cards above is set here. */
.holdings-locations { margin-top: 0; }

.holdings-empty {
  color: rgba(255, 255, 255, 0.6);
  font-size: 0.8125rem;
  margin: 0;
}

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

.result-row {
  position: relative;
  overflow: hidden;
  border-radius: 1rem;
  border: 1px solid rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  background: rgba(15, 23, 42, 0.6);
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
  transition: all 0.3s ease;
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0.75rem 1rem;
}
/* Pointer only - a tapped row would otherwise stay lifted. See .list-row. */
@media (hover: hover) {
  .result-row:hover {
    transform: translateY(-0.125rem);
    box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
  }
}

/* medal stripe — was: absolute inset-y-0 left-0 w-1 + a FreeMarker-conditional
   bg-gradient-to-b. The conditional now picks a modifier class instead. */
.rank-stripe {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 0.25rem;
}
.rank-gold   { background-image: linear-gradient(to bottom, #facc15, #fcd34d, #fef08a); }
.rank-silver { background-image: linear-gradient(to bottom, #94a3b8, #cbd5e1, #e2e8f0); }
.rank-bronze { background-image: linear-gradient(to bottom, #fb923c, #fcd34d, #fed7aa); }
.rank-none   { background: #e2e8f0; }

/* was: w-10 flex items-center justify-center shrink-0 */
.rank-num {
  width: 2.5rem;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}
/* was: text-3xl */
.medal { font-size: 1.875rem; }
/* was: text-sm text-slate-400 font-semibold */
.rank-label { font-size: 0.875rem; color: var(--ink-faint); font-weight: 600; }

/* was: flex-1 min-w-0 */
.result-main { flex: 1 1 0%; min-width: 0; }
/* was: text-lg md:text-xl font-extrabold text-white truncate */
.result-name {
  font-size: 1.125rem;
  font-weight: 800;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
@media (min-width: 768px) { .result-name { font-size: 1.25rem; } }
/* was: text-xs text-slate-400 tracking-wide uppercase mt-0.5 */
.result-meta {
  font-size: 0.75rem;
  color: var(--ink-faint);
  letter-spacing: 0.025em;
  text-transform: uppercase;
  margin-top: 0.125rem;
}

/* was: flex gap-2 shrink-0 */
.result-stats { display: flex; gap: 0.5rem; flex-shrink: 0; }

/* was: flex flex-col items-center justify-center w-14 h-10 rounded-lg shadow-sm
        + bg-amber-600/90 text-white / bg-slate-700/90 text-amber-300 */
.stat-box {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 3.5rem;
  height: 2.5rem;
  border-radius: var(--radius-sm);
  box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.stat-box-points { background: rgba(217, 119, 6, 0.9); color: var(--ink); }
.stat-box-prestige { background: rgba(51, 65, 85, 0.9); color: var(--accent-hover); }

/* was: text-lg font-bold leading-none */
.stat-value { font-size: 1.125rem; font-weight: 700; line-height: 1; }
/* was: text-[10px] uppercase tracking-wide opacity-70 */
.stat-label {
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.025em;
  opacity: 0.7;
}

/* was: text-center mt-10 */
/* text-align alone could never centre what this holds. Its only content is a
   .btn, and .btn is display:flex - which makes it a BLOCK-level box, while a
   button with width:auto still shrink-wraps. A shrink-wrapped block box sits at
   the start of its line and text-align cannot move it, so the Rematch button on
   the finished-game page sat hard against the left edge: measured 2026-08-28 at
   390px and 1024px, 0px of slack on the left and 182px / 688px on the right.
   The flex centre moves the box; text-align stays for any inline content. */
.center-top {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  margin-top: 2.5rem;
}
/* was: rounded-xl shadow-lg border border-white/10 */
.media-frame {
  border-radius: var(--radius);
  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.1);
}
/* was: text-2xl */
.emoji-lg { font-size: 1.5rem; }


/* ----- friends / find-a-friend ----- */

/* was: max-w-lg mx-auto mb-10 relative */
.search-wrap {
  max-width: 32rem;
  margin-left: auto;
  margin-right: auto;
  margin-bottom: 2.5rem;
  position: relative;
}

/* was: absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-600
   Find a Friend adds text-gray-500 + pointer-events-none via .search-icon-muted */
.search-icon {
  position: absolute;
  left: 1rem;
  top: 50%;
  transform: translateY(-50%);
  width: 1.25rem;
  height: 1.25rem;
  color: #475569;
}
.search-icon-muted {
  color: #6b7280;
  pointer-events: none;
}

/* was: divide-y divide-slate-200/60 */
.list-divided > * + * {
  border-top: 1px solid rgba(226, 232, 240, 0.6);
}

/* was: flex items-center gap-4 */
.row-center {
  display: flex;
  align-items: center;
  gap: 1rem;
}
/* was: flex items-center gap-3 */
.row-center-tight {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}
/* was: flex gap-2 */
.row-actions {
  display: flex;
  gap: 0.5rem;
}

/* was: font-semibold text-slate-900 text-lg leading-tight */
.friend-name {
  font-weight: 600;
  color: #0f172a;
  font-size: 1.125rem;
  line-height: 1.25;
}

/* was: text-sm italic text-slate-600 */
.friend-note {
  font-size: 0.875rem;
  font-style: italic;
  color: #475569;
}

/* was: text-sm font-medium text-amber-600 */
.status-pending {
  font-size: 0.875rem;
  font-weight: 500;
  color: #d97706;
}

/* was: text-sm font-semibold text-green-600 */
.status-accepted {
  font-size: 0.875rem;
  font-weight: 600;
  color: #16a34a;
}

/* was: text-center text-slate-200 italic mt-10 */
.empty-note {
  text-align: center;
  color: #e2e8f0;
  font-style: italic;
  margin-top: 2.5rem;
}


/* was: w-full py-3 pl-12 pr-28 rounded-full bg-white/80 text-black
        border border-white/40 focus:outline-none focus:ring-2 focus:ring-amber-400 */
.search-input {
  width: 100%;
  padding: 0.75rem 7rem 0.75rem 3rem;
  border-radius: 9999px;
  background: rgba(255, 255, 255, 0.8);
  color: #000000;
  border: 1px solid rgba(255, 255, 255, 0.4);
}
.search-input:focus {
  outline: none;
  box-shadow: 0 0 0 2px var(--accent);
}

/* was: absolute right-2.5 top-1/2 -translate-y-1/2 bg-amber-400 text-black
        font-semibold px-5 py-2 rounded-full hover:bg-amber-300 transition */
.search-submit {
  position: absolute;
  right: 0.625rem;
  top: 50%;
  transform: translateY(-50%);
  background: var(--accent);
  color: #000000;
  font-weight: 600;
  padding: 0.5rem 1.25rem;
  border-radius: 9999px;
  transition: background 0.15s ease;
}
.search-submit:hover { background: var(--accent-hover); }

/* was: bg-white/60 backdrop-blur-xs rounded-2xl shadow-xl border border-white/40 overflow-hidden */
.friend-list {
  background: rgba(255, 255, 255, 0.6);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  border-radius: 1rem;
  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.4);
  overflow: hidden;
}

/* was: flex items-center justify-between px-6 py-4 hover:bg-white/60 transition-colors */
.friend-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1rem 1.5rem;
  transition: background-color 0.15s ease;
}
/* Pointer only - a tapped row would otherwise stay lit. See .list-row. */
@media (hover: hover) {
  .friend-row:hover { background: rgba(255, 255, 255, 0.6); }
}

/* was: w-11 h-11 rounded-full bg-gradient-to-br from-amber-300 to-amber-400
        text-slate-900 flex items-center justify-center font-bold text-sm
        shadow-md ring-1 ring-amber-500/40 */
.friend-avatar {
  width: 2.75rem;
  height: 2.75rem;
  border-radius: 9999px;
  background-image: linear-gradient(to bottom right, #fcd34d, #fbbf24);
  color: #0f172a;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 700;
  font-size: 0.875rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1),
              0 0 0 1px rgba(245, 158, 11, 0.4);
}

/* was: px-4 py-1.5 rounded-full text-sm font-medium shadow transition
        + bg-green-500 hover:bg-green-600 / bg-red-500 hover:bg-red-600 */
.btn-pill {
  padding: 0.375rem 1rem;
  border-radius: 9999px;
  color: var(--ink);
  font-size: 0.875rem;
  font-weight: 500;
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
  transition: background 0.15s ease;
}
.btn-pill-accept { background: #22c55e; }
.btn-pill-accept:hover { background: #16a34a; }
.btn-pill-decline { background: #ef4444; }
.btn-pill-decline:hover { background: #dc2626; }
/* the amber "add friend" pill on Find a Friend — text-slate-900 not white */
.btn-pill-add { background: var(--accent); color: #0f172a; }
.btn-pill-add:hover { background: var(--accent-hover); }


/* ----- notifications toggle -----
   Replaces Tailwind's `peer` mechanism. The original used
   peer-checked:/peer-disabled:/peer-focus-visible: variants on siblings
   of a `.sr-only peer` checkbox; those become real sibling selectors. */

/* was: flex items-center justify-between gap-4 cursor-pointer */
.toggle-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  cursor: pointer;
}

/* was: text-slate-200 font-medium */
.toggle-label {
  color: #e2e8f0;
  font-weight: 500;
}

/* was: relative inline-flex shrink-0 */
.toggle-switch {
  position: relative;
  display: inline-flex;
  flex-shrink: 0;
}

/* was: sr-only peer */
.toggle-input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

/* was: w-12 h-7 rounded-full bg-white/20 border border-white/30 transition-colors */
.toggle-track {
  width: 3rem;
  height: 1.75rem;
  border-radius: 9999px;
  background: rgba(255, 255, 255, 0.2);
  border: 1px solid rgba(255, 255, 255, 0.3);
  transition: background-color 0.15s ease, border-color 0.15s ease;
}
/* was: peer-checked:bg-amber-400 peer-checked:border-amber-300 */
.toggle-input:checked ~ .toggle-track {
  background: var(--accent);
  border-color: var(--accent-hover);
}
/* was: peer-disabled:opacity-50 peer-disabled:cursor-default */
.toggle-input:disabled ~ .toggle-track {
  opacity: 0.5;
  cursor: default;
}
/* was: peer-focus-visible:ring-2 peer-focus-visible:ring-amber-400 */
.toggle-input:focus-visible ~ .toggle-track {
  box-shadow: 0 0 0 2px var(--accent);
}

/* was: pointer-events-none absolute top-1 left-1 w-5 h-5 rounded-full
        bg-slate-200 shadow transition-transform */
.toggle-knob {
  pointer-events: none;
  position: absolute;
  top: 0.25rem;
  left: 0.25rem;
  width: 1.25rem;
  height: 1.25rem;
  border-radius: 9999px;
  background: #e2e8f0;
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
  transition: transform 0.15s ease, background-color 0.15s ease;
}
/* was: peer-checked:translate-x-5 peer-checked:bg-slate-900 */
.toggle-input:checked ~ .toggle-knob {
  transform: translateX(1.25rem);
  background: #0f172a;
}

/* was: max-w-lg mx-auto bg-black/40 backdrop-blur-md rounded-2xl shadow-xl
        border border-white/20 p-6
   Now a sibling of .form-panel on the Profile page, so it matches that panel's
   geometry and treatment exactly (28rem, no border, no shadow) and carries the
   gap between the two sections. Only the Profile page uses this class.
   The Home page's notifications nudge deliberately does NOT use this - it takes
   the wider .pwa-prompt/.panel geometry so it matches the install card it can
   appear alongside. */
.notif-card {
  max-width: 28rem;
  margin-left: auto;
  margin-right: auto;
  margin-top: 1.5rem;
  background: rgba(0, 0, 0, 0.4);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border-radius: 1rem;
  padding: 1.5rem;
}

/* was: text-lg font-semibold text-amber-300 */
.notif-title {
  font-size: 1.125rem;
  font-weight: 600;
  color: var(--accent-hover);
}

/* was: text-sm leading-relaxed text-slate-300 */
.notif-body {
  font-size: 0.875rem;
  line-height: 1.625;
  color: var(--ink-dim);
}

/* was: max-w-lg mx-auto mt-6 flex flex-col gap-3 */
.notif-actions {
  max-width: 32rem;
  margin-left: auto;
  margin-right: auto;
  margin-top: 1.5rem;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}


/* ----- navbar ----- */

/* was: fixed left-4 z-50 w-10 h-10 rounded-full bg-white/90 backdrop-blur
        flex items-center justify-center shadow hover:scale-105 transition border-2 border-white/30 */
.nav-home {
  position: fixed;
  left: 1rem;
  z-index: 50;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: 9999px;
  /* ⚠️ NO backdrop-filter - see .nav-avatar below for why. Already almost
     opaque, so losing the blur costs this one almost nothing. */
  background: rgba(255, 255, 255, 0.96);
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
  border: 2px solid rgba(255, 255, 255, 0.3);
  transition: transform 0.15s ease;
}
/* Pointer only - on the navbar of every page, so a tap left the logo sitting
   5% larger. See .list-row. */
@media (hover: hover) {
  .nav-home:hover { transform: scale(1.05); }
}

/* was: w-10 h-10 rounded-full object-cover */
.nav-home-img {
  width: 2.5rem;
  height: 2.5rem;
  border-radius: 9999px;
  object-fit: cover;
}

/* was: fixed right-4 z-50 */
.nav-menu-wrap {
  position: fixed;
  right: 1rem;
  z-index: 50;
}

/* was: z-50 w-10 h-10 rounded-full bg-black/40 backdrop-blur-md border-2 border-white/30
        flex items-center justify-center text-white font-semibold shadow-md hover:bg-black/60 transition */
.nav-avatar {
  z-index: 50;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: 9999px;
  /* ⚠️ NO backdrop-filter, on purpose - 2026-08-28.
     -----------------------------------------------------------------------
     Andy's installed PWA loses fixed positioning entirely after the app is
     switched away from and back: fixed elements stop staying put and scroll
     with the page. It survives a reload and affects every game, so it is web
     view state created by the app-switcher restore.

     backdrop-filter forces an element onto its own compositing layer, which is
     the machinery iOS has to rebuild from its snapshot - and every fixed
     element that misbehaved carried it. This is the navbar's share of that
     change; the game view's HUD and view-toggle buttons lost theirs at the same
     time.

     .nav-avatar is not itself fixed, but it sits inside .nav-menu-wrap which is
     - so the layer is created INSIDE a fixed element, which is the same
     arrangement.

     Background raised from 0.4 to carry the contrast the blur used to. */
  background: rgba(0, 0, 0, 0.78);
  border: 2px solid rgba(255, 255, 255, 0.3);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--ink);
  font-weight: 600;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
  transition: background 0.15s ease;
}
.nav-avatar:hover { background: rgba(0, 0, 0, 0.6); }

/* was: absolute right-0 mt-2 w-48 rounded-xl bg-black/70 backdrop-blur-xl
        border border-white/20 shadow-xl text-sm text-slate-100
   NOTE: the `hidden` class stays on the element in markup — JS toggles it. */
.nav-menu {
  position: absolute;
  right: 0;
  margin-top: 0.5rem;
  width: 12rem;
  border-radius: var(--radius);
  background: rgba(0, 0, 0, 0.7);
  backdrop-filter: blur(24px);
  -webkit-backdrop-filter: blur(24px);
  border: 1px solid rgba(255, 255, 255, 0.2);
  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
  font-size: 0.875rem;
  color: #f1f5f9;
}

/* was: block px-4 py-3 hover:bg-white/10 transition */
.nav-menu-item {
  display: block;
  padding: 0.75rem 1rem;
  transition: background 0.15s ease;
}
.nav-menu-item:hover { background: rgba(255, 255, 255, 0.1); }

/* the logout entry — adds text-amber-300 */
.nav-menu-item-alt { color: var(--accent-hover); }

/* was: border-t border-white/20 */
.nav-menu-divider { border-top: 1px solid rgba(255, 255, 255, 0.2); }

/* was: fixed inset-0 h-screen w-screen bg-cover bg-center z-0 pointer-events-none (renamed from .bg-image: the bg- prefix collides with utility greps) */
.page-bg {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  height: 100vh;
  width: 100vw;
  background-size: cover;
  background-position: center;
  z-index: 0;
  pointer-events: none;
}

/* layout helpers */
.col {
  display: flex;
  flex-direction: column;
}

.stack {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.row {
  display: flex;
  gap: 0.75rem;
}

/* was: w-20 h-20 rounded-full shadow-md border-4 border-indigo-500
   object-cover mx-auto */
.img-icon-lg {
  width: 5rem;
  height: 5rem;
  border-radius: 9999px;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
  border: 4px solid var(--campaign);
  object-fit: cover;
  margin-left: auto;
  margin-right: auto;
}

.lobby-overlay {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #0f172a;
  transition: opacity 0.5s;
}

/* Lets a <form> wrap a button without becoming a flex item itself, so the button
   still sits directly in .action-row alongside the Join Game link. */
.action-form {
  display: contents;
}

/* The module toggles button visibility with the hidden property; class rules
   that set display would otherwise win. */
[hidden] { display: none !important; }




/* .friend-pick on a <button>: buttons do not inherit the page font.
   space-between parks the tick against the right edge, opposite the name. */
.invite-friend {
  width: 100%;
  text-align: left;
  font: inherit;
  color: inherit;
  justify-content: space-between;
}

/* The tick is a pseudo-element rather than markup so it costs the template
   nothing and stays out of the JS - and as a CSS escape it survives being
   served as Latin-1, which a literal U+2713 in a .js file would not.
   Always present but transparent, so ticking a row cannot reflow the list. */
.invite-friend::after {
  content: "\2713";
  font-size: 1.125rem;
  font-weight: 700;
  line-height: 1;
  opacity: 0;
  transition: opacity 0.15s ease;
}

/* Ticked: about to be invited. */
.invite-friend.is-selected::after {
  opacity: 1;
  color: #000000;
}

/* Already in the lobby - invited just now, or arrived under their own steam
   with the code. Dimmed and inert, distinguished from a merely blocked row by
   the tick. */
.invite-friend.is-invited {
  opacity: 0.55;
  cursor: default;
}

.invite-friend.is-invited .friend-pick-name {
  color: var(--ink-dim);
}

.invite-friend.is-invited::after {
  opacity: 1;
  color: var(--accent);
}

/* Tappable in principle, but every free slot is already spoken for. No tick:
   that is what separates it from an invited row. */
.invite-friend.is-blocked {
  opacity: 0.4;
  cursor: not-allowed;
}

/* Explains the blocked rows above it. */
.invite-slots {
  margin-top: 0.75rem;
  font-size: 0.8125rem;
  color: var(--ink-dim);
  text-align: center;
}


/* Separates the invite action from the players list above it. Matches the
   spacing .invite-dialog-actions uses for the same content-then-action shape. */
.btn-invite {
  margin-top: 1.25rem;
}

/* Invite dialog.
   The flex layout must be scoped to [open]: author `display` beats the UA's
   `dialog:not([open]) { display: none }` regardless of specificity, so an
   unscoped `display: flex` would leave the dialog visible while closed. */
.invite-dialog {
  /* A modal dialog centres itself through the UA's `margin: auto`, which any
     author rule can quietly outrank. These dialogs sit inside .lobby-wrap in
     the markup, so `.lobby-wrap > * + *` was giving them margin-top: 1.5rem
     and pinning them to the top of the screen - rendering in the top layer
     does not exempt an element from rules matching where it sits in the DOM.
     Stating the centring here puts it out of reach of whatever container a
     dialog is dropped into. */
  margin: auto;
  width: 91.666667%;
  max-width: 24rem;
  max-height: calc(100dvh - 2rem);
  padding: 0;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  color: var(--ink);
}

.invite-dialog[open] {
  display: flex;
  flex-direction: column;
}

.invite-dialog::backdrop {
  background: rgba(0, 0, 0, 0.7);
}

/* The dialog holds focus itself while it opens so that no control inside
   starts out ringed, but the dialog is a container - there is nothing for a
   ring on it to tell anyone. */
.invite-dialog:focus {
  outline: none;
}

/* Keyboard focus keeps its ring, in the app's amber rather than the browser's
   blue. :focus-visible matches keyboard focus only, so tapping a control never
   draws one - which is the whole complaint - while tabbing through always
   does. */
.invite-dialog :focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.invite-dialog-title {
  flex: 0 0 auto;
  margin: 0;
  padding: 1.25rem 1.5rem 0.75rem;
  font-size: 1.25rem;
  font-weight: 700;
  color: var(--ink-dim);
}

/* min-height: 0 is what actually lets this shrink and scroll - without it a
   flex child refuses to go below its content height and the dialog grows off
   the bottom of the screen instead. */
.invite-dialog-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  padding: 0 1.5rem;
}

/* House rule: the way out sits bottom left, the action that commits sits bottom
   right. space-between rather than flex-end, so they take opposite corners
   instead of huddling together on the right. Markup order is already
   cancel-then-confirm, which is also the order a screen reader and the tab key
   follow. */
.invite-dialog-actions {
  flex: 0 0 auto;
  display: flex;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 1rem 1.5rem 1.25rem;
}

/* Page-sized buttons are too heavy in a 24rem dialog - at full size the pair
   nearly filled the row. Scaled down here rather than on the variants, since
   the same classes have to stay full size out on the page. Targets the element
   so it catches .btn and .btn-white alike. */
.invite-dialog-actions > button {
  padding: 0.5rem 1.125rem;
  font-size: 0.9375rem;
}

.invite-dialog-actions .btn:disabled {
  opacity: 0.45;
}

.invite-friend.is-selected {
  background: var(--accent);
  border-color: var(--accent);
  color: #000000;
}

/* The name is in a child span with its own colour, so the selected row's
   black-on-amber has to be set here too or the name keeps the unselected
   colour and sits illegibly on the amber fill. */
.invite-friend.is-selected .friend-pick-name {
  color: #000000;
}

/* The invite dialog with no friends to list. Deliberately not .info-box: that
   shape is a light panel with dark text, which inside this dark dialog came out
   as grey on grey - and it is shared with Campaign, so it is left alone.
   The code reuses .code-share-code from the strip further down the page, so the
   same value looks the same wherever the host meets it. */
.invite-empty {
  text-align: center;
  padding: 0.5rem 0 0.25rem;
}

/* For a button whose label changes while it sits there: it keeps one width, so
   the row does not twitch under the finger as the wording changes.

   Both buttons in this dialog do it. "Invite" is 80px and "Invite (1)" 103px,
   and the row is space-between, so the button is anchored right and its left
   edge jumped 23px on every select and deselect - which, inside a dialog with
   a backdrop-filter, is what leaves the ghost of the wider button behind for a
   frame. "Copy code" against "Copied!" is the same 20px problem.

   7.5rem clears the widest of those. The failure message is longer still and
   will push past it, which is fine - it is rare, and being pushed about is the
   least of your problems at that point. */
.btn-steady-label {
  min-width: 7.5rem;
}

.invite-empty-text {
  color: var(--ink-dim);
  line-height: 1.4;
  margin-bottom: 0.875rem;
}

/* Holds the page still behind the modal. showModal() already makes it inert;
   this stops it scrolling, which iOS otherwise allows. */
body.dialog-open {
  position: fixed;
  width: 100%;
  overflow: hidden;
}


/* ----- home chapter plate -----

   The home header presents the current season the way a chronicle presents a
   chapter: the wordmark, a drawn rule, then the season's name as the chapter
   line. Season names carry the weight here ("Summer Siege", "The Fall of
   Hallowmere"), so they get the treatment and the "Current Season:" label
   goes - the position says what it is.

   Home only. Every other page's h1 is a functional label ("Friends", "How to
   play") and keeps .page-title / .page-subtitle unchanged.

   Named .chronicle-* rather than .hero-*: .hero-shell and .hero-veil already
   belong to the marketing CTA page. */

/* The seasonal artwork behind the header is not a neutral ground - Summer
   Siege is a gold-lit castle, and amber on gold is close to invisible. So the
   plate carries its own pool of shadow: a soft ellipse, fully transparent by
   its edges, sitting behind the type only. No hard edge to notice, and it
   works whatever the next season's artwork turns out to be.

   Paired with z-index: 1 on the three children rather than a negative index,
   so nothing depends on how .page's stacking context resolves. */
.chronicle-plate::before {
  content: "";
  position: absolute;
  top: -2.5rem;
  /* Exactly .page's 1.5rem padding, no more: the scrim then reaches the
     viewport edge on a phone and stops there. At -2rem it overhung by 8px,
     which an absolutely positioned box turns into real horizontal scroll. */
  right: -1.5rem;
  bottom: -2.5rem;
  left: -1.5rem;
  z-index: 0;
  pointer-events: none;

  /* Both radii must be under 50%+a bit of the box, or the ellipse runs past
     the box and gets clipped - which shows up as a straight dark edge across
     the artwork. The negative insets above buy the room for the fade. */
  background: radial-gradient(62% 55% at 50% 50%,
                              rgba(2, 6, 23, 0.78) 0%,
                              rgba(2, 6, 23, 0.5) 46%,
                              rgba(2, 6, 23, 0) 82%);
}

.chronicle-title,
.chronicle-rule,
.chronicle-season {
  position: relative;
  z-index: 1;
}

.chronicle-title {
  font-size: 2.25rem;
  font-weight: 800;
  letter-spacing: -0.025em;
  line-height: 1.1;
  margin-bottom: 1.25rem;

  /* White at the cap line falling to amber at the baseline. The solid colour
     is the fallback and must stay: without background-clip the text below
     would render transparent, i.e. invisible. */
  color: var(--ink);
  background-image: linear-gradient(180deg,
                                    #ffffff 0%,
                                    #ffffff 42%,
                                    #fde68a 76%,
                                    var(--accent) 100%);

  /* drop-shadow, not text-shadow: once -webkit-text-fill-color is transparent
     a text-shadow paints straight through the glyphs. filter applies to what
     was actually rasterised, so the letters keep an edge over the seasonal
     artwork. The second shadow is the amber glow. */
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.75))
          drop-shadow(0 0 14px rgba(251, 191, 36, 0.28));
}

@supports (-webkit-background-clip: text) or (background-clip: text) {
  .chronicle-title {
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
    color: transparent;
  }
}

/* Both halves fade out at their far ends, so the rule reads as drawn rather
   than as a border that got cut off. */
.chronicle-rule {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  max-width: 20rem;
  margin: 0 auto 0.875rem;
}

.chronicle-rule::before,
.chronicle-rule::after {
  content: "";
  flex: 1 1 0%;
  height: 1px;
}

.chronicle-rule::before {
  background: linear-gradient(90deg, rgba(251, 191, 36, 0) 0%, rgba(251, 191, 36, 0.65) 100%);
  transform-origin: right center;
}

.chronicle-rule::after {
  background: linear-gradient(90deg, rgba(251, 191, 36, 0.65) 0%, rgba(251, 191, 36, 0) 100%);
  transform-origin: left center;
}

/* A rotated square, not a diamond glyph: nothing to survive an encoding
   round-trip, and it takes the amber glow cleanly. */
.chronicle-mark {
  display: block;
  flex: none;
  width: 0.4375rem;
  height: 0.4375rem;
  background: var(--accent);
  transform: rotate(45deg);
  box-shadow: 0 0 10px rgba(251, 191, 36, 0.7);
}

/* Small and widely tracked against a 3rem wordmark - the scale gap is what
   makes it read as a chapter line rather than a subtitle. text-transform, not
   literal capitals, so the h2 still says "Summer Siege" to a screen reader.
   text-indent offsets the trailing letter-space of the final character, which
   would otherwise sit the whole line half a space left of centre. */
.chronicle-season {
  font-size: 0.8125rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.22em;
  text-indent: 0.22em;
  color: var(--accent);
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
}

@media (min-width: 768px) {
  .chronicle-title { font-size: 3rem; }

  .chronicle-season {
    font-size: 1.0625rem;
    letter-spacing: 0.28em;
    text-indent: 0.28em;
  }
}

/* Drawn once on load, in the order you would draw it: wordmark, then the mark,
   then the two rules outward from it, then the season. One short sequence and
   no more - this is the page a player lands on several times a day. */
@keyframes chronicle-rise {
  from { opacity: 0; transform: translateY(0.5rem); }
  to   { opacity: 1; transform: translateY(0); }
}

@keyframes chronicle-draw {
  from { opacity: 0; transform: scaleX(0); }
  to   { opacity: 1; transform: scaleX(1); }
}

@keyframes chronicle-mark-in {
  from { opacity: 0; transform: rotate(45deg) scale(0.3); }
  to   { opacity: 1; transform: rotate(45deg) scale(1); }
}

.chronicle-title  { animation: chronicle-rise 0.45s ease-out both; }
.chronicle-mark   { animation: chronicle-mark-in 0.35s ease-out 0.12s both; }
.chronicle-season { animation: chronicle-rise 0.45s ease-out 0.36s both; }

.chronicle-rule::before,
.chronicle-rule::after {
  animation: chronicle-draw 0.5s ease-out 0.2s both;
}

@media (prefers-reduced-motion: reduce) {
  .chronicle-title,
  .chronicle-mark,
  .chronicle-season,
  .chronicle-rule::before,
  .chronicle-rule::after {
    animation: none;
  }
}


/* ----- home action pair, finish -----

   Appended at the end of the file on purpose. These are (0,2,0) compounds that
   must outrank .btn-primary / .btn-secondary and their :hover at (0,1,1), and
   source order settles anything that ties.

   The problem being fixed: two flat slabs under a generic shadow, next to a
   header that now has a gradient, a glow and hairlines. These give the pair the
   same lit-object treatment without changing its shape.

   Note both variants set background-IMAGE only. The base rules set the
   background shorthand, so the flat colour stays underneath as the fallback,
   and .btn-secondary's hover and press - which change background-color - keep
   working untouched. */

.btn-primary.btn-flex {
  background-image: linear-gradient(180deg, #fcd34d 0%, var(--accent) 55%, #f0a91c 100%);

  /* Top highlight, a hard bottom edge, then amber bled into the shadow so the
     button looks like it is lit rather than sitting on a grey blur. */
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45),
              0 1px 0 rgba(0, 0, 0, 0.35),
              0 8px 20px -8px rgba(251, 191, 36, 0.55);

  /* Warm near-black. Pure #000 on amber is a harder edge than the rest of the
     page has anywhere. */
  color: #1c1207;
}

.btn-primary.btn-flex:hover {
  background-image: linear-gradient(180deg, #fde68a 0%, var(--accent-hover) 55%, #f5b528 100%);
}

/* Pressed: the gradient flips dark-at-top and the lift becomes an inset, so it
   reads as pushed in rather than just recoloured. */
.btn-primary.btn-flex:active {
  background-image: linear-gradient(180deg, #e59a10 0%, #f0a91c 100%);
  box-shadow: inset 0 2px 5px rgba(69, 39, 3, 0.5);
}

.btn-secondary.btn-flex {
  background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.09) 0%, rgba(255, 255, 255, 0) 62%);

  /* Amber-tinted hairline: ties the quiet half of the pair to the marks and
     rules above it without making a second amber button. */
  border-color: rgba(251, 191, 36, 0.35);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18),
              0 1px 0 rgba(0, 0, 0, 0.35),
              0 8px 20px -8px rgba(2, 6, 23, 0.85);
}

.btn-secondary.btn-flex:active {
  box-shadow: inset 0 2px 5px rgba(2, 6, 23, 0.7);
}

/* The Heroicons "login" glyph points its arrow leftwards, which reads as
   leaving. Mirrored, the doorway lands on the right and the arrow travels
   left-to-right into it - going in, which is what Join Game does. */
.icon-flip {
  transform: scaleX(-1);
}

/* Marketing CTA hero only. Deliberately smaller and lighter than the
   .page-subtitle it replaced: the plate above it ends on the amber season
   line, and a heavier line straight underneath stops that reading as the
   flourish it is meant to be. It lives inside .chronicle-plate so the scrim
   covers it - outside it measured 3.7:1 over the artwork - and keeps a shadow
   of its own for the busiest patches. */
.hero-tagline {
  font-size: 1.125rem;

  /* Warm off-white, not --ink-dim. slate-300 is a cool grey, and against warm
     artwork under a white-to-amber wordmark it reads as dirty rather than
     quiet. This keeps it clearly below the wordmark without going cold. */
  color: #efe3cf;

  /* Zero, not 2rem: it is the last child of .page-header, which already
     supplies the 3rem gap down to the Log In button. */
  margin-top: 1.5rem;
  margin-bottom: 0;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);

  /* Own stacking context so the scrim below can sit behind the text only. */
  position: relative;
  z-index: 1;
}

/* The tagline needs its own ground - the plate's does not reach it.
   .chronicle-plate::before is an ellipse sized in percentages of the plate,
   and its last stop is at 82%, so it is fully transparent by 0.82 x 55% = 45%
   of the box height from the centre. The tagline sits at ~90% of that height,
   i.e. outside the pool entirely, on open foliage. Measured over logo.jpg the
   ground under the type was mean 0.030 luminance but sdev 0.038 with a p99 of
   0.231 - bright branches directly behind thin 18px type, worst-case contrast
   2.5:1. That is the "grey and blurred" look: parts of the line genuinely
   disappear and the eye reads the whole thing as smeared.

   Enlarging the plate scrim does not fix it. Alpha does nothing out at the
   fade (measured: p99 0.57 with the stops pushed to 0.92/0.80), and growing
   the box via its insets needs -14rem to get p99 only as far as 0.22, by which
   point the pool has swallowed the whole hero.

   So: a second pool, same vocabulary, sized to this line.

   Two constraints it has to respect, both measured:
   - Horizontal insets are ZERO. The <p> is already full plate width, so it
     needs none, and any negative side inset here overhangs the viewport on a
     phone and becomes real horizontal scroll - .page-header's 1rem plus
     .login-inner's 1.5rem is all the slack there is.
   - The radius cannot exceed 62%. A radial-gradient still opaque where its box
     ends gets clipped, and that shows as a straight edge over the artwork.
     The rule is (last stop) x (radius) <= 50%: at 80% x 62% = 49.6% it fades
     out just inside the edge. This is where the plate's own 62% came from.
     Verified on a flat ground - 62% steps 0.004, 80% steps 0.120 and 88%
     steps 0.147, i.e. plainly visible bars.

   Result under the type: mean 0.006, sdev 0.006, p99 0.042, worst-case
   contrast 9.0:1 - a flatter, darker ground than the season line above it
   (0.018 / 0.012 / 0.055), which is the line already agreed to read correctly. */
.hero-tagline::before {
  content: "";
  position: absolute;
  z-index: -1;
  top: -2.5rem;
  right: 0;
  bottom: -2.5rem;
  left: 0;
  pointer-events: none;
  background: radial-gradient(62% 58% at 50% 50%,
                              rgba(2, 6, 23, 0.88) 0%,
                              rgba(2, 6, 23, 0.66) 45%,
                              rgba(2, 6, 23, 0) 80%);
}

/* Marketing CTA only. The shared plate scrim is cut for the home page, where
   the plate ends at the season line. Here the box is taller, and the same
   ellipse leaves the wordmark out at the fade - it measured 4.2:1 against the
   hero artwork, down from 5.6:1.

   NOTE: this does NOT cover the tagline, whatever the alpha. The tagline is
   outside the ellipse entirely and carries its own scrim - see
   .hero-tagline::before at the end of this file.
   Alpha is the only safe knob: growing either radius pushes the ellipse past
   the box and Chrome clips it into a straight dark edge across the artwork
   (measured 0.16 luminance step at 70%/64%, against 0.02 here). Geometry
   therefore stays exactly as the home page has it; only the stops get darker.
   Measured after this change - wordmark 5.5:1, season line 9.0:1, tagline
   8.4:1, all against logo.jpg at 1200px. */
.hero-shell .chronicle-plate::before {
  background: radial-gradient(62% 55% at 50% 50%,
                              rgba(2, 6, 23, 0.88) 0%,
                              rgba(2, 6, 23, 0.64) 46%,
                              rgba(2, 6, 23, 0) 82%);
}

/* Login page only. The plate here is the shortest of the three - wordmark,
   rule, season line, no tagline - and that is exactly what makes it the
   hardest. The season line is the LAST thing in the box, so it sits against
   the bottom edge where the ellipse has already faded; on the CTA hero the
   tagline underneath extends the box and re-centres it, which is the only
   reason that page does not have this problem.

   Inheriting the home stops measured mean 0.037 / sdev 0.033 / p99 0.176 under
   the season line at 768px, against the 0.016 / 0.011 / 0.050 of the CTA line
   already agreed to read correctly - three times the busyness, which is the
   "grey and blurred" signature from the CTA tagline.

   ALPHA ALONE CANNOT FIX IT, for the reason recorded on .hero-tagline::before:
   alpha does nothing outside the fade. Pushed to 0.95/0.80 the season line was
   still at p99 0.212 at 390px. Nor can the bottom inset alone - growing it
   walks the ellipse's centre down and trades the wordmark away for the season
   line (at -7rem the title went to p99 0.614 at 768px).

   What works is growing BOTH insets: a physically larger pool, still centred
   on the type, so the two lines move toward the middle together. At -8rem,
   measured against the CTA benchmark (mean / sdev / p99):

       390px   title  0.0119/0.0076/0.0398   vs  0.0270/0.0407/0.2284
               season 0.0131/0.0146/0.0807   vs  0.0224/0.0186/0.0769
       768px   title  0.0244/0.0352/0.1853   vs  0.0480/0.0626/0.3133
               season 0.0101/0.0067/0.0364   vs  0.0162/0.0107/0.0500
       1200px  title  0.0145/0.0111/0.0530   vs  0.0348/0.0350/0.1729
               season 0.0092/0.0053/0.0291   vs  0.0154/0.0106/0.0472

   Geometry is untouched at 62% 55% with the last stop at 82%, the same as home
   and the CTA: 0.82 x 55% = 45%, inside the (last stop) x (radius) <= 50%
   no-clip rule. Horizontal insets stay at -1.5rem - .login-inner's padding and
   no more, or the box overhangs the viewport on a phone and becomes real
   horizontal scroll. Only the vertical insets and the stops move.

   Re-measure with tests/scrim-check.py if the plate's contents ever change:
   these numbers are cut for a three-line plate. */
.login-shell .chronicle-plate::before {
  top: -8rem;
  bottom: -8rem;
  background: radial-gradient(62% 55% at 50% 50%,
                              rgba(2, 6, 23, 0.92) 0%,
                              rgba(2, 6, 23, 0.72) 46%,
                              rgba(2, 6, 23, 0) 82%);
}
