/*
 * Hype Climb — hand-written theme, "Forecast" palette.
 * Self-contained: system font stack, no CDN fonts/images/scripts. Cool
 * slate-navy chrome with an instrument-cyan brand; every warm hue is reserved
 * for data so a warm colour on screen always means a number, never
 * decoration. CSS nesting, :has and light-dark are allowed (allow_browser
 * versions require Safari 17.5 / Chrome 123 / Firefox 120).
 */

:root {
  /* Tell the UA which theme its own chrome should follow -- scrollbars (the
     page one and the one inside a select's picker) and native control glyphs
     were rendering light-on-light against a dark page. `light dark` here is
     the SYSTEM default: it defers to prefers-color-scheme, same as every
     light-dark token below. An explicit `data-theme` on <html> (bottom
     of this block) overrides it per-visitor. */
  color-scheme: light dark;

  /* never register any colour token below
     with `@property { syntax: "<color>" }`. A REGISTERED colour property
     computes on the DECLARING element, not the element that reads it, which
     would freeze every light-dark here to :root's own colour-scheme and
     silently break the always-dark .game-hero: its descendants would
     stop seeing the hero's explicit dark re-declarations and render whatever
     :root's scheme happens to be instead. Every token below must stay an
     *unregistered* custom property. */

  /* chrome: cool */
  --surface:           light-dark(#eef2f7, #0a0e14);
  --surface-raised:    light-dark(#ffffff, #121822);
  /* Darkened from #e2e8f0 so the light-theme
     hypemeter track (below) clears 3:1 against every tier's fill, including
     the lowest band (--hype-low). See the "hype meter fills stay visible"
     contrast test. */
  --surface-overlay:   light-dark(#d7dfe9, #1b2330);
  --border:            light-dark(#cfd8e3, #2a3444);
  --text:              light-dark(#0f1720, #e6ecf4);
  --text-muted:        light-dark(#566173, #8d9bb0);
  --neutral-chip:      light-dark(#dde5ef, #253244);
  --neutral-chip-text: light-dark(#415066, #a9b8cc);

  /* brand / interactive ONLY -- never a tier of a data scale, never
     "success". */
  --accent:            light-dark(#0e7490, #22b8d4);
  --accent-strong:     light-dark(#0b5566, #63d9ee);
  --accent-contrast:   light-dark(#ffffff, #04121a);

  /* state (chrome semantics)
     warn and --good's light values are deepened, and --danger's dark
     value is brightened, past what reads as "correct" in isolation, because
     each is also used as INK on its own 22%-and-below tint (badges, admin
     status, triage chips): at that blend the composited background sits
     mostly at the surface's own colour, so the ink needs real contrast
     margin against its bare-surface value, not just against the pure hue.
     test/helpers/palette_contrast_test.rb is what enforces this now --
     re-tune against it, don't eyeball a replacement.
     warn and --hype-moderate deliberately share the amber family. A
     delay badge (chrome) and a hype chip (data) never render side by side
     needing to be told apart, and both mean the same thing at a glance --
     "middling, pay attention" -- so forcing them into different hues would
     fight the intensity ramp for no reader benefit. Do not "fix" this later. */
  --good:              light-dark(#095231, #2fbf7f);
  --good-strong:       light-dark(#063d24, #5fe0a4);
  --warn:              light-dark(#833a0c, #e8a52c);
  --danger:            light-dark(#a3231d, #f4868c);
  --danger-solid:      light-dark(#a3231d, #b52d24);

  /* data: the intensity ramp, hype only -- --hype-X is the vivid swatch
     (meter fill, calendar tint: needs only 3:1 non-text contrast against
     --surface-overlay); --hype-X-ink is the AA text that sits on a 22% tint
     of it. A single value cannot be both a vivid swatch and AA-safe ink --
     that conflation was the muddy light-mode hypemeter (Purpose §3). The
     light-mode values below are deliberately deep and SATURATED, not merely
     dark: a desaturated brown-grey at the same luminance reads as mud, which
     is the exact defect this ramp exists to fix.
     the tiers are named by DEGREE
     (low/moderate/high/extreme), and not one hex value moved when they stopped
     being temperatures. The ramp rises in saturation, which is what carries
     "more" here; it is not a thermometer and not a landscape. Every warm hue
     is still reserved for data (Purpose), which is why re-colouring was never
     the lever -- the tier vocabulary was. */
  --hype-low:           light-dark(#5f6c86, #7c8ba3);
  --hype-moderate:      light-dark(#b25b00, #efb43a);
  --hype-high:          light-dark(#c1440c, #f9853d);
  --hype-extreme:       light-dark(#c81e1e, #fb5647);
  --hype-low-ink:       light-dark(#4d5867, #9aa8bd);
  --hype-moderate-ink:  light-dark(#7a3d00, #f0c069);
  --hype-high-ink:      light-dark(#8a3208, #fba873);
  --hype-extreme-ink:   light-dark(#8f1616, #ff8f84);
  /* The third member of the ramp, and the reason there are three: ink for text
     sitting on a SOLID --hype-X fill (the game card's hype chip), which is a
     different problem from ink on a 22% tint. The -ink twins above are tuned
     against a tint that composites mostly to the page surface, so they track
     the theme -- dark ink in light mode. On a solid fill the polarity flips
     with the swatch instead: the light half of every --hype-X is a deep
     saturated colour wanting white, the dark half a bright one wanting near
     black. One neutral pair covers all four tiers; palette_contrast_test's
     "hype badge ink" case is what keeps that claim true. */
  --hype-contrast:     light-dark(#ffffff, #0a0e14);

  /* Platform-glyph tints -- tuned as one exercise, not hand-picked
     individually. See ":494" below (.platform-icon) for how these are
     consumed, and .game-hero further down for the always-dark
     re-declaration of the same nine. Constraints, in priority order:
     (a) >=3:1 against --neutral-chip in both themes and against the hero
     scrim; (b) all nine mutually distinguishable at 12-17px; (c) none within
     ~20deg hue of --accent -- the old #67c8e8 --pf-web sat almost exactly on
     the new brand cyan and had to move to an indigo hue instead; (d)
     PlayStation stays monochrome (that IS its brand mark, and it keeps the
     set's only other blue -- Windows -- unambiguous) and --pf-other stays
     undeclared (the catch-all keeps the neutral chip colour). */
  --pf-windows:      light-dark(#233095, #6d87e3);
  --pf-playstation:  light-dark(#1b2331, #d5dce8);
  --pf-xbox:         light-dark(#157926, #59cf76);
  --pf-nintendo:     light-dark(#aa1824, #f87777);
  --pf-apple:        light-dark(#59606f, #adb4c0);
  --pf-android:      light-dark(#426d17, #9ed369);
  --pf-linux:        light-dark(#8e6d0b, #e4b744);
  --pf-web:          light-dark(#5c2fc6, #9e89f0);
  --pf-vr:           light-dark(#9b2dd2, #da9bf3);

  /* Event-badge family identity (same category as --pf-* above): a colour
     that IDENTIFIES a family of events, never one that encodes a number.
     Tuned as INK on their own --tint-strong tint (badges);
     palette_contrast_test.rb enforces it. */
  --event-media:       light-dark(#7a23c5, #d9a4f4);
  --event-presence:    light-dark(#1e40af, #7b9beb);

  /* docs/charts/chart-movement.md D3: the movement scale -- a rank or a hype
     figure that went UP or DOWN since the previous chart. Its own data pair
     rather than --good/--danger, which are chrome-side judgements
     ("succeeded", "an alarm"): a game slipping two places is neither good
     news nor a problem, it is a direction, and the token has to be free to
     move without dragging a success badge with it. Green and red because
     that is the one convention every reader of a ranked list already has.

     The VALUES are shared with the --good / --danger families on purpose,
     the same way --warn and --hype-moderate share the amber family above:
     both pairs are already tuned AA on --surface, a movement figure and a
     status badge never render side by side needing to be told apart, and
     re-tuning a second green would be inventing a distinction no reader
     asked for. The names are what carry the meaning. */
  --move-up:           light-dark(#095231, #2fbf7f);
  --move-down:         light-dark(#a3231d, #f4868c);

  /* docs/hypemeter/force-display.md V4: the one pull token, meaning
     exactly one thing -- a pull force on the hypemeter (both wedges, the
     pull chips' tint, .date-trend--slipping). --pull is the vivid swatch
     (the wedge fill, the tinted chip's background source); --pull-ink is
     the same tint/ink split the hype ramp above uses -- AA text on the
     chip's own --tint-strong blend, tuned against palette_contrast_test.rb
     rather than eyeballed. There is no second warm token: "warm" is
     whatever fill the bar already had.

     The wedge-vs-fill adjacency is NOT solved by these values and cannot
     be: 3:1 against the track needs the dark swatch light, 3:1 against
     --hype-high needs it dark -- unsatisfiable (measured: 1.00:1 vs
     --hype-high dark, 1.67:1 vs --hype-low light). The boundary is drawn
     instead by each wedge's inset track-colour hairline, which clears 6:1
     against both neighbours in both themes. */
  --pull:              light-dark(#2c4a7c, #7fa6e0);
  --pull-ink:          light-dark(#1f3a66, #a9c4ec);

  /* docs/signals/series-pedigree/00-contract.md C4. Series pedigree ranks the
     `awaited` chart and is a DIFFERENT SCALE from the hypemeter, which the
     same row also shows -- so it must not wear hype_level's warm tier
     colours, or a reader reads one number in the other's units. A muted
     violet: distinct from the warm hype ramp, from --good's risers green and
     from --pull's blue.

     Two tokens for the same reason --pull needed two: --accent could not be
     its own ink here (measured 3.96:1 at --tint-faint over --surface, against
     a 4.5 floor), so the ink is darkened separately from the fill. Verified
     by palette_contrast_test at the tint level it is actually used at. */
  --pedigree:          light-dark(#5b3f8f, #b49ae0);
  --pedigree-ink:      light-dark(#432d6b, #c9b6ea);

  /* light-dark is only valid in <color> position, so the function
     wraps each colour STOP, not the whole shorthand value. */
  --shadow: 0 1px 2px light-dark(rgba(20, 21, 26, 0.06), rgba(0, 0, 0, 0.4)),
            0 8px 24px light-dark(rgba(20, 21, 26, 0.08), rgba(0, 0, 0, 0.28));

  --radius: 10px;
  /* Two steps below --radius, because the sheet had six spellings of "smaller
     than --radius" -- 4px five times, 6px, 8px, 2px, and two different calc
     forms -- and nobody chose the differences. Not to be
     confused with the nested-inset idiom, calc(var(--radius) - 4px), which
     computes to the same 6px today but MEANS "tucked inside the parent's
     corner" and is supposed to move when --radius does. */
  --radius-sm: 6px;
  --radius-xs: 4px;
  /* The pill. Any value past half the element's height rounds the ends fully,
     so this is a shape rather than a measurement -- which is exactly why it
     wants a name: `border-radius: 999px` written out 19 times reads like 19
     independent decisions instead of one. */
  --radius-pill: 999px;
  /* The three tint levels. 37 declarations spanned five
     levels, and palette_contrast_test.rb's tint table -- maintained by hand --
     knew four of them, so three tinted chips (.admin-chip--live among them)
     shipped with no contrast assertion at all. Naming the levels is what lets
     that test read the SHEET instead of a list. */
  --tint-strong: 22%;
  --tint-soft: 18%;
  --tint-faint: 14%;
  /* One height for every control that sits on a row with another control (text
     input, submit, select, icon button). Before this they landed on
     37/37/38/32px, which is what made a filter row look assembled from spare
     parts rather than designed. */
  --control-h: 2.375rem;
  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial,
    sans-serif;
  /* Tabular data that must line up column-wise: raw JSON, id columns, the
     trace output. */
  --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;

  /* Type scale. The sheet had 32 distinct font sizes across 145 declarations,
     including 0.64/0.65 and 0.88/0.90/0.92 -- differences of a fraction of a
     pixel that nobody chose and nobody can see. These are the steps.
     The app is dense, so the ladder is finer below 1rem (where the chrome
     lives) than a display scale would be; --text-md is the default UI size and
     --text-lg is the CONTROL size, shared by .btn, the text inputs and
     .select-shell > select so a filter row lines up.
     em-relative sizes are deliberately NOT in here: 0.95em on
     .chart-tabs--periods scales its children in proportion to a parent that
     may itself change, which is a different thing from an absolute step. */
  --text-2xs: 0.65rem;
  --text-xs: 0.72rem;
  --text-sm: 0.78rem;
  --text-md: 0.85rem;
  --text-lg: 0.9rem;
  --text-xl: 0.95rem;
  --text-2xl: 1rem;
  --text-3xl: 1.1rem;
  --text-4xl: 1.3rem;
  --text-5xl: 1.5rem;
  --text-6xl: 2.5rem;
  --text-7xl: 3rem;
  /* The game hero's title, the one size that is viewport-relative. */
  --text-hero: clamp(1.6rem, 4vw, 2.4rem);
}

/* the theme toggle. System (default, above) resolves through the OS;
   an explicit choice pins color-scheme directly, so light-dark no longer
   needs prefers-color-scheme at all once a visitor has picked a side. No
   duplicated palette -- every token above already carries both values. */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"]  { color-scheme: dark; }

* {
  box-sizing: border-box;
}

html {
  overflow-x: hidden;
  /* reserve the scrollbar gutter on every page so a short page (no
     vertical scrollbar) and a long page (scrollbar present) render .container
     at the same width; the sticky header no longer jumps sideways between
     routes. Do not "fix" this with overflow-y: scroll, and don't remove it
     later as unused -- this is the whole fix. */
  scrollbar-gutter: stable;
}

/* global keyboard-focus indicator: every link, button, and form control
   (including inside the nav-menu dropdowns) gets a visible outline in the
   accent color. :focus-visible so mouse clicks don't show it, only keyboard
   (and other non-pointer) focus.
   The list must name every focusable control type -- <textarea> was missing
   from it, which left the trace snippet box on /admin/extraction as the one
   control in the app with no visible keyboard focus at all. Nothing failed;
   the omission was only findable by tabbing to it. control_system_test.rb
   now asserts the full list. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
summary:focus-visible {
  outline: 2px solid var(--accent-strong);
  outline-offset: 2px;
}

/* visually hidden until focused; first focusable element in <body>. */
.skip-link {
  position: absolute;
  top: -3rem;
  left: 0.5rem;
  z-index: 100;
  background: var(--accent);
  color: var(--accent-contrast);
  padding: 0.6rem 1rem;
  border-radius: var(--radius);
  font-weight: 600;
}

.skip-link:focus {
  top: 0.5rem;
}

body {
  margin: 0;
  background: var(--surface);
  color: var(--text);
  font-family: var(--font-sans);
  line-height: 1.5;
  overflow-x: hidden;
}

img {
  max-width: 100%;
  display: block;
}

/* Flat, not nested: palette_semantics_test.rb cannot match a
   rule whose body contains a brace, so while the :hover sat nested here the
   outer rule vanished from that test entirely and the inner `&:hover` was
   captured as a selector of its own. Nesting does not fail those tests, it
   disappears from them, and the failure surfaces elsewhere as "no
   .btn--danger rule found".
   This particular rule's colour is unasserted either way -- that test's regex
   only starts a match at `.` or `:`, so a bare type selector was never in
   scope. Unnesting it is about not leaving the trap loaded, not about
   coverage gained. */
a {
  color: var(--accent-strong);
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

h1,
h2,
h3 {
  line-height: 1.2;
  margin: 0 0 0.5em;
}

.container {
  width: 100%;
  max-width: 1500px;
  margin-inline: auto;
  padding-inline: 1.25rem;
}

/* ---------- Primitives ---------- */

/* These sit above every component section on purpose: nearly every rule in
   this sheet is (0,1,0), so ties break on source order alone. Primitives first
   means a component can always override the primitive it uses, and never the
   reverse. Nothing else moved position.

   They are selector LISTS, not a .panel class in the markup. A class is what a
   framework would do; here it would be edits across ~19 templates that no test
   can verify, and an element that lost its class renders unstyled while the
   suite stays green -- the .game-tabs failure mode CLAUDE.md is built around.
   One rule saying "these things are panels" lives in the file that already
   governs appearance. */

/* PANEL -- the app's surface, written out 19 times before this. Padding,
   layout, overflow and grid geometry stay with each consumer: that is where a
   panel SITS, not what it IS.

   Controls are deliberately absent even though a text input, a select and the
   search bar wear these same three declarations. They are governed by
   the controls rules, and one rule per component beats a shared list spanning
   two -- the select in particular is the settled control that does not move. */
.nav-menu__panel,
.card,
.auth-card,
.game-gallery,
.game-shots,
.phase-section,
.admin-pipeline__node,
.entry,
.calendar-grid,
.calendar-window__item,
.calendar-legend,
.chart-list,
.admin-stat,
.admin-metrics-chart,
.admin-metrics-path {
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

/* PANEL, raised -- the one axis on which the panels genuinely differ: a
   surface floating over the page takes the shadow, one sitting in the flow
   does not. */
.nav-menu__panel,
.card,
.auth-card,
.game-gallery,
.game-shots,
.phase-section,
.admin-pipeline__node {
  box-shadow: var(--shadow);
}

/* TILE -- a recess rather than a raised surface: a well inside a panel, not
   another panel on top of it. No border and no shadow, which is what keeps a
   tile from reading as a second card. */
.filter-drawer__row,
.game-hero__cover,
.game-trailer,
.reception__score,
.admin-activity__item,
.admin-queue-health,
.admin-build,
details.admin-explainer,
details.admin-json > pre,
.admin-queue-console__paused-banner {
  background: var(--surface-overlay);
  border-radius: var(--radius);
}

/* NEUTRAL CHIP -- "no signal here". What a chip or badge falls back to when
   its data carries no tier: the editorially-flat event types (announcement,
   release_date, dlc, patch, review), a bare .badge stamp with no event
   semantics of its own, unscored hype, a met verdict, a job that has never
   run. Nine rules said it independently, five of which said nothing else at
   all.

   It is a fallback, so every tier modifier that overrides it is either (0,2,0)
   or sits later in the file. Putting this first is what makes that safe. */
.chip,
.badge--announcement,
.badge--release_date,
/* docs/events/date-change-taxonomy.md T8: date_firmed is seated with
   release_date, W5's own precedent -- a precision change makes no
   directional claim about the game. */
.badge--date_firmed,
/* docs/events/release-window-classification.md W5: seated with
   release_date, NOT with .badge--delay's warning colour further down. A
   widening window is release-date news of unknown sign -- colouring it as a
   setback is the same claim the delay label made, moved into the palette. */
.badge--date_loosened,
/* docs/hypemeter/hype-forces.md D6/L5: the same W5 precedent -- a
   withdrawn date or a vanished store id is upstream data loss far more
   often than a studio's own decision, and the copy claims only what IGDB's
   catalogue shows. The pull classification (Hypemeter::PULL_EVENT_TYPES)
   carries the scoring judgement; the palette does not overclaim a cause. */
.badge--date_withdrawn,
.badge--steam_delisting,
.badge--gog_delisting,
/* docs/events/hype-move-coverage.md X3: the storefront-breadth pair's own
   delisting arm, seated beside its store cousins for the same reason. */
.badge--web_delisting,
.badge--review,
.badge--neutral,
.game-card__hype.hype--unscored,
.reception__verdict.verdict--met,
.chart-metric,
.admin-status--never,
/* An operator's judgement that a stalled run is gone, not a completed outcome --
   neutral like --never rather than --good or --danger. */
.admin-status--cancelled,
/* Marker in admin/games/index: a fact about a feed, so neither
   --live's accent nor --hidden's warn is the right reading. .admin-chip alone
   carries no colour and would render as stray text beside the feed name. */
.admin-chip--neutral,
/* docs/hypemeter/force-display.md C2: a push force chip stays the
   neutral chip style -- only a pull gets a colour, below. */
.force-chip--push {
  background: var(--neutral-chip);
  color: var(--neutral-chip-text);
}

/* ---------- Header / nav ---------- */

.site-header {
  position: sticky;
  top: 0;
  z-index: 10;
  background: color-mix(in srgb, var(--surface-raised) 92%, transparent);
  backdrop-filter: blur(8px);
  border-bottom: 1px solid var(--border);
}

.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding-block: 0.85rem;
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  font-weight: 700;
  font-size: var(--text-3xl);
  color: var(--text);
  letter-spacing: -0.01em;
}

/* The mark carries the brand colour so the header has one accent anchor; the
   wordmark stays --text so it reads as text, not as a link to be clicked past.
   Set on the mark itself rather than inherited, because .brand's own colour is
   the wordmark's. */
.brand-mark {
  color: var(--accent);
}

.brand:hover {
  text-decoration: none;
  color: var(--accent-strong);
}

.brand:hover .brand-mark {
  color: var(--accent-strong);
}

.site-nav {
  display: flex;
  align-items: center;
  gap: 1.25rem;
  flex-wrap: wrap;
  position: relative; /* anchors .nav-menu__panel */
}

/* The two groups inside <nav>: the five section links, and the tools corner
   (search link + settings menu). Boxless here on purpose -- the flex row above is the layout at
   every width down to 560px, and a real box for either group would change the
   gap arithmetic of a header that has been right for a year. Their boxes come
   back in the 560px block below, where the links become a scrolling row of
   their own. */
.site-nav__links,
.site-nav__tools {
  display: contents;
}

.site-nav a {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  color: var(--text-muted);
  font-weight: 500;
  font-size: var(--text-xl);
}

.site-nav a:hover {
  color: var(--text);
  text-decoration: none;
}

/* docs/site/header-redesign.md HR2: Reception's own entry carries
   aria-current="true" (not "page") on /attention -- the reader is inside
   Reception's fold, not on its page -- and gets the same ink shift as an
   ordinary current page. */
.site-nav a[aria-current="page"],
.site-nav__links > a[aria-current="true"] {
  color: var(--text);
}

/* The reset lives on .btn-link itself now; this only says how a nav one looks. */
.site-nav .btn-link {
  color: var(--text-muted);
  font-weight: 500;
  font-size: var(--text-xl);
}

.site-nav .btn-link:hover {
  color: var(--text);
  text-decoration: underline;
}

/* icon + visible label; label collapses to icon-only below 560px, kept
   in the DOM (never removed) with aria-label + title carrying the name. */
.nav-label {
  white-space: nowrap;
}

/* docs/signals/showcases.md U15: the on-air chip beside the Showcases nav
   entry -- its own sibling anchor to /showcases/live. The house red
   (--danger, the urgent-status pair) -- no new colour token; the dot is the
   ::before, and its pulse is answered by name in the prefers-reduced-motion
   block at the end of this file. The compound selector is load-bearing:
   `.site-nav__links a` outranks a bare `.nav-live` and would grey the chip. */
.site-nav__links a.nav-live {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  color: var(--danger);
  font-size: var(--text-sm);
  font-weight: 700;
  white-space: nowrap;
}

.nav-live::before {
  content: "";
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: currentColor;
  animation: nav-live-pulse 2s ease-in-out infinite;
}

@keyframes nav-live-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.35; }
}

/* ---------- Dropdown chrome (details.nav-menu) ----------

   Used by the settings menu (.nav-settings), whose panel holds the theme
   group (.nav-theme) and the Language group (.nav-locale,
   ). The account menu (.nav-user) that
   would share this chrome are gone with the accounts layer. Chrome lives
   here; identity lives on the extra class. */

.nav-menu {
  position: relative;
}

.nav-menu > summary {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  list-style: none;
  color: var(--text-muted);
  cursor: pointer;
  padding: 0.3rem;
  border-radius: var(--radius);
}

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

.nav-menu > summary:hover {
  color: var(--text);
}

.nav-menu[open] > summary {
  color: var(--text);
}

/* absolutely positioned so opening it never pushes page content down;
   right-aligned under the header; stacks above .site-header (z-index 10). */
.nav-menu__panel {
  position: absolute;
  top: calc(100% + 0.5rem);
  right: 0;
  z-index: 20;
  display: flex;
  flex-direction: column;
  min-width: 11rem;
  max-width: calc(100vw - 2rem);
  padding: 0.4rem;
}

.nav-menu__panel a,
.nav-menu__panel .btn-link {
  width: 100%;
  padding: 0.5rem 0.6rem;
  border-radius: calc(var(--radius) - 4px);
  justify-content: flex-start;
}

.nav-menu__panel a:hover,
.nav-menu__panel .btn-link:hover {
  background: var(--surface-overlay);
  text-decoration: none;
}

/* Below the compact breakpoint the header is two rows: the brand and the two
   dropdown controls on the first, the five section links on the second.

   The links KEEP THEIR WORDS. They used to collapse to icon-only here, which
   asked a visitor to tell a bar chart from a pair of scales from a calendar at
   a glance and gave each of those a target barely wider than the glyph. Five
   labelled links do not fit on a 390px line, so the row scrolls sideways --
   the same idiom, and the same reason, as the secondary bars below
   (.admin-nav / .charts-nav / .calendar-scopes and their "one row always"):
   a row that WRAPS inside a sticky header pushes the whole page down as it
   grows.

   .site-nav goes boxless so its two groups become grid items of the header
   directly. That drops its `position: relative`, which is safe: .nav-menu is
   itself positioned and is the nearer ancestor, so the dropdown panels already
   anchor there. It is also why the controls are NOT in the scroller -- an
   absolutely positioned panel inside an overflow-x:auto box is clipped.

   The two control labels stay hidden. They are the only two items sharing the
   brand's line, the glyph plus chevron is the whole control, and their words
   ("Theme", "Language") name a chrome affordance rather than a destination. */
@media (max-width: 560px) {
  .site-header__inner {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: center;
    column-gap: 0.75rem;
    row-gap: 0.35rem;
  }

  .brand {
    grid-area: 1 / 1;
  }

  .site-nav {
    display: contents;
  }

  .site-nav__tools {
    grid-area: 1 / 2;
    display: flex;
    align-items: center;
    gap: 0.5rem;
  }

  /* Full-bleed: the negative margin cancels .container's gutter and the
     padding puts it back inside the scroller, so the row starts flush with the
     brand and visibly runs off the screen edge rather than stopping short in a
     gutter and looking like a list that merely got cut. The margins exactly
     equal the gutter, so the grid gains no overflow of its own. */
  .site-nav__links {
    grid-area: 2 / 1 / 2 / -1;
    display: flex;
    align-items: center;
    gap: 0.15rem;
    overflow-x: auto;
    scrollbar-width: none;
    margin-inline: -1.25rem;
    padding-inline: 1.25rem;
  }

  .site-nav__links::-webkit-scrollbar {
    display: none;
  }

  /* A tap target rather than a word: 0.45rem of block padding puts the box at
     ~40px tall, and `flex: none` keeps a label from being squeezed into two
     lines by the row it is already allowed to overflow. */
  .site-nav__links > a {
    flex: none;
    padding: 0.45rem 0.6rem;
    border-radius: var(--radius);
  }

  /* aria-current is the only active-state signal (house-style), and at this
     width the desktop ink shift alone is too quiet to find among five labels
     that may be scrolled half off screen. The fill is the same one the
     dropdown panels use for a hovered row, so the bar gains no new colour. */
  .site-nav__links > a[aria-current="page"],
  .site-nav__links > a[aria-current="true"] {
    background: var(--surface-overlay);
  }
}

/* ---------- Main / footer ---------- */

main.container {
  padding-block: 2rem 3rem;
  min-height: 60vh;
}

.site-footer {
  border-top: 1px solid var(--border);
  color: var(--text-muted);
  font-size: var(--text-md);
  padding-block: 1.5rem;
  margin-top: 2rem;
}

/* Same reasoning as .entry__snippet: the credit line is prose, not layout. */
.site-footer p {
  max-width: 80ch;
}

.site-footer a {
  color: var(--text-muted);
  text-decoration: underline;
}

/* ---------- Search link (a.nav-search) ----------

   docs/site/header-redesign.md HR5: a plain icon link now, no <details>, no
   panel -- so it borrows only the summary's own sizing (padding,
   border-radius), not the .nav-menu chrome, and its current state rides the
   ordinary .site-nav a[aria-current] rule above like any other nav link. */
.site-nav__tools .nav-search {
  display: inline-flex;
  align-items: center;
  padding: 0.3rem;
  border-radius: var(--radius);
}

/* ---------- Settings menu (details.nav-settings, shared/_settings_menu) ----

   docs/site/header-redesign.md HR9: one details.nav-menu.nav-settings
   replaces the separate theme (details.nav-theme) and Language
   (details.nav-locale) dropdowns -- same borrowed .nav-menu chrome as
   before, icon-only trigger (a gear) since the panel now carries its own
   group headings instead. Rendered for every visitor, admin or anonymous;
   its Language group only on routes carrying a :locale segment (contract M6).

   Each group keeps the class its retired partial used (.nav-theme /
   .nav-locale) on a plain <div> now, so the choices' own current-state
   highlight below and every place already scoped to those classes keep
   working unchanged. */
.nav-settings .nav-menu__panel {
  gap: 0.75rem;
}

.nav-menu__group-heading {
  padding: 0.3rem 0.6rem 0;
  font-size: var(--text-sm);
  font-weight: 600;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: 0.02em;
}

/* The three theme choices are submit buttons inside one <form>, which would
   otherwise be a single flex child of .nav-theme and collapse them onto one
   row. display: contents lifts them back into the group's own column so
   they inherit the .btn-link sizing and hover already defined above. */
.nav-theme form {
  display: contents;
}

/* The trigger glyph used to name the active theme on its own; the open menu
   must still say so now that the trigger is a bare gear -- aria-current
   carries it for assistive tech, this is the visible half of the same fact.
   Same highlight for the current locale, unchanged from the retired
   locale menu. */
.nav-theme__choice[aria-current="true"],
.nav-locale__choice[aria-current="true"] {
  color: var(--accent-strong);
  font-weight: 600;
}

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

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 1.25rem;
  margin-block: 1.25rem 2rem;
}

/* The desktop track is sized for a card that is read rather than counted, and
   at this width that minimum would leave a single column where two have always
   fitted. The narrow grid keeps the track it had. */
@media (max-width: 480px) {
  .card-grid {
    grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  }
}

/* A strip is a bounded handful of cards (at most six), not an open-ended list.
   auto-fill keeps the tracks the container has room for whether or not anything
   sits in them, so at 1500px the six cards huddled to the left of two empty
   ones. auto-fit collapses the empty tracks and lets the cards share the whole
   line -- and unlike a flex row, a strip that wraps keeps one track width for
   every row instead of stretching the short last row. */
.card-grid--strip {
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}

/* Four cards or fewer have more line than they can share: at 1500px they would
   stretch to 350px covers, and a two-card strip to 700px. Those keep the
   auto-fill packing and leave the rest of the line empty, which is the honest
   look for a section that came back short. */
.card-grid--strip:not(:has(> :nth-child(5))) {
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}

/* docs/site/card-rails.md: a BOUNDED row (a curated handful, no pagination) --
   home's two strips, /search's popular strip, and the three games/_related
   blocks -- becomes a horizontal scroller below 900px instead of stacking.
   At 390px .card-grid is one column and .card-grid--strip is two, so
   "Releasing soon" (six cards) was a two-column block three rows tall filling
   the whole first screen. Open-ended result grids (/games, /search results,
   a showcase roster) never carry this class (card_rail_scope_test.rb).

   display: flex, never a grid-template-columns override -- Trap 1:
   .card-grid--strip:not(:has(> :nth-child(5))) above is (0,2,0), because
   :not() and :has() take the specificity of their most specific argument and
   :nth-child(5) is (0,1,0), while a media-query rule for .card-grid--rail is
   only (0,1,0) and a media query adds no specificity -- so a
   grid-template-columns override here would LOSE that fight despite coming
   later in the sheet. display: flex sidesteps the contest by making every
   grid-template-columns declaration inert, whoever wins it. */
@media (max-width: 900px) {
  .card-grid--rail {
    display: flex;
    overflow-x: auto;
    gap: 0.75rem;
    /* Full-bleed: same pair, same reason, as .site-nav__links -- the negative
       margin cancels .container's own padding-inline and the padding puts it
       back inside the scroller, so the row starts flush with its heading and
       visibly runs off the screen edge rather than stopping short in a
       gutter and reading as a list that was merely cut. */
    margin-inline: -1.25rem;
    padding-inline: 1.25rem;
    /* Both properties, not scrollbar-width alone: the COLOUR is what takes
       the scrollbar out of the OS's overlay mode -- scrollbar-width alone
       leaves it invisible until touched (calendar-filters.md S2), which here
       would be the only affordance a reader with JavaScript off has left. */
    scrollbar-width: thin;
    scrollbar-color: var(--border) transparent;
  }

  /* A fixed track, not a stretching one: at a 390px viewport this leaves a
     third card partially visible, the strongest scroll cue there is and the
     only one that works before a reader has noticed a scrollbar or a fade. */
  .card-grid--rail > * {
    flex: none;
    /* min(), not a flat 160px: the peek is what is left of the viewport after
       two whole tracks, and a fixed track makes that a function of the screen.
       At 375px a flat 160 put the third card's leading edge exactly ON the
       edge -- two cards and a strip of nothing, which reads as a row that
       ends. 40vw keeps a peek at every width in the range (measured 360, 375,
       390, 480) and stops mattering above 400px, where min() picks 160. */
    width: min(160px, 40vw);
  }

  /* R7/Trap 2: the edge fade is a mask on the rail's own rendered box, keyed
     on the data-overflow state card_rail_controller.js sets from scrollLeft.
     A background-attachment scroll shadow (.table-scroll's technique) would
     paint the CONTAINER's background instead, and game cards are opaque
     --surface-raised, so it would only ever show in the 0.75rem gaps between
     them. Transparency only, so it needs no dark-theme branch. A side with
     nothing further to show gets no fade at all -- no rule matches
     data-overflow="none", and the "start"/"end" rules only fade their own
     edge. */
  .card-grid--rail[data-overflow="start"] {
    mask-image: linear-gradient(to right, transparent, black 1.25rem);
    -webkit-mask-image: linear-gradient(to right, transparent, black 1.25rem);
  }

  .card-grid--rail[data-overflow="end"] {
    mask-image: linear-gradient(to left, transparent, black 1.25rem);
    -webkit-mask-image: linear-gradient(to left, transparent, black 1.25rem);
  }

  .card-grid--rail[data-overflow="both"] {
    mask-image: linear-gradient(to right, transparent, black 1.25rem, black calc(100% - 1.25rem), transparent);
    -webkit-mask-image: linear-gradient(to right, transparent, black 1.25rem, black calc(100% - 1.25rem), transparent);
  }
}

.card {
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.game-card__cover-link {
  display: block;
}

.game-card__cover {
  width: 100%;
  aspect-ratio: 3 / 4;
  object-fit: cover;
  background: var(--surface-overlay);
}

.game-card__cover--placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--text-6xl);
  font-weight: 700;
  color: var(--text-muted);
}

.game-card__body {
  padding: 0.85rem 1rem 1rem;
}

/* ONE line,
   ellipsised. It was a 2-line clamp with 2 lines of reserved height: that did
   fix the original defect (unbounded names sizing the whole grid row to the
   tallest card) but paid for it with a permanently empty second line under
   every short name. One line reserves exactly what it uses. min-height stays
   as the floor for the degenerate empty-name case, so the card cannot collapse
   below its row. CSS-only: the full name stays in the DOM as the link text --
   nothing lost to crawlers or screen readers -- and the link carries a `title`
   so pointer users can still read what was cut. */
.game-card__name {
  font-size: var(--text-2xl);
  line-height: 1.3;
  margin-bottom: 0.25rem;
  min-height: 1.3em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.game-card__name a {
  color: var(--text);
}

/* the date shares its line with the
   hype chip. It is paired with the DATE rather than with the platform chips
   because the date is the card's one short, bounded piece of body text: even
   the longest locale's `:card` format leaves room for the chip on a 128px
   strip card, whereas the platform row can carry four chips and cannot.
   min-height reserves the chip's height (taller than the date text) so an
   unscored game does not make a shorter card -- the same reservation made
   for the meta row, and for the same reason. */
.game-card__dateline {
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  gap: 0.4rem;
  min-height: 1.5rem;
  margin-bottom: 0.5rem;
}

.game-card__date {
  color: var(--text-muted);
  font-size: var(--text-md);
  margin: 0;
  /* the belt-and-braces half of the pairing. The date is expected to fit,
     but if some locale's format ever outgrows the line, it must ellipsise
     rather than shove the chip out of the card: overflow that cannot shrink is
     exactly what the platform row got wrong. */
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.game-card__dateline .game-card__hype-badge {
  flex: 0 0 auto;
  margin-left: auto;
}

/* the meta row is ONE line, always:
   the card's height must not depend on how many platforms a game has. Since
   the hype chip sits up on the dateline, the chip list has the row to
   itself -- which is also what stopped the two colliding. */
.game-card__meta {
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  gap: 0.4rem;
  /* reserve the row's height rather than letting its content set it, so a
     game with no platforms at all still leaves the row's line and the grid row
     does not size to its tallest neighbour. Measured over CDP: content ran
     19-24px, so the taller case was the reservation.

     1.6rem, not 1.5: the platform badge became a true circle of 2.2em on
     .chip's own --text-xs, which is 1.584rem, and a reservation shorter than
     its own content reserves nothing. Raise this whenever that box does, or
     the invariant it exists for -- every meta row the same height, with or
     without platforms -- goes quietly back to being false. */
  min-height: 1.6rem;
}

.game-card__meta .chip-list {
  flex: 1 1 auto;
  min-width: 0;
  flex-wrap: nowrap;
}

/* .table-scroll is one rule, filed with the admin tables that are its only
   consumers. Split across two bodies it would take its margins from one and
   its scroll shadow from the other, and each would look complete while doing
   half the job. */

/* Compact hype indicator on a game card -- only rendered when hype is present. */
.game-card__hype {
  display: inline-block;
  width: fit-content;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.02em;
  padding: 0.15rem 0.5rem;
  border-radius: var(--radius-pill);
  margin: 0 0 0.5rem;
}

/* same tint/ink split as the hypemeter
   above, so a card's compact hype pill never drifts from the meter's own
   colouring. */
.game-card__hype.hype--low,
.chart-metric.hype--low {
  background: color-mix(in srgb, var(--hype-low) var(--tint-strong), transparent);
  color: var(--hype-low-ink);
}

.game-card__hype.hype--moderate,
.chart-metric.hype--moderate {
  background: color-mix(in srgb, var(--hype-moderate) var(--tint-strong), transparent);
  color: var(--hype-moderate-ink);
}

.game-card__hype.hype--high,
.chart-metric.hype--high {
  background: color-mix(in srgb, var(--hype-high) var(--tint-strong), transparent);
  color: var(--hype-high-ink);
}

.game-card__hype.hype--extreme,
.chart-metric.hype--extreme {
  background: color-mix(in srgb, var(--hype-extreme) var(--tint-strong), transparent);
  color: var(--hype-extreme-ink);
}

/* the card's
   hype chip, on the trailing edge of the dateline. It was 0.68rem on a 22%
   tint, which made the one number the product exists to publish the quietest
   mark on the card; it is now 0.85rem/800 on a SOLID tier fill. It briefly
   overlaid the cover's top-left corner instead, which read well but cost the
   artwork a corner -- hence back in the body, keeping the weight it gained.
   Deliberately NOT built on .game-card__hype: that class is the tint+ink pair
   the calendar chips still use, and this fills solid with the vivid --hype-*
   swatch and takes --hype-contrast as ink. It lives on the dateline, not the
   platform row -- see .game-card__dateline for why that matters. */
.game-card__hype-badge {
  min-width: 1.75rem;
  padding: 0.1rem 0.4rem;
  border-radius: var(--radius-pill);
  font-size: var(--text-md);
  font-weight: 800;
  line-height: 1.3;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

/* docs/calendar/calendar-legibility.md P4: .calendar-grid__peak joins these
   lists rather than forking a second family, so palette_contrast_test keeps
   measuring one ramp -- its own shape rule (~4,300 lines below) declares no
   color and no background for exactly this reason. */
.game-card__hype-badge.hype--low,
.entry__hype.hype--low,
.calendar-grid__peak.hype--low {
  background: var(--hype-low);
  color: var(--hype-contrast);
}

.game-card__hype-badge.hype--moderate,
.entry__hype.hype--moderate,
.calendar-grid__peak.hype--moderate {
  background: var(--hype-moderate);
  color: var(--hype-contrast);
}

.game-card__hype-badge.hype--high,
.entry__hype.hype--high,
.calendar-grid__peak.hype--high {
  background: var(--hype-high);
  color: var(--hype-contrast);
}

.game-card__hype-badge.hype--extreme,
.entry__hype.hype--extreme,
.calendar-grid__peak.hype--extreme {
  background: var(--hype-extreme);
  color: var(--hype-contrast);
}

/* The port marker beside a port card's date, and the remake/remaster marker
   on the card meta row and the hero -- both a small neutral OUTLINE chip
   (no fill), so it reads as a tag rather than competing with the solid-filled
   hype badge sharing the same line. No new colour tokens: border and text
   are the existing neutral pair. Inside .game-hero (always dark, see H2
   above) that pair resolves to its dark half -- --border/--text-muted are
   now part of the hero's own belt-and-braces re-declaration precisely so
   this chip does not go low-contrast on the engine where color-scheme alone
   is unverified. */
.game-card__port-tag,
.game-type-tag {
  flex: 0 0 auto;
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  padding: 0.1rem 0.45rem;
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--text-muted);
  white-space: nowrap;
}

/* .game-hero__body is a column flex with default (stretch) cross-axis
   alignment, which .game-hero__release opts out of the same way -- without
   this the chip would stretch to the hero's full width instead of hugging
   its own text. */
.game-hero__body > .game-type-tag,
.game-hero__body > .game-card__port-tag {
  align-self: flex-start;
}


/* ---------- Chips ---------- */

.chip-list {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  padding: 0;
  margin: 0;
}

.chip {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  white-space: nowrap;
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  padding: 0.2rem 0.55rem 0.2rem 0.45rem;
  border-radius: var(--radius-pill);
}

/* docs/hypemeter/force-display.md C2/C4: the events row's force chips --
   the .chip idiom above, a size step down (the row already carries a
   points figure and a detail line; a full-size chip here would out-shout
   both). .force-chip--push takes the shared neutral fallback above; only
   the pull variant, below, carries its own colour. */
.force-chip-list {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.3rem;
  padding: 0;
  margin: 0.4rem 0 0;
}

.force-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  white-space: nowrap;
  font-size: var(--text-2xs);
  font-weight: 600;
  padding: 0.15rem 0.4rem 0.15rem 0.3rem;
  border-radius: var(--radius-pill);
}

.force-chip .icon {
  width: 0.85em;
  height: 0.85em;
}

.force-chip--pull {
  background: color-mix(in srgb, var(--pull) var(--tint-strong), transparent);
  color: var(--pull-ink);
}

/* C3: the honest overflow line -- deliberately not a .force-chip (it names
   no direction and no event), so it never inflates the rendered-chip count
   the fatal test pins. */
.force-chip-list__more {
  font-size: var(--text-2xs);
  color: var(--text-muted);
}

/* Platform glyphs. Two things carry "which platform is this": the silhouette
   (see PLATFORM_ICON_SHAPES) and the tint below. The tint is a SECOND cue, not
   the only one -- every chip also carries the platform name in title/aria-label
   -- but it is what makes a row of three chips scannable without reading them.
   Sized in em so the labelled hero chip and the icon-only card chip scale off
   their own chip; no opacity, which only muddied them against the chip fill. */
.platform-icon {
  width: 1.15em;
  height: 1.15em;
  flex: none;
  color: var(--pf, currentColor);
}

/* Brand-family tints -- the nine --pf-* declarations now live in the single
   :root colour block up top, one light-dark line each; see the
   comment there for the tuning rationale (contrast vs --neutral-chip, mutual
   distinguishability, the accent-hue exclusion, PlayStation's monochrome
   exception).
                                                                    IMPORTANT:
   .game-hero is a dark band in BOTH themes (a scrim over the cover art,
   white text -- see .game-hero__backdrop below), so it must show the DARK
   half of every --pf-* token even when the page itself is in the light
   theme. The probe run found
   that .game-hero's own `color-scheme: dark` is enough for this on
   Blink -- a subtree's color-scheme wins over :root's through arbitrary
   var nesting -- but WebKit is unverified, so .game-hero (H2, below) also
   carries an explicit re-declaration of each --pf-* as belt-and-braces. Keying
   platform tints off prefers-color-scheme alone (the pre-T1 mechanism) once
   put near-black #1b2331 PlayStation glyphs on that dark band in the light
   theme, where they were invisible -- that regression is exactly what H2's
   re-declaration, and platforms_helper_test.rb, guard against. */

.platform-icon--windows { --pf: var(--pf-windows); }
.platform-icon--playstation { --pf: var(--pf-playstation); }
.platform-icon--xbox { --pf: var(--pf-xbox); }
.platform-icon--nintendo { --pf: var(--pf-nintendo); }
.platform-icon--apple { --pf: var(--pf-apple); }
.platform-icon--android { --pf: var(--pf-android); }
.platform-icon--linux { --pf: var(--pf-linux); }
.platform-icon--web { --pf: var(--pf-web); }
.platform-icon--vr { --pf: var(--pf-vr); }

/* the "+N" overflow chip
   _platforms.html.erb renders past its `limit:`. Same base .chip look; no
   platform icon (there's no single platform left to depict), tabular digits
   so the count doesn't jitter next to the icon'd chips beside it. Height
   pinned to the icon chips' own box below, because it sits in the same
   cluster: left to its text's line box it stood ~3px shorter than the discs
   beside it, and a row of equal-diameter circles with one squat lozenge in it
   is the one shape in the cluster the eye stops on. */
.chip--platform-more {
  height: 2.2em;
  padding-inline: 0.55em;
  justify-content: center;
  font-variant-numeric: tabular-nums;
}

/* the card's icon-only variant. A
   card's meta row is 174px wide (measured over CDP at 1400px); three labelled
   chips need 270-310px, so labels are dropped on cards and the glyph carries
   the platform; the name lives in title/aria-label, and the game page's hero
   still renders the full labelled list.

   AN EXPLICIT SQUARE BOX, not padding. This rule's own comment used to claim
   "square padding keeps the chip a circle-ish badge rather than a pill with an
   empty right half" while setting `0.15rem 0.3rem` -- 2.4px above and below
   against 4.8px either side. With .chip's --radius-pill on a 26x21 box that
   draws an ELLIPSE, and the glyph inside it nearly touched the top and bottom
   while floating in space left and right. The intent was right and only the
   arithmetic was wrong: a square box plus the pill radius is a true circle,
   and 2.2em against the 1.45em glyph leaves the same ~4px on all four sides.

   Sized in em, not rem, so the badge tracks whatever font-size the chip is
   given rather than needing a second rule per surface. */
.chip--platform-icon {
  width: 2.2em;
  height: 2.2em;
  padding: 0;
  justify-content: center;
}

/* With no label beside it the glyph IS the chip, so it gets the room: 1.05em
   put it at 12px, where the silhouettes were indistinguishable. 1.45em of the
   chip's 0.72rem font is ~17px, which the 2.2em disc above clears on every
   side. */
.chip--platform-icon .platform-icon {
  width: 1.45em;
  height: 1.45em;
}

/* ---------- Badges ---------- */

.badge {
  display: inline-block;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  padding: 0.2rem 0.55rem;
  border-radius: var(--radius-pill);
  white-space: nowrap;
}

/* Launch progress: release, alpha, beta, early_access, port_release,
   early_access_release -- the delay-wears-warn idiom, mirrored. Note
   .badge--early_access is a DISTINCT selector from .badge--early_access_date
   below (an undated status latch vs. a dated phase announcement) -- do not
   merge or reuse either rule for the other.

   docs/phases/alpha-beta-phases.md E4: alpha_release/beta_release join
   this family beside early_access_release -- the dated phase ARRIVING, same
   launch-progress meaning as release/port_release/early_access_release.
   .badge--alpha_release is a DISTINCT selector from .badge--alpha above
   (.badge--alpha is the undated status latch, .badge--alpha_release is the
   dated phase's own arrival event) -- same distinction early_access/
   early_access_release already draws, now drawn for alpha and beta too.

   docs/events/uncancellation.md U6: .badge--uncancellation joins this group
   too -- the exit from `cancelled` reads as good news, same family as a
   phase arriving. */
.badge--release,
.badge--alpha,
.badge--beta,
.badge--early_access,
.badge--port_release,
.badge--early_access_release,
.badge--alpha_release,
.badge--beta_release,
.badge--uncancellation {
  background: color-mix(in srgb, var(--good) var(--tint-strong), transparent);
  color: var(--good);
}

/* Media: video, trailer_million */
.badge--video,
.badge--trailer_million {
  background: color-mix(in srgb, var(--event-media) var(--tint-strong), transparent);
  color: var(--event-media);
}

/* Presence expanding: steam_listing, gog_listing, wikipedia_listing, platform,
   port_date, showcase_appearance, early_access_date -- a game's presence
   widening, not a schedule change.

   docs/phases/alpha-beta-phases.md E4: alpha_date/beta_date join this
   family beside early_access_date -- the dated phase ANNOUNCING (a date
   appearing in IGDB's catalogue), same presence-widening meaning as
   early_access_date/port_date, not the launch-progress family above. */
.badge--steam_listing,
.badge--gog_listing,
/* docs/events/hype-move-coverage.md X3: the storefront-breadth pair's own
   listing arm, seated beside its store cousins for the same reason. */
.badge--web_listing,
.badge--wikipedia_listing,
.badge--platform,
.badge--port_date,
.badge--showcase_appearance,
/* docs/events/hype-move-coverage.md S2/X3: the SAME exposure as
   showcase_appearance above -- the roster gate now decides which of the
   two types this is, not whether either exists, so they share one colour. */
.badge--showcase_lineup,
.badge--early_access_date,
.badge--alpha_date,
.badge--beta_date {
  background: color-mix(in srgb, var(--event-presence) var(--tint-strong), transparent);
  color: var(--event-presence);
}

/* docs/signals/showcases.md U9 (Y1): the live badge reads the SAME
   --event-presence tint as the group above -- a showcase's presence
   widening (it is on air right now), not a schedule change -- but is NOT an
   Event::TYPES value, so it is a separate rule rather than a member of that
   comma-selector group (test/helpers/events_helper_test.rb's anti-drift scan
   assumes every selector in a shared group maps to a real Event type; the
   .badge--delay / .badge--cancellation split is the same precedent, one
   family across physically separate rules). No new colour token. */
.badge--live {
  background: color-mix(in srgb, var(--event-presence) var(--tint-strong), transparent);
  color: var(--event-presence);
}

/* Our own rankings: chart_entry, reviewed_chart_entry. unanticipated_review
   joins this family too: docs/events/unanticipated-reviews.md U1/U7 --
   it is derived from our own catalogue-curation reading of a game's raw
   fields (igdb_hypes, platform count) against its critic score, not an IGDB
   catalogue change. anticipation_milestone joins here too
   (docs/events/hype-move-coverage.md H/X3): derived from our own reading of
   an IGDB anticipation number crossing a threshold, the same self-observed
   provenance as the other two. */
.badge--chart_entry,
.badge--reviewed_chart_entry,
.badge--unanticipated_review,
.badge--anticipation_milestone {
  background: color-mix(in srgb, var(--accent) var(--tint-strong), transparent);
  color: var(--accent-strong);
}

/* Cautionary: delay, cancellation.
   docs/events/date-change-taxonomy.md T8: the graded delay trio joins here,
   one comma-group -- badge COLOUR carries direction, badge TEXT carries
   magnitude (EVENT_LABELS names "Slight delay"/"Delayed"/"Major delay"). */
.badge--delay,
.badge--delay_small,
.badge--delay_medium,
.badge--delay_major {
  background: color-mix(in srgb, var(--warn) var(--tint-strong), transparent);
  color: var(--warn);
}

/* docs/events/date-change-taxonomy.md T8: the ungraded date_moved_earlier
   type and its graded trio -- :good, the same family as launch progress
   above (a pull-forward is good news too), one comma-group of its own so
   R10a's "types sharing a selector share a BADGE_FAMILIES value" holds. */
.badge--date_moved_earlier,
.badge--date_moved_earlier_small,
.badge--date_moved_earlier_medium,
.badge--date_moved_earlier_major {
  background: color-mix(in srgb, var(--good) var(--tint-strong), transparent);
  color: var(--good);
}

.badge--cancellation {
  background: color-mix(in srgb, var(--danger) var(--tint-strong), transparent);
  color: var(--danger);
}

/* ---------- News entries ---------- */

.entry-list {
  display: flex;
  flex-direction: column;
  gap: 1.1rem;
}

.entry {
  padding: 1rem 1.1rem;
}

/* The event badge, and the entry's own phase chip when the surface passes
   phases_by_id. */
.entry__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  /* Two nowrap children since the phase chip joined the badge; without a
     wrap the pair overflows the entry panel at 390px in the locales whose
     labels run long (it/fi), which an EN screenshot cannot show. */
  flex-wrap: wrap;
}

/* Neutral outline pill, same tokens as .game-card__port-tag/.game-type-tag
   -- a timeline entry naming its release is a tag, not a value, so it takes
   the same chrome rather than a colour of its own. */
.entry__phase {
  flex: 0 0 auto;
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  padding: 0.1rem 0.45rem;
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--text-muted);
  white-space: nowrap;
}

.entry__game {
  display: block;
  font-size: var(--text-md);
  font-weight: 600;
  color: var(--text-muted);
  margin-block: 0.35rem 0.15rem;
}

/* docs/events/event-prominence.md M. "Who is this game" beside the
   entry link on a cross-game surface (/, /updates) -- hype, date and
   platforms, the same three facts a per-game surface already states in its
   hero. */
.entry__game-meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 0 0 0.5rem;
  color: var(--text-muted);
  font-size: var(--text-sm);
}

/* Own class, not a reuse of .game-card__hype-badge (M2 asks for the
   entry's own), but the same solid tier-filled shape -- joined into that
   badge's tier-colour rules above rather than duplicated. */
.entry__hype {
  flex: 0 0 auto;
  min-width: 1.5rem;
  padding: 0.05rem 0.35rem;
  border-radius: var(--radius-pill);
  font-size: var(--text-sm);
  font-weight: 800;
  line-height: 1.3;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

/* The container is 1500px wide because the tables and grids want the room;
   a sentence does not. Cap the snippet at a reading measure so it wraps at a
   comfortable line length instead of stretching the full width of the card. */
.entry__snippet {
  color: var(--text-muted);
  margin: 0 0 0.5rem;
  font-size: var(--text-lg);
  max-width: 80ch;
}

.entry__meta {
  color: var(--text-muted);
  font-size: var(--text-sm);
  margin: 0;
}

/* The video a `video` event announced. Native <details>, closed by default --
   opening pushes the entry taller rather than reflowing the list above it. */
.entry__reveal {
  margin-top: 0.6rem;
}

details.entry__reveal > summary {
  cursor: pointer;
  color: var(--text-muted);
  font-size: var(--text-md);
  font-weight: 600;
}

/* YouTube's own default embed width, not the entry's own measure -- a list
   of one-line changes should not carry a full-bleed player. */
.entry__reveal .game-trailer {
  width: min(100%, 40rem);
}

.entry__reveal .game-video + .game-video {
  margin-top: 1rem;
}

/* The gallery sizes this as a page-level heading. Inside a timeline entry it
   labels one revealed video and must not outweigh the entry's own sentence. */
.entry__reveal .game-trailer__name {
  font-size: var(--text-lg);
}

.entry__reveal .game-video {
  margin-top: 0.6rem;
}

/* docs/site/front-door-redesign.md FD11 (second amendment, 2026-09-02): an
   entry is three lines -- event type, the game, what happened -- and the game
   is the cover with a title over its subtitle beside it. The block places all
   three itself, so the sentence below starts at the card's own padding instead
   of indenting past a rail, which is what the article-level grid used to force
   on every line. .entry--with-cover keeps no layout: it is only the marker for
   "this surface shows covers", which /updates does not. */
.entry__game-block {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  column-gap: 0.75rem;
  row-gap: 0.2rem;
  margin-block: 0.5rem 0.6rem;
}

.entry__game-block > .entry__game {
  grid-column: 2;
  grid-row: 1;
  align-self: end;
  margin-block: 0;
}

.entry__game-block > .entry__game-meta {
  grid-column: 2;
  grid-row: 2;
  align-self: start;
  margin: 0;
}

/* Cover art's own 3:4 -- a square crops every one of them. Shared by the real
   image and the initial-letter tile below, which is what keeps the titles on
   one left edge down a feed. */
.entry__cover {
  grid-column: 1;
  grid-row: 1 / span 2;
  width: 48px;
  height: 64px;
  object-fit: cover;
  border-radius: var(--radius-xs);
  background: var(--surface-overlay);
}

/* Same initial-letter tile as .chart-entry__cover--placeholder and
   .calendar-window__cover--placeholder -- a coverless game is 2% of the
   catalogue, and an empty square reads as a broken image where a letter reads
   as "no art". Smaller than theirs: this tile is 48px, not a card's. */
.entry__cover--placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--text-xl);
  font-weight: 700;
  color: var(--text-muted);
}

/* A clustered entry (docs/events/event-prominence.md C,
   date-change-taxonomy.md K) stands for many games, so it has no identity
   line at all -- and no cover, not even a placeholder square. Its three lines
   are badge, summary, provenance, and compact here is the space between them:
   tighter block padding than a full .entry, and the badge sitting close over
   the disclosure it labels rather than at the arm's length a video reveal
   wants from the sentence above it. */
.entry--facade {
  padding-block: 0.75rem;
}

.entry--facade > .entry__reveal {
  margin-top: 0.35rem;
}

/* A smaller thumbnail on a phone (docs/site/front-door-redesign.md FD11) --
   the subtitle wraps to two rows at that width, and 48px of cover is width the
   title cannot spare there. */
@media (max-width: 480px) {
  .entry__cover {
    width: 36px;
    height: 48px;
  }
}

/* ---------- Search / pagination ---------- */

/* Chrome comes from the shared text-control rule in Controls; this is only
   how a search field takes up space in its row. */
.search-form__input {
  flex: 1 1 240px;
  min-width: 0;
}

/* A search form's submit is `.btn .btn--primary` like every other CTA. There
   is deliberately no .search-form__submit here saying the same thing in slightly
   different numbers -- no font-size at all (so it inherited 1rem where .btn
   is 0.9rem), a flat border instead of the transparent 1px, and its own
   padding. The variant that IS deliberate lives on .games-toolbar__search
   below, where the button is part of a segmented control. */

/* docs/site/search-page.md SP4: the /search page's hero form -- spans the
   content column, field and button side by side, the field visibly larger
   than the browse toolbar's (.games-toolbar__search, whose field pins to
   --text-lg/--control-h). This is the ONE consumer of the bare
   .search-form__input that gets a bigger size; the browse toolbar's own
   override (below) is unaffected since it's more specific. */
.search-hero {
  display: flex;
  gap: 0.6rem;
  margin-block: 1rem 1.5rem;
  flex-wrap: wrap;
}

.search-hero .search-form__input {
  flex: 1 1 320px;
  min-width: 0;
  min-height: calc(var(--control-h) + 0.6rem);
  padding: 0.7rem 1rem;
  font-size: var(--text-2xl);
}

.search-hero .btn {
  font-size: var(--text-lg);
  padding-inline: 1.4rem;
}

.pagination {
  display: flex;
  align-items: center;
  gap: 1rem;
  margin-block: 1.5rem;
}

/* Next always hugs the right edge,
   whether or not Previous/the page indicator are present next to it; both
   links reserve a >=40px hit target (touch-target size, not just a text
   link's line-height). */
.pagination__prev,
.pagination__next {
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  min-width: 40px;
  padding-inline: 0.4rem;
  font-weight: 600;
}

.pagination__next {
  margin-left: auto;
}

.pagination__status {
  color: var(--text-muted);
  font-size: var(--text-md);
  font-variant-numeric: tabular-nums;
}

/* ---------- Select control (every <select> in the app) ---------- */

/* One control everywhere: the sort and filter selects on /games, the admin
   filter rows, the inline event_type pickers, the settings and trace forms.
   Each <select> sits in a .select-shell, which owns the chevron and (where the
   engine supports it) the drop-down list itself. SIZING is the shell's job --
   the modifiers below -- so the control inside is identical wherever it lands
   and no page grows a select of its own. A select without a shell loses the
   chevron, which is the point: the omission shows.
   The chevron is drawn in CSS (two borders on a rotated box), because the
   policy allows no `data:` URL anywhere and so no
   inline SVG background. Inset from the border by its own gutter -- the UA's
   own arrow sits flush against the edge, which is what looked wrong. */
.select-shell {
  position: relative;
  display: block;
  min-width: 0;
}

/* Full-width inside its shell, with enough right padding that a long option
   name clips against the chevron's gutter rather than running underneath it. */
.select-shell > select {
  appearance: none;
  width: 100%;
  min-width: 0;
  min-height: var(--control-h);
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0 2rem 0 0.7rem;
  color: var(--text);
  font-size: var(--text-lg);
  text-overflow: ellipsis;
  cursor: pointer;
}

.select-shell > select:hover {
  border-color: var(--text-muted);
}

.select-shell::after {
  content: "";
  position: absolute;
  top: 50%;
  right: 0.85rem;
  width: 0.4rem;
  height: 0.4rem;
  margin-top: -0.3rem;
  border-right: 2px solid var(--text-muted);
  border-bottom: 2px solid var(--text-muted);
  transform: rotate(45deg);
  /* the chevron is decoration sitting on top of the select -- clicks belong to
     the control underneath it, or the arrow becomes a dead zone. */
  pointer-events: none;
}

/* Sizing modifier: a select sharing a flex row with others (the admin filter
   rows, the event_type pickers next to their Save button) takes its share of
   the line. Inherited from the old .filter-bar__select, which sized the select
   itself; the shell is the flex item now. Inert wherever the parent isn't a
   flex container -- the settings and trace forms just get the full width. */
.select-shell--filter {
  flex: 1 1 150px;
}

/* Sized to its content, not stretched (see .games-toolbar__sort below), with a
   floor so short values ("Hype") still give the control a usable width. Content-
   sized rather than fixed because the sort value is the one select whose text
   must always be fully readable -- "Meilleure correspondance" (fr) needs 200px,
   and a fixed 10.5rem would fade half of it out. */
.select-shell--sort {
  flex: 0 0 auto;
  width: auto;
  min-width: 10.5rem;
  max-width: 100%;
}

/* Chromium's native picker opens OVER the closed select -- the list covers the
   value you came to change -- and its arrow cannot be moved. `base-select`
   renders the picker as an in-page popover anchored BELOW the control, which is
   the fix, and lets the list carry this theme instead of the UA's. Chromium
   only for now; every other engine keeps the native picker and the ::after
   chevron above, both of which are fine. */
@supports (appearance: base-select) {
  .select-shell > select,
  .select-shell > select::picker(select) {
    appearance: base-select;
  }

  /* base-select brings its own arrow -- a filled triangle, heavier than the
     chevron every other engine gets here. Hide it and keep .select-shell::after,
     so the control looks identical in every engine and only the picker differs.
     The chevron therefore sits ON TOP of the select rather than in its flow. */
  .select-shell > select::picker-icon {
    display: none;
  }

  /* base-select puts the chosen option's content inside the control as real
     layout (::selectedcontent). Only a limited set of properties applies to that
     pseudo -- overflow/text-overflow/flex on it do NOTHING, so a long value
     neither ellipsises nor shrinks: it runs straight through the chevron's
     gutter to the border. (`overflow: hidden` here only stops it escaping the
     box -- it clips at the PADDING box, i.e. after the gutter.) */
  .select-shell > select {
    display: flex;
    align-items: center;
    overflow: hidden;
    white-space: nowrap;
  }

  /* ...so the value is faded out before the gutter by a strip drawn OVER it.
     Not a mask on the select: a mask applies to the whole element, and eats its
     border and background along with the text. Inset 1px to spare the border,
     and painted before ::after (tree order), so the chevron stays on top. */
  .select-shell::before {
    content: "";
    position: absolute;
    top: 1px;
    right: 1px;
    bottom: 1px;
    width: 2.6rem;
    border-radius: 0 var(--radius) var(--radius) 0;
    background: linear-gradient(to right, transparent, var(--surface-raised) 55%);
    pointer-events: none;
  }

  .select-shell:has(> select:open)::after {
    margin-top: -0.15rem;
    transform: rotate(-135deg);
  }

  .select-shell > select::picker(select) {
    margin-top: 0.3rem;
    max-height: 20rem;
    padding: 0.3rem;
    background: var(--surface-overlay);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
    color: var(--text);
    /* the platform list overflows 20rem, and a default scrollbar inside a dark
       popover is a bright white slab */
    scrollbar-width: thin;
    scrollbar-color: var(--border) transparent;
  }

  .select-shell option {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.4rem 0.5rem;
    border-radius: var(--radius-sm);
    font-size: var(--text-lg);
  }

  .select-shell option:hover {
    background: color-mix(in srgb, var(--accent) var(--tint-strong), transparent);
  }

  .select-shell option:checked {
    color: var(--accent-strong);
    font-weight: 600;
  }

  .select-shell option::checkmark {
    color: var(--accent-strong);
  }
}

/* ---------- Toolbar + filter drawer (games index B) ---------- */

/* the form itself: one control row holding search, the drawer's toggle
   and the sort group, with the drawer's panel wrapping onto a line of its own
   underneath. */
/* No leading margin: the h1 above it is visually hidden (it only repeated the
   nav), so the toolbar is the page's first visible element and main's own
   2rem of top padding is the whole gap. */
.games-toolbar {
  margin-block: 0 0.75rem;
}

/* Wrapping is load-bearing here, not just a narrow-screen fallback: the open
   drawer's panel is a 100%-basis item on this row, so it forms its own line
   (see .filter-drawer below). */
.games-toolbar__row {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex-wrap: wrap;
}

/* The search field and its submit are ONE control: the wrapper carries the
   border, background and radius, and the button is nested inside it. Everything
   here is scoped to .games-toolbar__search on purpose -- the same
   .search-form__input/__submit classes are used bare in the admin filter rows,
   where they must keep their own chrome.
   Capped at 30rem: an 830px-wide search box on a 1280px viewport is not a
   better search box, it just strands the sort group at the far edge. */
.games-toolbar__search {
  display: flex;
  align-items: center;
  flex: 1 1 260px;
  max-width: 30rem;
  min-width: 0;
  min-height: var(--control-h);
  padding: 3px;
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.games-toolbar__search:hover {
  border-color: var(--text-muted);
}

.games-toolbar__search .search-form__input {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
  padding: 0 0.55rem;
  background: none;
  border: none;
  border-radius: 0;
}

/* The field has no border of its own to outline,
   so keyboard focus on it rings the whole bar. Scoped with :has to the FIELD:
   the button sits inside the same wrapper and keeps its own ring, or focus
   between the two would be indistinguishable. */
.games-toolbar__search:has(.search-form__input:focus-visible) {
  outline: 2px solid var(--accent-strong);
  outline-offset: 2px;
}

.games-toolbar__search .search-form__input:focus-visible {
  outline: none;
}

/* The one deliberate button variant: inside the search bar the submit is half
   of a segmented control, not a free-standing CTA, so it fills the bar's
   height instead of keeping the app-wide one and tucks its corners inside the
   bar's. Still a .btn -- this only overrides what being inset requires. */
.games-toolbar__search .btn {
  align-self: stretch;
  min-height: 0;
  padding: 0 0.9rem;
  border-radius: calc(var(--radius) - 4px);
  font-size: var(--text-md);
}

/* the default 2px offset would put the ring outside the bar it sits in */
.games-toolbar__search .btn:focus-visible {
  outline-offset: 0;
}

/* Sort is ordering, not membership -- set it apart with a label + a divider,
   sized to its content instead of stretching (same reasoning as the old
   .filter-bar__sort it replaces). */
.games-toolbar__sort {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-left: auto;
  padding-left: 0.9rem;
  border-left: 1px solid var(--border);
}

.games-toolbar__sort-label {
  color: var(--text-muted);
  font-size: var(--text-md);
  font-weight: 600;
  white-space: nowrap;
}

/* the direction toggle sitting
   next to the sort select; the arrow is aria-hidden, the accessible
   name lives on the link itself via aria-label. */
.sort-direction {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--control-h);
  height: var(--control-h);
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text-muted);
  font-size: var(--text-lg);
  flex: none;
}

.sort-direction:hover {
  color: var(--text);
  border-color: var(--text-muted);
  text-decoration: none;
}

/* Once the row wraps, the sort group drops to its own line -- a left divider and
   auto margin read as broken there, so clear them and let it fill the width. */
@media (max-width: 560px) {
  .games-toolbar__sort {
    margin-left: 0;
    padding-left: 0;
    border-left: none;
    width: 100%;
  }

  .select-shell--sort {
    flex: 1 1 auto;
    width: auto;
  }
}

/* docs/phases/canonical-release.md M1: the status control -- Upcoming /
   Out now / TBA, a native radio group dressed in the same pill chrome
   .tabs--pill uses for aria-current below: a radio's own :checked state is
   this control's equivalent "applied" signal. Sits on the toolbar row beside
   the search box and the Filters disclosure -- fieldset/legend carry no
   border or margin of their own, so the group reads as one more row item,
   not a boxed sub-form. The native input itself is .visually-hidden (same
   utility the legend uses): the pill's fill/border already carries the
   selected state, so a second, redundant dot would just repeat it -- the
   exact call .tabs--pill's own aria-current fill already made. */
.games-toolbar__status {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0;
  padding: 0;
  border: none;
}

.games-toolbar__status-option {
  display: inline-flex;
  align-items: center;
  min-height: var(--control-h);
  padding: 0 0.9rem;
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  color: var(--text-muted);
  font-size: var(--text-md);
  font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
}

.games-toolbar__status-option:hover {
  color: var(--text);
  border-color: var(--text-muted);
}

/* The applied bucket -- the same fill .tabs--pill's aria-current state
   uses, the radio's own :checked in place of that anchor's aria-current. */
.games-toolbar__status-option:has(input:checked) {
  color: var(--accent-contrast);
  background: var(--accent);
  border-color: var(--accent);
}

.games-toolbar__status-option:has(input:focus-visible) {
  outline: 2px solid var(--accent-strong);
  outline-offset: 2px;
}

/* the filter drawer: platform/genre/year/hype, disclosed on
   demand. Server-rendered <details open> -- no JS needed to operate it. */

/* The drawer lives INSIDE .games-toolbar__row so its toggle shares the line
   with search and sort, but its panel must still get the full width below them.
   A <summary> cannot leave its <details>, so the <details> box itself is what
   goes: `display: contents` promotes the <summary> and the UA's
   ::details-content box to flex items of the row.
   This rule is the fallback for engines without ::details-content (where
   `display: contents` on a <details> risks defeating the disclosure): the whole
   drawer takes a line of its own at the end of the row, which is where it sat
   before this change. */
.filter-drawer {
  order: 1;
  flex: 1 0 100%;
}

@supports selector(::details-content) {
  .filter-drawer {
    display: contents;
  }

  /* Closed: out of the layout entirely -- a zero-width flex item would still
     force a line break and collect the row's gap on both sides. */
  .filter-drawer:not([open])::details-content {
    display: none;
  }

  /* Open: a full-width line, ordered last so the sort group stays up on line 1.
     The panel is before the sort group in the DOM (the drawer must wrap its own
     summary), so tabbing runs search -> Filters -> the five selects -> sort:
     out of visual order for the sort group, but it does put the selects the
     toggle just revealed next in line, which is the more useful order. */
  .filter-drawer[open]::details-content {
    order: 1;
    flex: 1 0 100%;
  }
}

/* The toggle is a pill, not a line of muted text: it is the only way into five
   filters, so it has to look like something you press. Same height as the
   controls it now shares a line with (--control-h).

   The chart platform menu takes the same treatment for the same reason: it is
   a disclosure sharing a row with pill controls, and the header's own
   .nav-menu chrome (muted text, no border) reads as a caption once it sits
   beside them. This is a shared rule rather than a second trigger style --
   three near-identical dropdowns is how .select-shell got written.
   docs/calendar/calendar-filters.md G4: .calendar-platform-menu joins the
   same list, a fourth near-identical dropdown solved once rather than again. */
.filter-drawer > summary,
.chart-platform-menu > summary,
.chart-genre-menu > summary,
.calendar-platform-menu > summary {
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  width: max-content;
  min-height: var(--control-h);
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  color: var(--text-muted);
  font-size: var(--text-md);
  font-weight: 600;
  padding: 0 0.9rem;
}

/* A drawer with filters in it says so on the toggle too, not only in the badge
   -- the pill is what you see first when the page loads scrolled to the cards. */
.filter-drawer:has(.filter-drawer__count) > summary {
  border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
  color: var(--text);
}

/* give the summary a disclosure affordance. Without one it renders as
   plain muted text and reads as a section LABEL, not a control nobody thinks
   to click. The native ::marker triangle is hidden on all four: the first
   three replace it with a chevron of their own, rotating on open, so the
   closed/open state stays legible without it; .phase-section__reveal
   replaces it with the "Show details"/"Hide details" text below instead
   (.phase-section__toggle) -- the affordance moved from an icon to real
   words, so nothing needs to draw a mark here any more either way.
   "Why this score" (.hype-breakdown, games/hype_breakdown) is a separate
   <details> and keeps its OWN native marker -- see its own rule further
   down -- because that one really is a small inline expand, not a whole
   card's disclosure. */
.filter-drawer > summary::-webkit-details-marker,
.chart-platform-menu > summary::-webkit-details-marker,
.chart-genre-menu > summary::-webkit-details-marker,
.calendar-platform-menu > summary::-webkit-details-marker,
.phase-section__reveal > summary::-webkit-details-marker {
  display: none;
}

.filter-drawer > summary::marker,
.chart-platform-menu > summary::marker,
.chart-genre-menu > summary::marker,
.calendar-platform-menu > summary::marker,
.phase-section__reveal > summary::marker {
  content: "";
}

.filter-drawer > summary::before,
.chart-platform-menu > summary::before,
.chart-genre-menu > summary::before,
.calendar-platform-menu > summary::before {
  content: "";
  width: 0.42em;
  height: 0.42em;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(-45deg) translate(-0.1em, -0.1em);
  transition: transform 120ms ease;
}

.filter-drawer[open] > summary::before,
.chart-platform-menu[open] > summary::before,
.chart-genre-menu[open] > summary::before,
.calendar-platform-menu[open] > summary::before {
  transform: rotate(45deg) translate(-0.05em, -0.05em);
}

.filter-drawer > summary:hover,
.chart-platform-menu > summary:hover,
.chart-genre-menu > summary:hover,
.calendar-platform-menu > summary:hover {
  color: var(--text);
}

/* the bare count and nothing else (no brackets/padding markup), so it
   reads cleanly next to the summary label and can be asserted exactly. */
.filter-drawer__count {
  display: inline-block;
  background: var(--accent);
  color: var(--accent-contrast);
  font-size: var(--text-xs);
  font-weight: 700;
  line-height: 1;
  padding: 0.2rem 0.45rem;
  border-radius: var(--radius-pill);
}

/* The open drawer is a panel, so the five fields read as one group instead of
   floating on the page background. --surface-overlay sits a step off the page
   in both themes, which keeps the --surface-raised controls legible on top of
   it. The 14rem track cap (not five 1fr tracks) is what stops a select being
   237px wide to hold the word "Hot"; space-between spreads the slack left over
   instead of parking it at the end of the row. */
.filter-drawer__row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9.5rem, 14rem));
  justify-content: space-between;
  gap: 0.7rem 0.6rem;
  padding: 0.85rem;
  border: 1px solid var(--border);
}

/* No top margin in the normal path -- the panel is its own line on the toolbar
   row, so the row's 0.6rem gap already separates it. Only the fallback path
   (drawer still a block containing summary + panel) has to space them itself. */
@supports not selector(::details-content) {
  .filter-drawer__row {
    margin-top: 0.6rem;
  }
}

.filter-field {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  min-width: 0;
}

/* Each select gets a real <label>: "Any year" self-labels, "2026" does not --
   and until now these five had no accessible name at all. */
.filter-field__label {
  color: var(--text-muted);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
}

/* One field per line on a phone, filling it: the 14rem track cap that keeps a
   desktop select from bloating would otherwise leave a third of every row
   empty. Must sit AFTER the base rule -- same specificity, source order
   decides. */
@media (max-width: 560px) {
  .filter-drawer__row {
    grid-template-columns: 1fr;
  }
}

/* the .js-enabled hide rule that
   belongs here: selects autosubmit via autosubmit_
   controller.js, but the text-search submit now stays visible always, JS or
   not, so a search field never looks like it does nothing without a
   visible way to fire it. */

/* the applied cut, always visible whatever the drawer is doing. The row
   is the chip list plus the clear-all link, so the link sits with the chips
   rather than under them. */
.filter-chips {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-block: 0.75rem 1rem;
}

.filter-chips__clear {
  color: var(--text-muted);
  font-size: var(--text-sm);
  font-weight: 600;
}

.filter-chips__clear:hover {
  color: var(--text);
}

.filter-chip {
  gap: 0.35rem;
}

.filter-chip__remove {
  color: inherit;
  font-weight: 700;
  line-height: 1;
}

.filter-chip__remove:hover {
  color: var(--danger);
  text-decoration: none;
}

/* ---------- Front door ---------- */

/* No top margin: main.container already supplies the 2rem every page opens
   on, and headings are reset to margin-block-start 0, so every other page's
   first element sits flush against that padding. A margin here would start
   the home page 1.5rem lower than the entire rest of the site.
   docs/site/front-door-redesign.md FD1/FD2: replaces the deleted
   .intro-band -- same block spacing, one heading and one search form,
   nothing else. */
.home-search {
  margin-block: 0 2rem;
}

.home-search__title {
  font-size: var(--text-hero);
  margin: 0 0 1rem;
}

.news-feed__more {
  margin-top: 0.5rem;
}

.news-feed__depth {
  color: var(--text-muted);
  font-size: var(--text-md);
  margin-top: 0.5rem;
}


/* ---------- Trending row (home page) ---------- */

.trending-strip {
  margin-block: 2rem;
}

.game-card__trend {
  color: var(--accent-strong);
  font-size: var(--text-sm);
  font-weight: 700;
  margin: 0 0 0.4rem;
}

/* docs/site/search-page.md SP5/SP6: the /search page's recovery/bare-state
   strip -- same rhythm as .trending-strip, on its own class because it can
   render directly under an .empty-state (SP6) rather than always at the top
   of the page. */
.search-popular {
  margin-block: 2rem;
}

.search-popular__more {
  margin-top: 0.75rem;
}

/* ---------- Empty state ---------- */

.empty-state {
  color: var(--text-muted);
  padding: 2rem 0;
}

/* The same idea standing INSIDE a group rather than in place of a page's whole
   content: no vertical air, because the group's own heading already separated
   it, and the group's type size. Both halves matter -- the
   retired .admin-metrics-empty carried font-size: var(--text-md) as well as no
   padding, so zeroing only the padding would have grown 21 admin empty states
   from 13.6px to 16px. */
.empty-state--compact {
  padding: 0;
  font-size: var(--text-md);
}

/* ---------- Controls ---------- */

/* Form controls do NOT inherit font-family from the UA default -- body's
   --font-sans never reached them. So `.btn` on an <a> rendered in the app's
   typeface and the same `.btn` on a <button> rendered in the UA's control
   font: the homepage's Calendar link and the Save button in /admin/settings
   were visibly different typefaces wearing the same class. Every control also
   sets its own font-size, so this only ever adds the family. */
button,
input,
select,
textarea {
  font: inherit;
  color: inherit;
}

/* Checkboxes and radios are left native and themed. accent-color is the whole
   fix: it tints the UA widget in both halves of the palette, which is the
   right amount of work for two checkboxes and five radios. Before this the
   radios in the review queue had no CSS at all. */
input[type="checkbox"],
input[type="radio"] {
  accent-color: var(--accent);
  width: 1rem;
  height: 1rem;
  margin: 0;
}

/* ONE text control. There were two, and on /admin/settings they stood in the
   same form as the select and disagreed with it about height (no floor at all
   vs --control-h), background (--surface vs --surface-raised) and size
   (0.95rem vs 0.9rem) -- three ways for one row to look assembled from spare
   parts.
   They resolve TOWARDS the select rather than the other way round: the select
   is the settled control, unified and signed off app-wide, so it is the one
   that should not move.
   Layout stays with each consumer -- .search-form__input's flex basis, the
   form field's full width -- because that is where a control sits, not what
   it is. No third class was invented: these two selectors already cover every
   text input in the app, and a `.field` to add to markup is how a third
   control would start. */
.form-field input,
.form-field textarea,
.search-form__input {
  min-height: var(--control-h);
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.4rem 0.75rem;
  color: var(--text);
  font-size: var(--text-lg);
  line-height: 1.3;
}

/* Inert reads as inert on every control, not just on buttons. .btn:disabled
   was the only rule, so a disabled text field or select looked identical to a
   live one -- and the permanently-checked English box on /languages, which is
   disabled precisely because it cannot be turned off, gave no sign of it. */
.btn:disabled,
.form-field input:disabled,
.form-field textarea:disabled,
.search-form__input:disabled,
.select-shell > select:disabled,
.checkbox-label:has(input:disabled) {
  opacity: .5;
  cursor: not-allowed;
}

/* The app's first pressed state -- there was no :active anywhere, so a click
   gave no feedback between hover and the page changing. Deliberately a filter
   rather than a transform or a transition: it is instantaneous, so it needs no
   entry in the prefers-reduced-motion block at the end of this file and stays
   identical for a reader who asked for less motion. */
.btn:active,
.filter-drawer > summary:active {
  filter: brightness(0.94);
}


/* ---------- Buttons ---------- */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* The app's icon+label scale (.site-nav a, .admin-nav__sign-out). .btn was
     the one such control without it, and relied on an ERB whitespace text
     node instead -- which a flex container discards, leaving the glyph flush
     against the label. A text-only button has one child and is unaffected. */
  gap: 0.4rem;
  /* --control-h says "one height for every control that sits on a row with
     another control", and .btn was the one that never joined -- which is why
     the /games toolbar needed a bespoke submit button to line up.
     line-height is load-bearing here, not decoration: min-height ALONE is a
     no-op, because the inherited 1.5 already puts an 0.9rem button at 39.6px,
     past the 38px floor. Pinning it to 1.2 drops the intrinsic height to
     ~30px so min-height actually governs, while a label that wraps at 390px
     still grows past it. */
  min-height: var(--control-h);
  line-height: 1.2;
  border: 1px solid transparent;
  border-radius: var(--radius);
  padding: 0.35rem 1.1rem;
  font-size: var(--text-lg);
  font-weight: 600;
  cursor: pointer;
}

/* the generic "do the main thing"
   CTA, admin and public alike (the public-only .btn--follow this was once
   paired with came out with the accounts layer,
   ). */
.btn--primary {
  background: var(--accent);
  color: var(--accent-contrast);
}

.btn--primary:hover {
  background: var(--accent-strong);
  text-decoration: none;
}

/* The disabled treatment is not restated here: .btn:disabled
   already reaches this element through the six-selector rule it shares with
   the text controls, the select and .checkbox-label, above with the other
   control states. A second copy rendered nothing differently, which is the
   problem -- deleting either one showed no change, so neither looked
   load-bearing. */

/* Secondary/outline button, styled as a sibling of
   .btn--primary (e.g. the Calendar link on the homepage). */
.btn--ghost {
  background: transparent;
  border-color: var(--border);
  color: var(--text);
}

.btn--ghost:hover {
  background: var(--surface-overlay);
  text-decoration: none;
}

/* Visually destructive action (account deletion).
   fills with --danger-solid, not --danger:
   the latter is an ink value (tuned to sit legibly ON a tint), and under
   white text in dark mode it shipped at ~2.77:1, failing even large-text AA.
   --danger-solid exists precisely so a solid fill stays AA-legible. */
.btn--danger {
  background: var(--danger-solid);
  color: #fff;
}

.btn--danger:hover {
  background: color-mix(in srgb, var(--danger-solid) 85%, black);
  text-decoration: none;
}


/* A <button> that has to look like the text or link beside it -- a form is the
   only honest way to POST, but the control should not announce itself as a
   button. .btn-link stripped the UA chrome in THREE separate places before
   this (the header nav, the dropdown panels, the admin sign-out), each
   re-deriving the same reset, and the class itself meant nothing.
   PLACEMENT IS LOAD-BEARING: `.admin-nav__sign-out` is also (0,1,0), so the
   tie between them is broken by source order alone and this must stay above
   it. The other two consumers are (0,2,0) and win on specificity wherever
   they sit. */
.btn-link {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  background: none;
  border: 0;
  margin: 0;
  padding: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
}

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

.auth-card {
  max-width: 380px;
  margin: 2rem auto;
  padding: 1.75rem 1.5rem;
}

.auth-card h1 {
  font-size: var(--text-4xl);
  margin-bottom: 1rem;
}

.auth-card form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.form-field {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

.form-field label {
  font-size: var(--text-md);
  font-weight: 600;
  color: var(--text-muted);
}

/* Chrome comes from the shared text-control rule in Controls; only the layout
   is a form field's own business.
   Selects are deliberately absent from both: a .form-field <select> is the
   app-wide select control (see .select-shell above), not a text input with an
   arrow. */
.form-field input,
.form-field textarea {
  width: 100%;
}

/* No .auth-card input[type="submit"] rule belongs here. Both sign-in buttons
   already carry `class="btn btn--primary"`, and a type selector plus an
   attribute selector outranks a single class -- such a rule would win and the
   Sign in button had been rendering with its own border and padding while
   claiming to be a primary. Deleting it IS the fix -- no markup changed. */

/* ---------- Flash ---------- */

.flash {
  margin: 0 0 1.25rem;
  padding: 0.7rem 1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  font-size: var(--text-lg);
}

.flash--notice {
  background: color-mix(in srgb, var(--accent) var(--tint-faint), transparent);
  border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
  color: var(--accent-strong);
}

.flash--alert {
  background: color-mix(in srgb, var(--danger) var(--tint-faint), transparent);
  border-color: var(--danger);
  color: var(--danger);
}

/* ---------- Settings ---------- */

.settings {
  max-width: 560px;
  margin: 1.5rem auto;
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
}

.settings > h1 {
  font-size: var(--text-5xl);
}


.form-field--checkboxes {
  flex-direction: row;
  flex-wrap: wrap;
  gap: 0.75rem 1.25rem;
}

/* A label wrapping a checkbox or a radio. The geometry was a separate rule
   shared with .admin-review-candidate until that class went with the articles
   review queue; without it the row sits on the text baseline rather than
   centred on the control. */
.checkbox-label {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-weight: 400;
  font-size: var(--text-xl);
  color: var(--text);
}

/* .form-field input stretches a text field to the field's width; a checkbox
   sharing that container must keep the control size instead. */
.form-field--checkboxes input[type="checkbox"] {
  width: 1rem;
}


/* ---------- Operator bar ---------- */

/* docs/admin/operator-bar.md P2/P4. Unmistakably NOT public chrome --
   a dashed --warn border over a faint --warn wash, with an uppercase eyebrow
   in --warn ink. Nothing else in this sheet looks like it, on purpose: a
   reader-facing element mistaken for this one is a cosmetic bug, and this
   one mistaken for reader chrome is an operator pressing Block believing
   it's a public control. Public primitives only inside it (.btn, .btn--ghost,
   .chip, .chip-list) plus the one namespaced modifier below -- P3. */
.operator-bar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.4rem 0.75rem;
  padding: 0.55rem 0.8rem;
  margin-block: 0 0.75rem;
  border: 1px dashed var(--warn);
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--warn) var(--tint-faint), transparent);
  color: var(--text);
  font-size: var(--text-sm);
}

.operator-bar__label {
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--warn);
}

/* P3: the one new namespaced modifier -- no .chip--warn/--danger, which
   would put an operator-only concept on a public primitive. Placed here,
   well after the neutral-chip fallback (Chips, above), so it wins at the
   same (0,1,0) specificity. The fill is --tint-faint rather than --tint-soft
   because it composites over the bar's own wash; palette_contrast_test
   measures it over --surface and so under-reports it. */
.operator-bar__alert {
  background: color-mix(in srgb, var(--warn) var(--tint-faint), transparent);
  color: var(--warn);
}

.operator-bar__facts {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 0.75rem;
  margin: 0;
  color: var(--text);
}

/* white-space: nowrap keeps a pair ("Synced" + its value) from breaking
   between the label and the value; the row itself still wraps between
   pairs via .operator-bar__facts' own flex-wrap. */
.operator-bar__fact {
  display: flex;
  align-items: baseline;
  gap: 0.3rem;
  white-space: nowrap;
}

.operator-bar__fact dt {
  font-size: var(--text-xs);
  text-transform: uppercase;
}

.operator-bar__fact dd {
  margin: 0;
}

.operator-bar__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-inline-start: auto;
}

/* A lone right-aligned button reads as an accident once it wraps onto its
   own line at this width, so the auto margin resets to 0 here. */
@media (max-width: 480px) {
  .operator-bar__actions {
    margin-inline-start: 0;
  }
}

/* ---------- Game hero ---------- */

/* .game-hero is a dark scrim over the
   cover art in BOTH themes, so its UA-rendered chrome should be dark too
   (color-scheme: dark), and it re-declares every --pf-* plus --danger,
   --surface-overlay, --shadow, --accent, --border and --text-muted to their
   plain DARK hex -- the second argument of the matching :root light-dark
   above -- so the hero reads correctly even on an engine where a subtree's
   color-scheme does not win over :root's (the probe verified Blink does;
   WebKit is unverified). Keep every value below in sync with its :root
   token's dark half -- platforms_helper_test.rb asserts the --pf-* ones do.
   This fixes two live light-theme bugs: the muddy
   .game-hero__backdrop--fallback gradient, and the light --shadow on
   .game-hero__cover -- neither of those rules needs editing itself, since
   they read these custom properties from this ancestor. --border and
   --text-muted join the set for .game-type-tag, the first hero element to
   read either. */
.game-hero {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  margin-block: 1rem 2rem;
  color-scheme: dark;

  --pf-windows: #6d87e3;
  --pf-playstation: #d5dce8;
  --pf-xbox: #59cf76;
  --pf-nintendo: #f87777;
  --pf-apple: #adb4c0;
  --pf-android: #9ed369;
  --pf-linux: #e4b744;
  --pf-web: #9e89f0;
  --pf-vr: #da9bf3;

  --danger: #f4868c;
  --surface-overlay: #1b2330;
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.28);
  --accent: #22b8d4;
  --accent-strong: #63d9ee;
  --accent-contrast: #04121a;
  --surface: #0a0e14;
  --border: #2a3444;
  --text-muted: #8d9bb0;
}

/* when the tab bar follows the hero it
   belongs TO the hero, so the hero gives up its 2rem card-gap and the two sit
   as one block; .game-tabs then owns the whole gap down to the page body. A
   hero with no tab bar under it -- no news, no media -- keeps the 2rem above.
   Adjacent-sibling margins collapse, so this is max(0.75rem, the tab bar's
   own margin-block-start) -- both are 0.75rem, deliberately. */
.game-hero:has(+ .game-tabs) {
  margin-block-end: 0.75rem;
}

/* Blurred cover backdrop; z-index 0 keeps it behind .game-hero__inner. A dark
   scrim (::after) sits on top so foreground text stays legible in BOTH
   themes -- the band always reads as "dark", so the foreground below is
   styled with light/white text regardless of prefers-color-scheme. */
.game-hero__backdrop {
  position: absolute;
  inset: 0;
  z-index: 0;
  background-size: cover;
  background-position: center;
  filter: blur(28px) brightness(0.55);
  transform: scale(1.15);
}

.game-hero__backdrop::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, rgba(8, 9, 12, 0.55) 0%, rgba(8, 9, 12, 0.88) 100%);
}

/* No cover: gradient fallback instead of a blurred image. */
.game-hero__backdrop--fallback {
  filter: none;
  transform: none;
  background-image: linear-gradient(135deg, color-mix(in srgb, var(--accent) 40%, var(--surface)) 0%, var(--surface) 100%);
}

/* IGDB key art is landscape and
   meant to be seen, unlike a stretched portrait cover (the reason for the
   heavy 28px blur above), so this modifier blurs much less. The base
   .game-hero__backdrop::after scrim above still applies (this class adds to,
   not replaces, .game-hero__backdrop), so foreground contrast is unchanged. */
.game-hero__backdrop--artwork {
  filter: blur(6px) brightness(0.65);
  transform: scale(1.06);
}

.game-hero__inner {
  position: relative;
  z-index: 1;
  display: grid;
  grid-template-columns: minmax(0, 220px) 1fr;
  gap: 1.5rem;
  padding: 2rem;

  @media (max-width: 560px) {
    grid-template-columns: 1fr;
    padding: 1.5rem 1.25rem;
  }
}

.game-hero__cover {
  width: 100%;
  aspect-ratio: 3 / 4;
  object-fit: cover;
  box-shadow: var(--shadow);
}

.game-hero__cover--placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--text-7xl);
  font-weight: 700;
  color: rgba(255, 255, 255, 0.6);
}

.game-hero__body {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  color: #fff;
}

.game-hero__body h1 {
  margin: 0;
  font-size: var(--text-hero);
  color: #fff;
}

.game-hero__release {
  align-self: flex-start;
  margin: 0;
  padding: 0.25rem 0.75rem;
  border-radius: var(--radius-pill);
  background: rgba(255, 255, 255, 0.14);
  color: #fff;
  font-size: var(--text-md);
  font-weight: 600;
}

.game-hero__meta {
  margin: 0;
  color: rgba(255, 255, 255, 0.75);
  font-size: var(--text-lg);
}

/* What is coming after the headline release. Secondary to the release date
   but not an aside: it carries a date a reader is actively looking for, so
   it takes the hero's own text colour and weight rather than the muted
   treatment .game-hero__meta uses for developer and publisher. */
.game-hero__next {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  margin: 0;
  color: #fff;
  font-size: var(--text-lg);
  font-weight: 600;
}

.game-hero__summary {
  margin-top: 0.4rem;
  max-width: 60ch;
  color: rgba(255, 255, 255, 0.88);
}

/* External links row: small text links, not a button. The hero band always
   reads as dark regardless of prefers-color-scheme, so these stay light/white
   text in BOTH themes. */
.game-hero__links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 1rem;
}

.game-hero__links a {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  color: rgba(255, 255, 255, 0.8);
  font-size: var(--text-md);
  text-decoration: none;
}

/* Underline the label only -- an underline running under the glyph reads as
   a strikethrough at this size. */
.game-hero__links a span {
  text-decoration: underline;
  text-underline-offset: 2px;
}

.game-hero__links .link-icon {
  width: 15px;
  height: 15px;
  flex: none;
}

.game-hero__links a:hover {
  color: #fff;
}

.game-hero .chip--platform {
  background: rgba(255, 255, 255, 0.14);
  color: #fff;
}

/* docs/site/discovery.md DS5/DS6: one linked chip per genre. Same
   translucent-white treatment .chip--platform uses on this always-dark band
   (H2), but not the small-caps .chip base -- a catalogue genre name is
   prose, not an abbreviation-style tag. Absent entirely for a genre-less
   game (DS6, "render nothing when there is nothing") -- there is no empty
   .game-hero__genres shell to style. */
.game-hero__genres {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}

.game-hero__genre {
  display: inline-flex;
  align-items: center;
  padding: 0.2rem 0.65rem;
  border-radius: var(--radius-pill);
  background: rgba(255, 255, 255, 0.14);
  color: rgba(255, 255, 255, 0.9);
  font-size: var(--text-sm);
  text-decoration: none;
}

.game-hero__genre:hover {
  background: rgba(255, 255, 255, 0.22);
  color: #fff;
}

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

/* modelled on .chart-tabs: selected state hangs off
   aria-current="page" only, never a class. The row scrolls
   horizontally and never wraps (same admin-nav/charts-nav idiom, :3025-3071)
   -- a wrapping row would reflow the hero above it. Chrome, so
   --accent/--accent-strong are the correct tokens; no --hype- or tier token
   may appear here (test/helpers/palette_semantics_test.rb).
   NB: never write a star-slash inside a comment -- it CLOSES the comment, and
   the leftover words then eat the next rule whole. That is what silently
   killed this entire block once already. */
/* the row is a navbar, not a filter-chip strip: it sits tight under the
   hero (0.75rem, see .game-hero:has above), the links are spaced apart from
   each other rather than nearly touching, and a rule plus a full 2.5rem of
   air separates the whole bar from the page body -- so the bar reads as
   belonging to the hero above it, never to the content below it. */
.game-tabs {
  display: flex;
  flex-wrap: nowrap;
  gap: 0.75rem;
  overflow-x: auto;
  scrollbar-width: thin;
  margin-block: 0.75rem 2.5rem;
  padding-block-end: 0.85rem;
  border-block-end: 1px solid var(--border);
}

/* ----- Tabs -------------------------------
   ONE tab row, everywhere. Seven rows shipped their own before this: the three
   sticky-bar navs (.admin-nav / .charts-nav / .calendar-scopes) shared a
   treatment, .admin-subnav restated the same underline a size larger,
   .admin-window-select restated it again with the rule taken off, and the two
   public pill rows were their own thing -- four descriptions of one component.

   A tab row is `.tabs` plus, where it differs, a variant:

     .tabs             underline; the default, and what a tab looks like here
     .tabs--pill       boxed, filled when current -- for a row that sits on a
                       hero or a card and has to hold its own visual weight
     .tabs--quiet      no rule at all, selection by weight; for a control
                       nested INSIDE a page that already has a tab row, which
                       must not read as a peer of it

   Size, spacing and scroll behaviour stay with the individual row: they are
   layout, and a bar tab, a page tab and a hero tab genuinely differ there.
   What must not fork again is the shape and the state hook.

   aria-current="page" is the only selected-state signal (house style). There
   is no .active class here and there must not be one. ----- */
.tabs a {
  white-space: nowrap;
  font-weight: 600;
  color: var(--text-muted);
  border-bottom: 2px solid transparent;
}

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

.tabs a[aria-current="page"] {
  color: var(--accent-strong);
  border-bottom-color: var(--accent);
}

/* A pill carries its own box, so the underline would be a second, competing
   selected-state signal: it is taken off rather than left transparent. */
.tabs--pill a {
  flex: none;
  border: 1px solid var(--border);
  border-bottom-width: 1px;
  border-radius: var(--radius);
  padding: 0.45rem 1rem;
  font-size: var(--text-lg);
  background: var(--surface-raised);
}

.tabs--pill a[aria-current="page"] {
  color: var(--accent-contrast);
  background: var(--accent);
  border-color: var(--accent);
}

.tabs--quiet a {
  border-bottom: 0;
}

/* how many stories the News tab holds. Tinted rather than solid so it
   reads as a detail of the label, not a second control; on the selected tab
   the fill is already --accent, so the badge inverts to stay legible. */
.game-tabs__count {
  margin-left: 0.4rem;
  padding: 0.05rem 0.4rem;
  border-radius: var(--radius-pill);
  font-size: 0.78em;
  font-variant-numeric: tabular-nums;
  color: var(--accent-strong);
  background: color-mix(in srgb, var(--accent) var(--tint-soft), transparent);
}

.game-tabs a[aria-current="page"] .game-tabs__count {
  color: var(--accent);
  background: var(--accent-contrast);
}

/* ---------- Game content grid: trailer + hype card ---------- */

/* Stacked, full banner width: banner -> tabs -> trailer -> hype card. */
.game-content {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
  margin-block-start: 1.5rem;
  margin-bottom: 2rem;
}

/* The phase stack: separates adjacent phase cards, since collapsing
   heading margins alone left them touching. */
[data-controller="phase-reveal"] {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

/* .game-gallery renders both inside a phase card and as the page-level
   tail gallery; inside a card it gives up its own chrome, since a card
   never nests inside another card. */
.phase-section .game-gallery {
  background: none;
  border: none;
  box-shadow: none;
  padding: 0;
}

/* The hairline between adjacent body blocks of a phase card, in sibling form
   so no :first-child reset is needed. Never border-bottom: the last block
   would rule against the card's own padding with nothing beneath it. The
   card already supplies a 0.9rem gap, so this padding stays modest and the
   divider reads as one seam rather than two. */
.phase-block + .phase-block {
  padding-top: 1.4rem;
  border-top: 1px solid var(--border);
}

/* One label vocabulary for every block of a phase card's body -- the eyebrow
   treatment .hypemeter__title and .reception__whose already carry. */
.phase-block > h3 {
  margin: 0;
  color: var(--text-muted);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
}

/* The card header: what this release is and when, and then the meter that
   scores it -- STACKED, one full-width column, identity above meter.
   docs/hypemeter/phase-header-meter.md S1: a <div> now, not a heading --
   the <h2> moved one level down, into .phase-section__lede.
   S7: two columns put the release's name and its own bar on opposite edges
   of the card, reading as two unrelated things side by side rather than one
   statement about one release. Stacked, the bar and the sparkline run the
   card's full width under the name they belong to -- which is also what the
   meter looked like before it moved into the header at all. Every phase
   card on a page then draws its bar at the same width by construction,
   where the old auto track sized each to its own label and made "EARLY
   ACCESS-HYPE" and "HYPEMETER" disagree by 50px on one page.

   B-series: this is a plain child of .phase-section now, current or
   collapsed alike -- never inside a <summary> -- so the bleed/gutter
   machinery a shared boundary with a padded summary used to need
   (docs/hypemeter/phase-header-meter.md S7, and .phase-section--solo's
   own un-reservation of it) is retired outright. There is only one header
   shape left, not two agreeing with each other. No border under it any
   more either: "Why this score" (games/phase_card, rendered right after
   this) reads as a continuation of the meter, not a separate block
   introduced by a seam -- just a little breathing room, narrowed from
   .phase-block's own default in .phase-section__header + .phase-block
   below, not zeroed outright. */
.phase-section__header {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  margin: 0;
}

/* .phase-block's own margin-top (below) puts 0.9rem of air above every
   block, including the first one in a card -- right when something
   precedes it worth a full seam from, more than the header (which draws no
   line under itself any more) wants directly beneath it. Narrowed here,
   once, to a small gap for whichever block happens to render first: the
   breakdown card if one did, else the collapsed reveal, else the current
   phase's first body block. */
.phase-section__header + .phase-block {
  margin-top: 0.6rem;
}

/* A collapsed phase's OWN "Show details"/"Hide details" reveal -- B-series
   split this out from the head entirely (see the doc comment in
   _phase_section.html.erb for why two disclosures, never one nested inside
   the other). The native triangle stays hidden (see the shared
   ::-webkit-details-marker/::marker rules above) but is not replaced with a
   mark of its own -- .phase-section__toggle below is real text, reading its
   own state directly instead of needing an icon to carry it, so there is no
   gutter to reserve for one either. */
.phase-section__reveal > summary {
  cursor: pointer;
}

/* Same hairline idiom as the head's own, above -- drawn under the summary
   only once there is revealed content beneath it to separate from. */
.phase-section__reveal[open] > summary {
  padding-bottom: 1.25rem;
  border-bottom: 1px solid var(--border);
}

/* B-series: "Why this score" lives beside the meter now (games/phase_card,
   rendered by _phase_section directly after this header) and keeps its own
   native <details><summary> -- unrelated to .phase-section__toggle below,
   which is a different phase's different disclosure (games/phase_body's
   reception/timeline/videos), reachable only for a collapsed phase and
   never nested inside "Why this score" or vice versa. */
.phase-section__toggle {
  margin: 0.6rem 0 0;
  color: var(--text-muted);
  font-size: var(--text-sm);
  font-weight: 600;
}

/* Exactly one of the two labels is visible at a time -- CSS alone, no JS,
   reading the [open] attribute .phase-section__reveal (not .phase-section
   itself, which is never a <details> any more) already toggles on click.
   The hidden one is excluded from the <summary>'s accessible name by the
   accessible-name computation (display:none content is never included), so
   the summary's natural, un-labelledby'd name always resolves to whichever
   one a sighted reader currently sees -- never both concatenated. */
.phase-section__toggle-label--open {
  display: none;
}

.phase-section__reveal[open] .phase-section__toggle-label--closed {
  display: none;
}

.phase-section__reveal[open] .phase-section__toggle-label--open {
  display: inline;
}

/* docs/hypemeter/phase-header-meter.md S1: the header's leading cell --
   the <h2> plus the head-start captions that sit outside it, both scoped to
   this release's identity rather than its score. */
.phase-section__lede {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  min-width: 0;
}

/* Now an <h2> (S1), so it carries UA margins by default -- reset to nest
   flush inside .phase-section__lede the way the old <span> always did. */
.phase-section__identity {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  min-width: 0;
  margin: 0;
}

.phase-section__title {
  font-size: var(--text-4xl);
  font-weight: 700;
  line-height: 1.2;
}

/* The subtitle row: when this release lands, and what it lands on. Wraps
   rather than overflowing -- a launch can carry a dozen platforms. */
.phase-section__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
}

.phase-section__date {
  color: var(--text-muted);
  font-size: var(--text-md);
  font-weight: 400;
}

/* No breakpoint of its own: S7's stack IS the layout at every width now, so
   the narrow case needs no rule to fall back to. The line is never hidden at
   any width on the game page either -- .chart-entry__spark's own 640 rule is
   a charts rule and says nothing about this one. */

/* docs/ingestion/editions.md E11 -- muted with no margin;
   .game-content's own flex `gap` supplies spacing above it. */
.edition-footnote {
  margin: 0;
  color: var(--text-muted);
  font-size: var(--text-sm);
}

.game-trailer {
  position: relative;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  box-shadow: var(--shadow);
}

.game-trailer__facade {
  position: relative;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  padding: 0;
  border: 0;
  cursor: pointer;
  color: #fff;
  background-color: var(--surface-overlay);
  background-size: cover;
  background-position: center;
}

.game-trailer__facade::before {
  content: "";
  position: absolute;
  inset: 0;
  background: rgba(8, 9, 12, 0.45);
}

.game-trailer__facade .icon {
  position: relative;
  z-index: 1;
  width: 3rem;
  height: 3rem;
}

.game-trailer__label {
  position: relative;
  z-index: 1;
  font-size: var(--text-lg);
  font-weight: 600;
}

.game-trailer iframe {
  width: 100%;
  height: 100%;
  border: 0;
}

/* The video's title, ABOVE its facade rather than captioned under it. It was
   a muted caption when it trailed the video; leading it, it has to carry the
   weight of a heading -- it is the only thing guaranteed to distinguish one
   entry from the next (docs/video/videos-tab.md O1a, as amended by
   docs/video/video-thumbnails.md), since a thumbnail alone can be an ambiguous frame
   or a title card. */
.game-trailer__name {
  margin: 0;
  color: var(--text);
  font-size: var(--text-2xl);
  font-weight: 600;
  line-height: 1.25;
}

/* The video's own upload date, beside its title -- muted so the title still
   leads. Absent entirely on a game with no dates. */
.game-trailer__date {
  margin-inline-start: 0.5rem;
  color: var(--text-muted);
  font-size: var(--text-lg);
  font-weight: 400;
}

/* Pairs one title with one facade. Without a wrapper the two would be
   .game-gallery's own flex siblings, so a title would sit the same gap from
   its video as from the previous one. */
.game-video {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

/* docs/video/video-gallery.md: replaces the Videos tab. Every entry in
   video_entries renders into the DOM as its own .game-video (G2), but the
   controller shows only ONE at a time via the native `hidden` attribute
   rather than a class -- a hidden element's background-image is never
   fetched, so only the visible facade's image loads. The two rules below
   restore that: an author `display` declaration of ANY specificity beats the
   UA stylesheet's `[hidden] { display: none }`, so without them
   `.game-video { display: flex }` above would silently show every facade at
   once. */
/* The gallery is a panel like the other sections of the game page -- its
   chrome comes from PANEL and its column layout from the shared game-page
   rule, both in the Primitives section, so nothing about being a card is
   written here. What is left is only what is the gallery's own. */

/* The facade already sits inside the panel's surface, so it drops the raised
   shadow it wore when it floated on the page background. Two stacked shadows
   read as a card inside a card. */
.game-gallery .game-trailer {
  box-shadow: none;
}

.game-gallery .game-video[hidden] {
  display: none;
}

.game-trailer__facade[hidden] {
  display: none;
}

/* The strip only renders from two videos up (58% of games with a video have
   exactly one -- they get no strip and no controls at all). A horizontally
   scrolling row, not a grid: it reads as film-strip navigation for the
   facade above it, not a second gallery of its own -- arrows were rejected
   (docs/video/video-gallery.md, "Why a strip and not a carousel") in
   favour of the strip itself being the whole control. */
.game-gallery__strip {
  display: flex;
  gap: 0.5rem;
  overflow-x: auto;
  padding-block-end: 0.25rem;
}

.game-gallery__thumb {
  flex: 0 0 auto;
  width: 7.5rem;
  aspect-ratio: 16 / 9;
  border: 2px solid transparent;
  border-radius: calc(var(--radius) - 4px);
  overflow: hidden;
  padding: 0;
  cursor: pointer;
  background-color: var(--surface-overlay);
  background-size: cover;
  background-position: center;
}

/* aria-current is the ONLY active-state signal (docs/house-style.md, G5) --
   never a parallel .is-active class. */
.game-gallery__thumb[aria-current="true"] {
  border-color: var(--accent-strong);
}

/* Replaces the strip when every video is dated and the dates actually span
   something (Game::VideoEntry, video_timeline_columns_style). Columns are
   set inline per game, in real elapsed days -- this sheet only supplies the
   floor and the rows. Row 1 is fixed to the thumb's own rendered height, so
   the axis (row 2) lands right under every thumbnail at the same point. */
.game-gallery__rail {
  --video-thumb-h: calc(7.5rem * 9 / 16);
  --video-tick-gap: calc(7.5rem + 0.5rem);
  display: grid;
  grid-template-rows: var(--video-thumb-h) 0.85rem auto;
  /* N videos sit on N grid lines, which the N-1 explicit tracks supply -- but
     placing an item on the last line opens an implicit track after it. Left at
     auto that track is a thumbnail wide, so the rail ends in dead space and
     the proportional tracks lose that width. */
  grid-auto-columns: 0;
  align-items: start;
  padding-inline: calc(7.5rem / 2);
  padding-block-end: 0.25rem;
  overflow-x: auto;
}

/* A real grid item, not position:absolute -- it has to span the full
   SCROLLED width of the rail, not just the edge of the visible viewport. */
.game-gallery__rail-axis {
  grid-row: 2;
  grid-column: 1 / -1;
  align-self: center;
  height: 2px;
  background: var(--border);
}

/* One item per video, on its own grid line -- justify-self:start plus the
   transform re-centres the item ON the line rather than inside the track
   beside it, which is what lets the first and last items sit flush with the
   rail's own edges once padding-inline (above) makes room for the overhang. */
.game-gallery__rail-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  grid-row: 1 / 4;
  justify-self: start;
  transform: translateX(-50%);
  position: relative;
}

/* Crosses the axis rather than hanging off it, so a thumbnail's exact place on
   the timeline is readable even where the minimum gap has pushed neighbours
   apart. Its row is a fixed height, so every tick is the same length. */
.game-gallery__rail-tick {
  width: 2px;
  height: 0.85rem;
  background: var(--text-muted);
}

/* Capped at the thumbnail's own width and centred under it: a caption wider
   than its track escapes the rail's padding at the first item and runs into
   its neighbour everywhere else. */
.game-gallery__rail-date {
  width: 7.5rem;
  text-align: center;
  color: var(--text-muted);
  font-size: var(--text-2xs);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Over the thumbnail's own frame, so it needs a backing of its own rather than
   a theme surface -- the same reason .game-trailer__facade carries a scrim. */
.game-gallery__duration {
  position: absolute;
  top: calc(var(--video-thumb-h) - 1.15rem);
  right: 0.25rem;
  padding: 0.05rem 0.3rem;
  border-radius: var(--radius-xs);
  background: rgba(8, 9, 12, 0.78);
  color: #fff;
  font-size: var(--text-2xs);
  line-height: 1.4;
  white-space: nowrap;
}

@media (max-width: 480px) {
  .game-gallery__thumb {
    width: 5.5rem;
  }

  .game-gallery__rail {
    --video-thumb-h: calc(5.5rem * 9 / 16);
    --video-tick-gap: calc(5.5rem + 0.5rem);
    padding-inline: calc(5.5rem / 2);
  }

  .game-gallery__rail-date {
    width: 5.5rem;
  }
}

/* ---------- Screenshot gallery + lightbox ---------- */

/* The game page's panel card. These two sections share their chrome with
   every other panel in the app now -- see PANEL in the Primitives section --
   so what is left here is only what is theirs: the column layout, the gap and
   the padding. That split is the rule, not a detail: chrome is what a panel
   IS, layout is where it SITS. */
.game-gallery,
.game-shots {
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
  padding: 1.25rem 1.4rem;
}

/* A phase card carries the same padding and column as the panels above, and
   deliberately NO gap: B-series made the OUTER element a plain <section> for
   every phase, current or collapsed, but its own inner reveal
   (.phase-section__reveal) is still a <details> whose body the engine wraps
   in one ::details-content box -- so a gap set HERE would land between that
   box and whatever precedes it, not between the blocks inside it. The
   blocks space themselves instead (.phase-block's own margin-top and the
   .phase-block + .phase-block divider), identically regardless of what
   wraps them. */
.phase-section {
  display: flex;
  flex-direction: column;
  padding: 1.25rem 1.4rem;
}

/* .phase-section--current carries no rule of its own any more: B-series
   made the header identical either way, and the only thing that still
   differs -- whether the rest of the body sits inside a <details> -- is
   handled by .phase-section__reveal's own presence, not by this modifier.
   Kept on the element (_phase_section.html.erb) as a test/semantic hook. */

/* Interior blocks of a phase card, not panels of their own -- they keep their
   column rhythm but take no padding from the panel layout list above, since
   the card around them already supplies it. One class for all five, so a
   label sits the same distance above its content in every block. */
.phase-block {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  /* The air above a block, and above the rule that heads it -- owned by the
     block rather than by the card's layout, which cannot reach it. */
  margin-top: 0.9rem;
  /* Out to the card's own edges and back in again, so a block's divider is a
     boundary across the whole card rather than a hairline floating inside
     its text column. */
  margin-inline: -1.4rem;
  padding-inline: 1.4rem;
}

.game-shots__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 0.6rem;
}

.game-shots__thumb {
  display: block;
  aspect-ratio: 16 / 9;
  border-radius: calc(var(--radius) - 4px);
  overflow: hidden;
  background: var(--surface-overlay);
}

.game-shots__thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* rendered WITHOUT the `open` attribute; the UA stylesheet's
   `dialog:not([open]) { display: none }` keeps this fully inert (invisible,
   out of the tab order, no layout shift) until the controller's
   showModal. */
/* Undoing the UA's <dialog> box so it can fill the viewport: a dialog is
   centred, auto-sized and bordered by default, and the lightbox wants none of
   that. This was a shared rule until the admin modal went with the articles
   workbench; the backdrop treatment stays below, where it always lived. */
.game-lightbox {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  max-width: 100%;
  max-height: 100%;
  margin: 0;
  border: 0;
  padding: 0;
  background: transparent;
}

.game-lightbox::backdrop {
  background: rgba(0, 0, 0, 0.75);
}

.game-lightbox__panel {
  position: relative;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.game-lightbox__panel img {
  max-width: min(92vw, 1400px);
  max-height: 88vh;
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}

.game-lightbox__nav,
.game-lightbox__close {
  position: absolute;
  border: 0;
  background: rgba(0, 0, 0, 0.5);
  color: #fff;
  cursor: pointer;
  border-radius: var(--radius-pill);
  width: 2.75rem;
  height: 2.75rem;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* The controls draw icon_helper SVGs, so no font-size or line-height here:
   those two only ever governed the text entities this used to hold, and they
   are exactly why the glyphs sat 2px low -- flex centres the line box, and the
   ink inside it is not centred on that box. An SVG's box is its artwork, so
   the same flex rule now lands it dead centre. 1.5rem is the old 24px
   font-size read back as a real size, the biggest icon in the app after the
   trailer facade's 3rem play button. */
.game-lightbox__nav .icon,
.game-lightbox__close .icon {
  width: 1.5rem;
  height: 1.5rem;
}

.game-lightbox__nav:hover,
.game-lightbox__close:hover {
  background: rgba(0, 0, 0, 0.75);
}

.game-lightbox__nav--prev {
  left: 1rem;
  top: 50%;
  transform: translateY(-50%);
}

.game-lightbox__nav--next {
  right: 1rem;
  top: 50%;
  transform: translateY(-50%);
}

.game-lightbox__close {
  top: 1rem;
  right: 1rem;
}

/* docs/phases/advanced-access.md D4/D2: the absorbed head-start line,
   a caption sibling of the <h2>, after it, inside .phase-section__lede.
   docs/hypemeter/phase-header-meter.md S5: renamed from
   .game-hype-card__head-start, which would otherwise name a parent it no
   longer has -- the meter moved into the header and took this caption with
   it. Margin reset for the same reason .hypemeter__stamp resets its own:
   .phase-section__lede is a flex column with its own gap, and an unreset
   <p> would ADD its UA margin on top of that gap rather than share it.
   Sized and coloured like .hypemeter__stamp (the meter's own date
   annotation) so the two read as one family. */
.phase-section__head-start {
  margin: 0;
  color: var(--text-muted);
  font-size: var(--text-sm);
}

/* docs/hypemeter/hype-forces.md C4: the direction chip, now a <span>
   inside .phase-section__meta, between the date and the platform chips
   (docs/hypemeter/phase-header-meter.md S4 -- a <p> there would be
   invalid inside the heading's phrasing content). Margin reset is
   belt-and-braces here (a <span> carries no UA margin to begin with), kept
   for parity with the header's other date annotations. Firming uses the
   positive token; slipping used to share .badge--delay's warning token --
   docs/hypemeter/force-display.md V4 moved it to --pull instead: a slip
   is an observation the meter reacts to, not an alarm, and cooling is the
   metaphor every pull force now shares (the wedges, the pull chips).
   .badge--delay itself is untouched -- the timeline is out of this
   contract's scope, a named residual. */
.date-trend {
  margin: 0;
  font-size: var(--text-sm);
  font-weight: 600;
}

.date-trend--firming {
  color: var(--good);
}

.date-trend--slipping {
  color: var(--pull);
}

/* ---------- Reception (reviews) ----------
   Panel-card chrome comes from the shared rule above. */

/* Two columns because there are exactly two audiences, critics and players,
   and comparing them is what this section is for. A flex row with
   `flex: 1 1 220px` was stretching a lone card the full panel width, which
   read as an unfinished element rather than as "we only have one of these";
   a fixed track keeps a single card the size of the pair it belongs to.
   align-items: start so a critic card carrying a contributor list does not
   drag an empty player card down to match its height. */
.reception__scores {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  align-items: start;
  gap: 1rem;
}

.reception__score {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  min-width: 0;
  padding: 0.85rem 1rem;
  border: 1px solid var(--border);
}

/* Whose verdict this is. Same treatment as .hypemeter__title so the three
   cards stacked down this page share one vocabulary for "what am I looking
   at". The icon is decorative; this text is its accessible name. */
.reception__whose {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0;
  color: var(--text-muted);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
}

.reception__whose .icon {
  width: 14px;
  height: 14px;
}

/* The number and what it means, on one row. Baseline rather than centre: the
   badge is ~3x the label's size, and centring floated it against the label
   it is supposed to be reading. */
.reception__headline {
  display: flex;
  align-items: baseline;
  gap: 0.85rem;
  min-width: 0;
}

.reception__badge {
  flex: 0 0 auto;
  font-size: var(--text-5xl);
  font-weight: 800;
  line-height: 1;
}

.reception__meta {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  min-width: 0;
}


.reception__label {
  font-weight: 600;
}

.reception__credit {
  margin: 0;
  font-size: var(--text-sm);
  color: var(--text-muted);
}


.reception__verdict {
  display: inline-block;
  align-self: flex-start;
  padding: 0.4rem 0.85rem;
  font-weight: 700;
  border-radius: var(--radius);
}

/* critic tier colours are a quality ramp,
   distinct from the hype intensity ramp above: --text-muted / --warn / --good /
   --good-strong. Red LEAVES this scale on purpose -- critic--mighty (the TOP
   tier, "best reviews") must not share --danger with steam--negative (the
   WORST sentiment band) below it in the very same reception row; that overlap
   was Purpose §2's headline defect. */
.reception__score.critic--weak .reception__badge {
  color: var(--text-muted);
}

.reception__score.critic--fair .reception__badge {
  color: var(--warn);
}

.reception__score.critic--strong .reception__badge {
  color: var(--good);
}

.reception__score.critic--mighty .reception__badge {
  color: var(--good-strong);
  /* The two "good" tiers compress in the light theme; the badge's label text
     ("Mighty" vs "Strong") carries the distinction, so no border is needed here. */
}

.reception__score.critic--unscored .reception__badge {
  color: var(--text-muted);
}

/* Steam sentiment bands share the SAME quality ramp as critic tiers
   above (--danger / --warn / --good / --good-strong): both answer "how well
   received", and the hype-vs-reception thesis depends on the two being
   comparable at a glance. */
.reception__score.steam--negative .reception__badge {
  color: var(--danger);
}

.reception__score.steam--mixed .reception__badge {
  color: var(--warn);
}

.reception__score.steam--positive .reception__badge {
  color: var(--good);
}

.reception__score.steam--very_positive .reception__badge {
  color: var(--good-strong);
}

.reception__score.steam--unscored .reception__badge {
  color: var(--text-muted);
}

/* hype-vs-reception wedge verdict. Direction per reception_helper.rb:66
   -- "over" is *"Exceeded the hype"*, i.e. good news; "under" is the
   disappointment. */
.reception__verdict.verdict--over {
  background: color-mix(in srgb, var(--good) var(--tint-soft), transparent);
  color: var(--good-strong);
}

.reception__verdict.verdict--under {
  background: color-mix(in srgb, var(--danger) var(--tint-soft), transparent);
  color: var(--danger);
}





/* ---------- Phase rail
   A phase's own chronology -- date-change history and first-party events
   merged onto one vertical rail. Flat, top-level rules only: nesting a dot
   or connector inside .phase-rail/.phase-rail__item would hide their colour
   from the palette parser. ---------- */

.phase-rail {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 1.1rem;
  padding-left: 1.6rem;
  list-style: none;
  margin: 0;
}

.phase-rail::before {
  content: "";
  position: absolute;
  left: 0.3rem;
  top: 0.4rem;
  bottom: 0.4rem;
  width: 2px;
  background: var(--border);
}

.phase-rail__item {
  position: relative;
}

.phase-rail__item::before {
  content: "";
  position: absolute;
  left: -1.6rem;
  top: 0.35rem;
  width: 0.6rem;
  height: 0.6rem;
  border-radius: 50%;
  background: var(--accent);
  /* The ring matches the surface actually behind a rail item: inside a
     phase card that is --surface-raised, not --surface, which is what
     the page-level .timeline's own dot sits on. */
  box-shadow: 0 0 0 3px var(--surface-raised);
}

/* An event row renders as .entry inside a rail item -- a card does not
   nest in a card, so the rail strips the panel chrome .entry otherwise
   carries (the PANEL/PANEL-raised lists in Primitives). */
.phase-rail .entry {
  background: none;
  border: none;
  box-shadow: none;
  padding: 0;
}

.release-history__change {
  margin: 0.35rem 0;
  font-size: var(--text-lg);
  font-weight: 600;
}

.release-history__arrow {
  margin: 0 0.35rem;
  color: var(--text-muted);
}

.release-history__changed-at {
  display: block;
  font-size: var(--text-sm);
  color: var(--text-muted);
}

/* ---------- Per-platform release dates
   A peer of .phase-section's other interior blocks: same row rhythm --
   one platform name and its own date. ---------- */

.platform-dates__list {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

/* Centred, not baseline-aligned: the platform half is itself a flex box now
   (glyph + name), and a flex container's baseline is its FIRST item's -- the
   glyph's, whose box bottom sits ~5px below the text baseline it should have
   agreed with, so the date drifted low on every row. Centring the two halves
   against each other is the honest fix and needs no per-glyph nudge. */
.platform-dates__entry {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  padding-top: 0.6rem;
  border-top: 1px solid var(--border);
}

.platform-dates__entry:first-child {
  padding-top: 0;
  border-top: none;
}

/* Glyph then name. The row is baseline-aligned (the date beside it is smaller
   text), so this half centres its own two items and lets the flex box's
   baseline be the name's. */
.platform-dates__platform {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  font-weight: 600;
}

/* Bigger than the 1.15em a chip's glyph takes: there is no chip fill behind it
   here, so the mark carries itself against the card. Centred on the FIRST line
   box rather than on the name as a whole (`align-items: flex-start` above plus
   this margin): at 390px "PC (Microsoft Windows)" wraps to two lines, and a
   glyph centred on the pair floats in the gutter between them. `1lh` is the
   line box the margin is halving, so the two numbers cannot drift apart the
   way a hard-coded pixel nudge would. */
.platform-dates__platform .platform-icon {
  width: 1.35em;
  height: 1.35em;
  margin-top: calc((1lh - 1.35em) / 2);
}

.platform-dates__date {
  color: var(--text-muted);
  font-size: var(--text-sm);
}

/* ---------- Featured in (per-game showcase timeline)
   A peer of .game-latest-news: the heading and the .timeline rail sit
   outside any card, and each showcase is its own dated entry -- same
   .timeline dot/line rail and .entry panel chrome the updates section
   uses, newest first. ---------- */

.featured-in {
  margin-top: 2rem;
}

/* docs/site/discovery.md DS7: up to three blocks (series/studio/similar),
   each a .card-grid under its own h2 -- same spacing rhythm as
   .featured-in above. A block renders no markup at all when it has no rows
   (DS7, "render nothing when there is nothing"), so there is no empty-shell
   case to style. Each block is also a rail below 900px
   (docs/site/card-rails.md R1) -- a bounded handful, capped at
   RelatedGames::CAP. */
.game-related {
  margin-top: 2rem;
}

.featured-in__link {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  color: inherit;
}

.featured-in__logo {
  flex: 0 0 auto;
  width: 2rem;
  height: 2rem;
  border-radius: var(--radius-sm);
  object-fit: cover;
}

.featured-in__name {
  flex: 1 1 auto;
  font-weight: 600;
  min-width: 0;
}

.featured-in__date {
  flex: 0 0 auto;
  color: var(--text-muted);
  font-size: var(--text-sm);
}


/* ---------- Latest news preview ---------- */

/* The overview's honest preview of the newest LATEST_NEWS entries -- same
   .timeline connector-line treatment as the full /news page below, just
   capped and with an "All news" link that only appears when the
   preview is hiding something. */
.game-latest-news {
  margin-top: 2rem;
}

/* ---------- Timeline (game page) ---------- */

.game-timeline {
  margin-top: 2rem;
}

.timeline {
  position: relative;
  padding-left: 1.6rem;
  display: flex;
  flex-direction: column;
  gap: 1.4rem;

  &::before {
    content: "";
    position: absolute;
    left: 0.3rem;
    top: 0.4rem;
    bottom: 0.4rem;
    width: 2px;
    background: var(--border);
  }

  .entry {
    position: relative;

    &::before {
      content: "";
      position: absolute;
      left: -1.6rem;
      top: 1.15rem;
      width: 0.6rem;
      height: 0.6rem;
      border-radius: 50%;
      background: var(--accent);
      box-shadow: 0 0 0 3px var(--surface);
    }
  }
}

/* ---------- Hypemeter ---------- */

/* docs/hypemeter/phase-header-meter.md S7: renders in exactly one
   template now (the header, _phase_heading.html.erb), so it gives up its
   own max-width -- the head is a full-width flex column, and width: 100% is
   what fills it -- and the margin-top M1 left applying here, which went with
   the two-column grid that briefly held this: align-items: center had
   centred the margin BOX, dropping the meter ~8px below the identity's
   optical centre. */
.hypemeter {
  width: 100%;
}

.hypemeter__label {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  margin-bottom: 0.35rem;
  font-size: var(--text-md);
}

.hypemeter__title {
  color: var(--text-muted);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
}

.hypemeter__score {
  font-weight: 700;
}

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

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

/* The frozen-at-release stamp (docs/hypemeter/hype-lock.md U1). Muted and one step down
   from body size, so it reads as a caption on the meter rather than as prose;
   without a rule it inherits the browser's default <p> margins and sets at
   full body size, which shouts louder than the score it annotates. */
.hypemeter__stamp {
  margin: 0 0 0.4rem;
  color: var(--text-muted);
  font-size: var(--text-sm);
}

.hypemeter__bar {
  width: 100%;
  height: 0.55rem;
  background: var(--surface-overlay);
  border-radius: var(--radius-pill);
  overflow: hidden;
  /* docs/hypemeter/force-display.md V2: lets the cool wedge sit flush
     against the warm fill's own tip, the same one-item-still-works flex row
     the breakdown's own .hype-breakdown__bar already uses for its editions
     split -- a lone fill (no wedge) renders byte-identical to before. */
  display: flex;
}

.hypemeter__fill {
  height: 100%;
  border-radius: var(--radius-pill);
  background: var(--hype-low);
  transition: width 0.3s ease;
}

/* V2: the wedge follows the fill in the DOM, so :has() alone tells whether
   the fill needs to give up its right-hand cap -- no second class on the
   fill itself, no markup change on the no-pull path this rule doesn't
   match. */
.hypemeter__fill:has(+ .hypemeter__pull) {
  border-radius: var(--radius-pill) 0 0 var(--radius-pill);
}

.hypemeter__pull {
  height: 100%;
  border-radius: 0 var(--radius-pill) var(--radius-pill) 0;
  background: var(--pull);
  /* the fill-vs-wedge boundary -- no single --pull value can hold 3:1
     against every hype tier (see the token's own comment), so the track
     colour draws the seam */
  box-shadow: inset 1px 0 0 var(--surface-overlay);
}

/* The wedge's own caption, visible on the current card in place of the
   sr-only sentence every other card still carries. */
.hypemeter__pull-note {
  margin: 0.35rem 0 0;
  color: var(--pull-ink);
  font-size: var(--text-sm);
}

/* the intensity ramp, hype only. Each tier
   fills with its own --hype-X vivid swatch, never a chrome token borrowed
   from elsewhere (that borrowing -- --warn for the middle band, --accent for
   the one above it, --danger for the top -- was the muddy brown light-mode bar
   in Purpose §3 and the --accent/--danger overload in Purpose §1-2). */
.hypemeter.hype--low .hypemeter__fill {
  background: var(--hype-low);
}

.hypemeter.hype--moderate .hypemeter__fill {
  background: var(--hype-moderate);
}

.hypemeter.hype--high .hypemeter__fill {
  background: var(--hype-high);
}

.hypemeter.hype--extreme .hypemeter__fill {
  background: var(--hype-extreme);
}

.hypemeter.hype--unscored .hypemeter__fill {
  width: 0%;
  background: var(--border);
}

/* Two readings under the bar, inside .hypemeter because
   docs/hypemeter/phase-header-meter.md S1/S2 fix the head's composition
   ([lede, hypemeter], fatal) and the sparkline's position (the meter's last
   child, fatal) -- nesting here is the only shape that leaves both intact.
   Not a part of the bar: no stacked segment, no second fill, no "= 100" --
   five of the eleven seats sit on neither axis (docs/hypemeter/demand-campaign.md
   D1/S5), so demand + campaign never claims to sum to the
   meter's own number. (Comment kept free of a literal star-slash sequence:
   one inside a CSS comment closes it early and silently drops the next
   rule.) */
.phase-axes {
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
  margin-top: 0.6rem;
  font-size: var(--text-sm);
  color: var(--text-muted);
}

/* The current card's own eyebrow row, ahead of the readings it introduces --
   absent on every collapsed card. */
.phase-axes__head {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
}

.phase-axes__eyebrow {
  color: var(--text-muted);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
}

/* Holds the two readings plus the quadrant badge -- the row .phase-axes
   itself used to be, kept as its own row now that the column also carries
   the eyebrow and the genre-standing line. This base rule is the compact
   form every collapsed card keeps, unchanged. */
.phase-axes__attention {
  display: flex;
  flex-wrap: wrap;
  gap: 0 1rem;
}

/* The current card's own
   detailed form -- each reading gets its own row (label, score, gauge,
   coverage, gloss) rather than sitting in the compact wrapped flex row. */
.phase-axes__attention--detailed {
  flex-direction: column;
  gap: 1rem;
}

.phase-axes__attention--detailed .phase-axes__reading {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

/* One line over the track: what the reading is on the left, what it measured
   on the right. The two groups wrap as units, so a narrow card drops the
   figures below the name rather than splitting either pair. */
.phase-axes__row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0 0.75rem;
}

.phase-axes__ident {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0 0.5rem;
  min-width: 0;
}

.phase-axes__figures {
  display: flex;
  align-items: baseline;
  gap: 0 0.6rem;
  white-space: nowrap;
}

/* A hairline between the reading and its coverage: two numbers with only a
   space between them read as one. A rule rather than a character, so there
   is nothing for a screen reader to announce or a translator to place. */
.phase-axes__figures .phase-axes__seats {
  border-left: 1px solid var(--border);
  padding-left: 0.6rem;
}

.phase-axes__label {
  font-weight: 700;
}

.phase-axes__score {
  color: var(--text);
  font-weight: 700;
}

.phase-axes__reading {
  margin: 0;
}

.phase-axes__seats {
  color: var(--text-muted);
  white-space: nowrap;
}

.phase-axes__gloss {
  font-size: var(--text-xs);
}

/* One track per reading, sized like the meter's own bar so the three read as
   one family of measurements rather than a gauge and its footnotes. They are
   not its parts: each carries its own name, its own number and the accent
   fill, never a hype tier, and no arrangement sums them. A <span>, because
   phrasing content stays nested inside the reading's <p> instead of being
   hoisted out by the parser; display: block sizes it as the track it is. */
.phase-axes__bar {
  display: block;
  width: 100%;
  height: 0.55rem;
  background: var(--surface-overlay);
  border-radius: var(--radius-pill);
  overflow: hidden;
}

.phase-axes__fill {
  display: block;
  height: 100%;
  border-radius: var(--radius-pill);
  background: var(--accent);
}

/* docs/site/attention-surface.md Q5: the quadrant reads as a summary
   OF the two readings, so it carries a box they do not -- otherwise it is a
   third muted phrase in the same flex row. */
.phase-quadrant {
  padding: 0.05rem 0.4rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-xs);
  color: var(--text);
  font-weight: 600;
}

.phase-axes__verdict {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0 0.5rem;
  margin: 0;
}

.phase-quadrant__gloss {
  display: inline;
  font-size: var(--text-xs);
  color: var(--text-muted);
}

/* Small anchor from the compact meter down to the live
   breakdown's <details> (plain #fragment jump, no JS). */
/* ---------- Hype sparkline ---------- */

/* The hype history line (shared/_hype_sparkline), rendering now as the LAST
   element child of .hypemeter itself (docs/hypemeter/phase-header-meter.md
   S2, superseding the old placement between the meter and the breakdown on
   .game-hype-card) and inside a fixed column on an anticipated/risers chart
   row. One partial serves both spots from identical coordinates because the
   SVG stretches: preserveAspectRatio="none" maps the 100x32 viewBox onto
   whatever box CSS gives it, and the polyline's vector-effect keeps that
   non-uniform scale from smearing the stroke thicker vertically than
   horizontally.

   Nothing here animates, deliberately rather than by omission: a draw-in would
   owe a prefers-reduced-motion entry, and reduced_motion_test.rb would
   correctly fail until it got one.

   No width rule of its own: in the header it stretches to whatever
   .hypemeter's own width: 100% resolves to, which is the stacked head's own
   full width (S7) -- so the bar and the line are sized together, by one
   thing, on every phase card -- and in a chart row the column sets it. A
   320px cap here would leave the line ending a quarter of the way across a
   card whose bar runs the full width -- two graphics about one number,
   disagreeing about where the number ends. */

/* S7: the line's own spacing below the bar, now that it renders inside the
   meter rather than as a sibling block with its own flex-gap to lean on.
   Taller than the bare-line original (0.45rem) now that G7's grid -- and
   specifically the top tick label, which straddles this margin -- needs
   real clearance from the bar above it. */
.hypemeter .hype-spark {
  margin-top: 1rem;
}

/* R14: the family glyphs hang above the plot, into this margin. 1rem is
   0.75rem of icon plus a hairline -- enough that it renders, not enough that
   it reads as belonging to the line rather than to the meter's own row above.
   Scoped to lines that actually carry marks, so the 94% of drawn lines with
   none pay no extra whitespace. */
.hypemeter .hype-spark--marked {
  margin-top: 1.9rem;
}

/* docs/hypemeter/sparkline-event-marks.md T1: the game-page line grows to
   ~5rem -- the viewBox itself is untouched (WIDTH/HEIGHT are arbitrary user
   units), so this is the only rule that changes. Taller is what makes the
   event marks below a real touch target and what makes G7's three axis
   numbers legible; the base rule and the /charts override stay at their own
   heights. */
.hypemeter .hype-spark__svg {
  height: 5rem;
}

/* docs/hypemeter/sparkline-event-marks.md R2/R3: the <svg>'s own sibling
   wrapper, so an event mark (HTML, never an SVG shape -- an SVG <circle>
   would render as an ellipse stretched by preserveAspectRatio="none")
   overlays through plain `left`/`top` percentages resolved against exactly
   the box the <svg> fills. Takes over the flex/min-width that used to sit on
   .hype-spark__svg directly -- .hype-spark__chart's flex row (G7's label
   gutter) is the only place either matters, and is a no-op elsewhere. */
.hype-spark__plot {
  position: relative;
  flex: 1 1 0%;
  min-width: 0;
}

/* overflow visible, because the extreme points sit ON the viewBox edge: the
   line's own top and bottom are y=0 and y=32 by construction, so half the
   stroke would be clipped at both. The bleed is 0.75px at the base 2rem
   height below; charts override the height and inherit a proportionally
   different bleed, still sub-pixel. */
.hype-spark__svg {
  display: block;
  width: 100%;
  height: 2.25rem;
  overflow: visible;
}

/* The stroke is a per-point gradient now, not a flat tier swatch (C2
   amendment, 2026-08-27): each point earns its OWN score's colour off the
   same four --hype-* tokens the meter fill uses, low cool through high warm,
   painted through a <linearGradient> the partial builds per instance and
   points at via the `stroke` PRESENTATION ATTRIBUTE
   (shared/_hype_sparkline.html.erb, HypeHelper#hype_gradient_stop_color). No
   `stroke` colour lives here any more, deliberately: a CSS rule matching
   this selector -- flat, tier-keyed, or otherwise -- always wins the cascade
   over that attribute regardless of specificity, which would silently paint
   the whole line one flat colour again. */
.hype-spark__line {
  fill: none;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* docs/hypemeter/hype-sparkline.md C6: the wash under the line. No colour
   here for the same reason .hype-spark__line carries none -- the fill is a
   <linearGradient> the partial builds per instance and points at through the
   `fill` presentation attribute, and any CSS `fill` on this selector would
   win the cascade over it regardless of specificity and paint the wash flat.
   The stroke is killed explicitly: a <polygon> takes the UA default stroke,
   which would draw a hard line down both sides of the box and along the
   bottom, closing the shape the eye is supposed to read as open. */
.hype-spark__area {
  stroke: none;
}

/* C7: the dot on the last point -- an HTML element over the <svg>, not an
   SVG <circle>, because the box is preserveAspectRatio="none" and a circle
   comes out an ellipse (the marks below are HTML for the same reason, R2).
   Positioned through the same linear map the viewBox uses, so it lands on
   the line's own last point and stays round at any box shape.

   The only inline style it carries is --tip-x/--tip-y/--tip-ink, consumed
   here and nowhere else, which is the shape R10b holds the marks to. The ink
   is the CURRENT tier's colour, taken from the same helper call the line's
   last gradient stop makes rather than a per-tier rule here: a third mapping
   from tier to token is exactly the drift palette_semantics_test's gradient
   case exists to catch, and it would not see a rule written here.

   The ring is the panel's own ground, not a colour: it separates the dot
   from the wash directly beneath it, which is the same hue. */
.hype-spark__tip {
  position: absolute;
  left: var(--tip-x);
  top: var(--tip-y);
  width: 0.3rem;
  height: 0.3rem;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: var(--tip-ink);
  box-shadow: 0 0 0 1.5px var(--surface-raised);
  pointer-events: none;
}

/* G7: a fixed top/mid/bottom reference grid. The numbers get their OWN
   column -- .hype-spark__chart is a flex row, .hype-spark__grid a fixed-width
   gutter before the <svg> -- rather than overlapping the line: the line's
   first drawn point sits at x=0, exactly where a left:0 label would too.
   The gridLINES stay inside the <svg>, ahead of the <polyline> in document
   order, which is what keeps THEM behind the drawn line (SVG paints in
   document order). Chrome tokens only, never --hype-*: a gridline carries no
   score of its own, only the per-game axis the line is drawn against (S3).
   Label position is three static classes, never an inline style -- the ticks
   always land at exactly 0/50/100% by construction
   (Hype::Series#grid_lines), so there is nothing to compute per instance. */
/* --hype-spark-gutter: the ONE spelling of the label gutter's width, read by
   both .hype-spark__grid's own flex-basis below and the readout's negative
   inset (docs/hypemeter/sparkline-event-marks.md R8) -- two spellings of that
   number is how the grid and the readout would start disagreeing about
   their shared left edge. */
.hype-spark__chart {
  display: flex;
  align-items: stretch;
  gap: var(--hype-spark-gap);
  --hype-spark-gutter: 1.6rem;
  --hype-spark-gap: 0.4rem;
}

.hype-spark__grid {
  position: relative;
  flex: 0 0 var(--hype-spark-gutter);
  pointer-events: none;
}

/* left: 0, not right-aligned: the gutter's own left edge is the SAME left
   edge every other element in the meter (the bar, the caption, the label)
   already sits flush against, and a right-aligned number floats inset from
   it by however wide that number happens to be. */
.hype-spark__grid-label {
  position: absolute;
  left: 0;
  transform: translateY(-50%);
  color: var(--text-muted);
  font-size: var(--text-xs);
  line-height: 1;
}

.hype-spark__grid-label--top {
  top: 0;
}

.hype-spark__grid-label--mid {
  top: 50%;
}

.hype-spark__grid-label--bottom {
  top: 100%;
}

.hype-spark__gridline {
  stroke: var(--border);
  stroke-width: 1;
  opacity: 0.5;
}

/* docs/hypemeter/sparkline-event-marks.md M9/R1: absent, not empty -- a
   phase with no marks renders no .hype-spark__marks element at all (the
   common case, 88% of drawn lines), so this box costs nothing when there is
   nothing to show. Covers exactly .hype-spark__plot's own box (R2): every
   mark's left/top percentage resolves against THIS box, which is sized
   identically to the <svg> it sits over. pointer-events: none so an empty
   gap between two marks does not swallow clicks meant for the page beneath
   it; each .hype-spark__mark re-enables its own. */
.hype-spark__marks {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* R10b (fatal): the only inline style this element ever carries is
   --mark-x/--mark-y, two percentages -- consumed here and nowhere else. R5:
   the hit target (this box, ~28px) is deliberately much larger than the
   visual dot (::before, ~8px) -- the measured maximum of three marks per
   line means the enlarged targets cannot collide in practice. No background
   and no border of its own: R10's colour lives entirely on the badge class
   this button also carries (event_badge_class), read by the ::before rule
   below through `currentColor`. R9: no transition, no animation -- an
   animated reveal would owe a prefers-reduced-motion entry. */
.hype-spark__mark {
  position: absolute;
  left: var(--mark-x);
  top: 0;
  bottom: 0;
  transform: translateX(-50%);
  width: 1.75rem;
  padding: 0;
  margin: 0;
  border: 0;
  border-radius: 0;
  background: none;
  font: inherit;
  text-transform: none;
  letter-spacing: normal;
  white-space: normal;
  cursor: pointer;
  pointer-events: auto;
}

/* The visual dot -- small on purpose (R5), drawn with the button's own
   `color`, which is exactly what event_badge_class's `.badge--<type>` rule
   set for this element (or `.badge--neutral` on a mixed-family day, R10a).
   No second colour table: this is the SAME token every badge chip renders
   its text in, reused as a fill via currentColor rather than restated. */
.hype-spark__mark::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 0;
  bottom: 0;
  width: 2px;
  background: currentColor;
  opacity: 0.5;
  transform: translateX(-50%);
}

/* The tick brightens under the pointer or under focus, so the one you are
   reading separates from any other on the line. Opacity only -- no transition,
   because an animated one would owe a prefers-reduced-motion entry (R9). */
.hype-spark__mark:hover::before,
.hype-spark__mark:focus-visible::before {
  opacity: 1;
}

/* R14: the family glyph, capping its own tick at the top of the plot. Sits
   ABOVE the box rather than inside it -- translateY(-100%) lifts it into the
   1rem the meter already leaves above the line, so it collides with neither
   the series nor the top gridline, and the chart's own geometry is untouched.
   0.75rem, a third of the nav's 18px: at two marks per line at most there is
   room, and anything larger starts competing with the line it annotates.
   Tracks the tick's own resting strength, and goes solid with it. */
.hype-spark__mark-icon {
  position: absolute;
  left: 50%;
  top: 0;
  display: block;
  opacity: 0.7;
  transform: translate(-50%, -100%);
  pointer-events: none;
}

.hype-spark__mark-icon .icon {
  display: block;
  width: 0.875rem;
  height: 0.875rem;
}

.hype-spark__mark:hover .hype-spark__mark-icon,
.hype-spark__mark:focus-visible .hype-spark__mark-icon {
  opacity: 1;
}

/* The point itself, revealed only while its tick is being read. The button
   spans the plot's full height, so --mark-y lands this exactly where the tick
   crosses the line -- the same coordinate the polyline drew, via the same
   custom property the tooltip anchors to. Half-strength FILL at rest, going
   solid under the pointer -- the fade is on the background alone, never
   `opacity`, because opacity would take the surface ring down with it and that
   ring is the whole reason the dot separates from the line it sits on.
   No transition, for R9's reason. */
.hype-spark__mark::after {
  content: "";
  position: absolute;
  left: 50%;
  top: var(--mark-y);
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: color-mix(in srgb, currentColor 50%, transparent);
  box-shadow: 0 0 0 2px var(--surface-raised);
  transform: translate(-50%, -50%);
}

.hype-spark__mark:hover::after,
.hype-spark__mark:focus-visible::after {
  background: currentColor;
}

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

/* R7/R8: visibility, never display/hidden -- the box stays reserved so a
   reveal never reflows the line beneath the cursor. One slot, two states: the
   readout PAINTS OVER the caption's own line rather than adding a second one
   below it, which is why it carries an opaque surface and spans the full
   width of .hype-spark, including back across the grid gutter via the SAME
   --hype-spark-gutter custom property .hype-spark__grid's own width reads
   (R8). top: 100% lands it right where .hype-spark__caption begins, sharing
   that caption's own top margin so the two never fight for the same
   pixel. */
.hype-spark__readout {
  position: absolute;
  left: calc(-1 * (var(--hype-spark-gutter) + var(--hype-spark-gap)));
  right: 0;
  top: 100%;
  margin-top: 0.9rem;
  visibility: hidden;
  background: var(--surface-raised);
  color: var(--text-muted);
  font-size: var(--text-sm);
  line-height: 1.3;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* One line, truncated -- never wrapped. The readout is out of flow, so a
   second line cannot push anything down (R7 holds either way), but its opaque
   background WOULD paint over the disclosure below it. Measured: a two-event
   German day fits 390px with room to spare, so this guards the rare three-event
   day in a verbose locale rather than a case seen today. Truncating loses a
   sighted reader nothing that matters -- the button's own accessible name
   carries the full sentence, and CSS truncation never reaches the
   accessibility tree. */
/* R6 (fatal): pure CSS, the plain adjacent-sibling combinator -- no Stimulus
   controller, no JS of any kind, and deliberately no :has() either
   (Firefox shipped :has() in 121, one version past this app's allow_browser
   floor of 120, and the failure mode here is not cosmetic: without it both
   the caption and the readout would paint on the same line at once).
   :focus, not only :focus-visible -- a tap must reveal on touch, where there
   is no hover at all. */
.hype-spark__mark:hover + .hype-spark__readout,
.hype-spark__mark:focus + .hype-spark__readout {
  visibility: visible;
}

/* Desktop gets a real tooltip -- the SAME readout element, re-anchored to its
   own dot instead of the caption slot below the line. `hover: hover and
   pointer: fine` is the gate: a coarse pointer keeps the full-width slot,
   which is the shape that works when a fingertip is covering the dot it just
   tapped and there is no cursor to point with. The reveal mechanism does not
   change here, only where the revealed box sits.

   Still no JS, and none is needed: each dot's own coordinates are already in
   the markup as --mark-x / --mark-y, so the box hangs off the point directly.
   The alignment and flip classes come from HypeHelper for the same reason the
   grid labels' three do -- CSS cannot branch on a custom property's value, and
   a tooltip centred on a dot at 0 percent, or hung above a dot at the top of
   the box, would leave the card entirely. Both values are known at render
   time, so five static classes replace arithmetic that CSS could not do. */
@media (hover: hover) and (pointer: fine) {
  .hype-spark__readout {
    left: var(--mark-x);
    right: auto;
    top: var(--mark-y);
    z-index: 2;
    width: max-content;
    max-width: min(18rem, 100%);
    margin-top: 0;
    padding: 0.25rem 0.5rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    box-shadow: var(--shadow);
    color: var(--text);
  }

  .hype-spark__readout--above {
    --readout-offset: calc(-100% - 0.6rem);
  }

  .hype-spark__readout--below {
    --readout-offset: 0.6rem;
  }

  .hype-spark__readout--center {
    translate: -50% var(--readout-offset);
  }

  .hype-spark__readout--start {
    translate: -0.6rem var(--readout-offset);
  }

  .hype-spark__readout--end {
    translate: calc(-100% + 0.6rem) var(--readout-offset);
  }
}

/* Two real dates and the peak, read off the rendered points. Same treatment as
   .hypemeter__stamp above, so the card's two annotations set at one size.
   More clearance than the bare-line original (0.3rem) now that G7's bottom
   tick label straddles this margin and needs real room above this line. */
.hype-spark__caption {
  margin: 0.9rem 0 0;
  color: var(--text-muted);
  font-size: var(--text-sm);
}

/* ---------- Hype breakdown (details.hype-breakdown) ---------- */

.hype-breakdown > summary {
  cursor: pointer;
  color: var(--text-muted);
  font-size: var(--text-md);
  font-weight: 600;
  /* Native disclosure marker kept: this is an inline expand, so the browser's
     rotating triangle describes it correctly. */
}

/* Revealed in place beside the meter it explains -- opening it pushes the
   page down. Plain native <details> -- no JS, closed by default, and
   independent of a collapsed phase's own "Show details" reveal (B-series):
   two disclosures, never one nested inside the other. */
.hype-breakdown__rows {
  margin-top: 0.6rem;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

/* Demand / Campaign / the unassigned seats -- rendered only above a group
   that actually has rows, so an empty group draws no heading at all. Sits
   flush with the row gap above it rather than adding a second margin. */
.hype-breakdown__group-heading {
  margin: 0;
  color: var(--text-muted);
  font-size: var(--text-sm);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.02em;
}

.hype-breakdown__row-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.5rem;
  font-size: var(--text-md);
}

.hype-breakdown__label {
  font-weight: 600;
}

.hype-breakdown__pts {
  color: var(--text-muted);
  white-space: nowrap;
}

.hype-breakdown__bar {
  width: 100%;
  height: 0.35rem;
  margin-top: 0.25rem;
  background: var(--surface-overlay);
  border-radius: var(--radius-pill);
  overflow: hidden;
  /* Lets the two-segment editions split (base then editions,
     docs/ingestion/editions.md E10) sit side by side without absolute
     positioning; a single plain fill still renders correctly as a one-item
     flex row. */
  display: flex;
}

.hype-breakdown__fill {
  height: 100%;
  border-radius: var(--radius-pill);
  /* Permitted: per-component magnitude bars (the allow-list grants the same
     treatment to the admin metrics bar). */
  background: var(--accent);
}

/* The editions split (E10): the base segment keeps the plain accent fill
   above, so a game with no editions is byte-identical to before this
   contract. The editions segment is a solid brand-ramp token (not a tint
   with docs/design/dedupe.md C4's transparent mix), rendered at full saturation for
   legibility against the meter track. It reads as "one bar, two sources"
   because it shares the accent's hue. */
.hype-breakdown__fill--base {
  border-radius: var(--radius-pill) 0 0 var(--radius-pill);
}

.hype-breakdown__fill--editions {
  background: var(--accent-strong);
  border-radius: 0 var(--radius-pill) var(--radius-pill) 0;
}

/* docs/hypemeter/force-display.md V2: the events row's own cool wedge --
   same :has() cap-swap idiom as .hypemeter__pull above. .hype-breakdown__bar
   is already a flex row (E10's editions split), so the wedge needs no
   layout change here, only the rule itself. */
.hype-breakdown__fill:has(+ .hype-breakdown__pull) {
  border-radius: var(--radius-pill) 0 0 var(--radius-pill);
}

.hype-breakdown__pull {
  height: 100%;
  border-radius: 0 var(--radius-pill) var(--radius-pill) 0;
  background: var(--pull);
  /* same seam as .hypemeter__pull: the track colour draws the boundary */
  box-shadow: inset 1px 0 0 var(--surface-overlay);
}

.hype-breakdown__detail {
  margin: 0.25rem 0 0;
  color: var(--text-muted);
  font-size: var(--text-sm);
}

/* docs/hypemeter/hype-attribution.md A7: the "what moved this score" line,
   first inside the reveal -- the footer's muted, hairline-bordered shape,
   slightly emphasised (full text colour, not muted) since it is read first. */
.hype-breakdown__change {
  margin: 0 0 0.75rem;
  padding-bottom: 0.5rem;
  border-bottom: 1px solid var(--border);
  font-size: var(--text-sm);
  color: var(--text);
}

.hype-breakdown__footer {
  margin-top: 0.75rem;
  padding-top: 0.5rem;
  border-top: 1px solid var(--border);
  font-size: var(--text-sm);
  color: var(--text-muted);
}

.hype-breakdown__disclosure {
  margin-top: 0.25rem;
  padding-top: 0;
  border-top: none;
}

/* ---------- Static prose pages (e.g. /about/hypemeter) ---------- */

.prose-page {
  max-width: 640px;
  margin-inline: auto;
  padding-block: 1rem;
}

.prose-page p {
  margin: 0 0 1em;
  line-height: 1.55;
}

.prose-page__list {
  margin: 0 0 1.25em;
  padding-left: 1.25em;
  line-height: 1.55;
}

.prose-page__list li {
  margin-bottom: 0.6em;
}

/* ---------- Changelog (/changelog) ----------
   A release inside a prose page: the version number leads, the date trails it
   on the same baseline, and the headline sits under both as a quieter line
   before the list of changes. */

.changelog-release {
  margin-bottom: 2rem;
}

.changelog-release__version {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.6rem;
  margin-bottom: 0.2rem;
}

.changelog-release__date {
  font-size: var(--text-xl);
  font-weight: 400;
  color: var(--text-muted);
}

.changelog-release__headline {
  color: var(--text-muted);
}

/* ---------- Calendar (almanac grid) ---------- */

.calendar-heading {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 0.75rem;
}

/* The TBA-list link on the month page and the day page's own link back to
   its month (docs/calendar/calendar-volume.md B4): both a small muted
   link riding the heading row. */
/* The TBA link at the right of the secondary bar. Muted and small like its
   heading-row ancestor, and flex: 0 0 auto so the scrolling tab row beside it
   cannot squeeze it -- the tabs shrink and scroll, this does not. */
.calendar-bar__tba {
  flex: 0 0 auto;
  margin-left: auto;
  padding-left: 0.75rem;
  font-size: var(--text-md);
  color: var(--text-muted);
}

.calendar-heading__tba,
.calendar-heading__back {
  font-size: var(--text-md);
  color: var(--text-muted);
}

/* The secondary bar's two halves: the scope tabs at the left, the platform
   filter at the right. The bar itself runs edge to edge for its tint, so this
   inner box is what repeats .container's width and centering -- the job
   .calendar-scopes used to do alone, back when the tab row was all the bar
   held. The gutter trim is that rule's, kept identical so the first tab still
   lines up with the brand above it and the h1 below.

   min-width: 0 on the tab half: it is the scrolling one (one row always, see
   its own rule), and a flex item will not shrink below its content without
   this -- the filter would be pushed off the right edge on a narrow viewport
   instead of the tabs scrolling. */
.calendar-bar__inner {
  width: 100%;
  max-width: 1500px;
  margin-inline: auto;
  padding-inline: calc(1.25rem - 0.7rem);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  /* One row, always. The bar now holds the scope tabs and one small link, so
     the wrap this rule used to need is gone with the filter that needed it --
     a ~60px link is not what crowded a ~250px tab row. The tabs keep their own
     horizontal scroll (their rule) for the widths where even that is tight. */
  flex-wrap: nowrap;
}

.calendar-bar__inner .calendar-scopes {
  /* width: auto is the load-bearing one. The shared .admin-nav/.charts-nav rule
     gives this nav width: 100% -- correct when it WAS the bar's only child and
     had to span it. Left in place beside a second child it claims the whole
     inner box, and the filter is pushed onto a line of its own: the bar renders
     double-height with a platform applied, which is what "cramped" looked like.
     min-width: 0 lets it shrink below its content so the tabs scroll (their own
     rule) instead of shoving the filter off the right edge. */
  width: auto;
  min-width: 0;
  margin-inline: 0;
  padding-inline: 0;
}

/* Rides the heading row, opposite the h1, with the TBA link moved out to the
   bar so this row holds exactly two things on the month page as well as on the
   other three. margin-left: auto rather than the parent's space-between,
   because the DAY page still carries a third child (its back-to-month link) and
   space-between would strand the filter in the middle of that row.

   nowrap: left to wrap internally it splits into a stack of two or three stray
   pills. It has room to wrap as a whole -- the heading is not competing with a
   tab row any more -- but a control that reflows into a ragged stack under a
   long platform name is what "cramped" looked like, and the width at which it
   happens depends on the reader's language, which a layout may not depend on.

   margin-block: 0 -- the heading row owns the vertical spacing, the same
   division of labour .chart-controls has with .chart-filters. */
.calendar-filters {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: 0.6rem;
  margin-block: 0;
  margin-left: auto;
}


/* docs/calendar/calendar-filters.md M12: the panel is a form, not a list of
   links -- two fields in the order a reader thinks in, then Apply. */
.calendar-filter-form {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

/* fieldset carries a UA border and padding that no other panel content has. */
.calendar-filter-form__group {
  border: 0;
  margin: 0;
  padding: 0;
  min-width: 0;
}

.calendar-filter-form__group[hidden] {
  display: none;
}

.calendar-filter-form__legend {
  padding: 0 0 0.25rem;
  font-size: var(--text-2xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-muted);
}

/* M12c: the platform field scrolls at a set height so the exclusivity field and
   the Apply button below it stay in view. Sixteen platforms in a busy month put
   both below the fold, which is the discoverability problem the form exists to
   fix -- an unscrolled list would have moved it rather than solved it.
   Roughly seven rows: enough that the list reads as a list, short enough that
   what follows it is never off-screen. */
.calendar-filter-form__list {
  max-height: 15rem;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--border) transparent;
}

.calendar-filter-form__option {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.3rem 0.15rem;
  cursor: pointer;
  border-radius: var(--radius);
}

.calendar-filter-form__option:hover {
  background: var(--surface-overlay);
}

/* Name at the left, count at the right, the gap between them doing the work --
   so the counts line up as a column however long the names are. */
.calendar-filter-form__name {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--text-md);
}

.calendar-filter-form__count {
  flex: 0 0 auto;
  margin-left: auto;
  padding-left: 0.75rem;
  font-size: var(--text-2xs);
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
}

.calendar-filter-form__apply {
  width: 100%;
}

/* Shared with .chart-nav: the same
   prev / next stepping row, so the two pages step through periods
   identically. */
.calendar-nav,
.chart-nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 1.5rem;
}

/* Three fixed slots, bounded by the catalogue, so
   each control keeps its own regardless of which of them exist. Under
   space-between alone, a month page missing one neighbour -- now an everyday
   state, since the bound stops prev at the catalogue's first month and next
   at its last, not at some year in 2100 -- slid "Today" into the empty slot.
   At the last month that put it hard right, exactly where "next" lives, so
   the one control that jumps you back to now read as a step forward.
   Explicit columns, not auto-placement: a missing prev must leave column 1
   empty rather than let "Today" fall into it. */
.calendar-nav {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
}

.calendar-nav__prev {
  grid-column: 1;
  justify-self: start;
}

.calendar-nav__today {
  grid-column: 2;
  justify-self: center;
  font-weight: 600;
}

.calendar-nav__next {
  grid-column: 3;
  justify-self: end;
}

.calendar-grid {
  overflow: hidden;
  margin-bottom: 1.5rem;
}

.calendar-grid__weekdays,
.calendar-grid__week {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
}

.calendar-grid__weekdays {
  background: var(--surface-overlay);
}

.calendar-grid__weekday {
  padding: 0.5rem;
  text-align: center;
  font-size: var(--text-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-muted);
}

.calendar-grid__week {
  border-top: 1px solid var(--border);
}

.calendar-grid__cell {
  /* A square, and now actually one. The height is the column's own width at
     every viewport -- a fixed height was square at exactly one width and
     portrait everywhere else.

     docs/calendar/calendar-filters.md S1 AMENDS calendar-volume A3: the
     cell scrolls again. A3 made overflow GROWTH, but a grid row's height is
     shared by all seven of its cells, so one tall day sets the height of the
     whole week -- and the days that fit render a square's worth of content in
     a rectangle's worth of space. The variance is not noise to be tuned away:
     a port or Early Access row wraps its tag onto a second line by design
     (port-legibility G1 chose that over truncating the name, and refused to
     make the row's shape depend on the reader's language), so 12 August runs
     three line-boxes taller than 13 August and always will.

     A3's actual objection was never scrolling as such -- it was that overlay
     scrollbars are INVISIBLE until touched, so a cell holding 35 games and
     showing four said nothing. Two things answer that now and neither existed
     then: DAY_CELL_CAP bounds a cell to six items, and the "+K more" link
     states the exact count it is holding back. What is left below the fold is
     a row or two, not thirty-one -- and the scrollbar is made visible below
     rather than left as an overlay, so the affordance is real. */
  aspect-ratio: 1;
  /* hidden, not auto: the CELL never scrolls. Its rows do, in the box below,
     so the day number and the "+K more" count stay pinned and visible however
     full the day is -- the count disappearing is the one failure A3 named. */
  overflow: hidden;
  min-width: 0;
  /* min-width: 0: a grid item's automatic minimum size is its content's min-content width -- without it, one long game name widens this 1fr track and pushes sibling day cells out of the clipped grid (whole days vanished from view). */
  /* docs/calendar/calendar-entry-wash.md E9: inline padding drops to 0.15rem
     and .calendar-grid__game carries the remaining 0.25rem itself, so the two
     still sum to the 0.4rem inset a cell always had. The row's own padding is
     what lets a washed row read as a band rather than a collar on its text --
     but adding it ON TOP of the full 0.4rem here narrowed every cell by 6.4px
     and truncated names that used to fit ("Halloween: The Game" -> "Halloween:
     The Ga..."), which is a legibility cost paid for decoration. Split instead:
     the text lands on the same x it always did, and the band is what got wider.
     The 0.15rem that stays keeps the band off the cell's own border. */
  padding: 0.4rem 0.15rem;
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

.calendar-grid__cell:last-child {
  border-right: none;
}

/* docs/calendar/calendar-filters.md G1 (measured): --surface-overlay is a
   step OFF the page -- right for the weekday header band above the grid,
   wrong for a hole inside it. Against a --surface-raised panel it reads
   DARKER in light theme and LIGHTER in dark (#1b2330 measured against a
   #121822 panel), so a filler cell was the brightest thing on a dark page
   instead of the dimmest. --surface is the page ground in both themes, so an
   out-of-month cell recedes below the panel regardless of theme. */
.calendar-grid__cell--filler {
  background: var(--surface);
  color: var(--text-muted);
}

/* The scrolling half of a day cell: the game rows and nothing else. The cell
   is a strict square (above), the day number and the drilldown link are pinned
   outside this box, and what does not fit scrolls here.

   min-height: 0 is the load-bearing line, the vertical twin of the cell's own
   min-width: 0. A flex item's automatic minimum size is its CONTENT's, so
   without it this box refuses to shrink below its rows, grows the cell past
   its aspect-ratio, and the square silently stops being one -- the very defect
   this rule exists to fix, back again with no symptom but a ragged grid.

   The gap moves here from the cell, because the cell's own gap now spaces
   three things (number, rows, link) rather than a list of rows. */
.calendar-grid__rows {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  /* docs/calendar/calendar-entry-wash.md E9: 0.05rem, not the 0.35rem that
     used to sit here, because .calendar-grid__game gained 0.15rem of BLOCK
     padding -- and 0.15 x 2 + 0.05 is exactly the 0.35 this replaced. The
     space did not shrink; it moved INSIDE the row, where a washed row uses it
     as the band's own breathing room instead of leaving it dead air the wash
     cannot reach.

     Keeping BOTH is the obvious thing and it is measurably wrong. At 0.35rem
     the row pitch went 24.4px -> 29.2px and every cell holding five rows
     showed four, the fifth scrolled out of a strict square -- buying a
     background by spending a row, which is the trade calendar-volume and
     calendar-legibility spent two contracts refusing. Measure the pitch in a
     screenshot before changing either number; the suite cannot see this.

     margin-block pays back what the gap cut cannot. The gap recovers the
     (N-1) spaces BETWEEN rows; the padding is added at 2N row edges, so the
     box ends up 0.30rem taller at every row count -- N cancels, it is a
     constant, and a cell whose height is a strict aspect-ratio has no slack
     for it. -0.15rem per side removes exactly that. Block axis only: an inline
     margin here would be the horizontal-scrollbar trap E9 names, since
     overflow-y: auto makes this box's x-axis auto too. */
  margin-block: -0.15rem;
  gap: 0.05rem;
  /* Both properties, not scrollbar-width alone: setting the COLOUR is what
     takes the scrollbar out of the OS's overlay mode, and an overlay scrollbar
     is exactly the invisible affordance A3 refused. Same pair the table-scroll
     rule already uses. */
  scrollbar-width: thin;
  scrollbar-color: var(--border) transparent;
}

.calendar-grid__daynum {
  font-size: var(--text-sm);
  font-weight: 600;
  color: var(--text-muted);
}

/* docs/calendar/calendar-legibility.md M3: the stacked list's weekday, hidden
   here because above the breakpoint .calendar-grid__weekdays already names
   every column -- 35 more copies of the same seven words on one screen. The
   640px block reveals it. */
.calendar-grid__dayname {
  display: none;
  font-size: var(--text-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-muted);
}

/* Sunday (7th column, Monday-first grid) reads red -- header + day number. */
.calendar-grid__weekday:nth-child(7),
.calendar-grid__cell:nth-child(7) .calendar-grid__daynum {
  color: var(--danger);
}

/* Today's cell: an inset ring (no layout shift, keeps cells the same size)
   and a filled pill on the day number. docs/calendar/calendar-legibility.md
   P7: the background tint is GONE -- under P2 the wash paints every day
   holding a scored release, so today's own accent tint would survive only on
   days with nothing scored, and today would look categorically different
   depending on whether anything shipped. Deleting the declaration removes the
   specificity race rather than winning it, and keeps --accent out of a cell
   background that is now purely data (house style: an accent never encodes a
   value). The ring alone clears 3:1 against every wash --
   calendar_wash_contrast_test.rb measures it. */
.calendar-grid__cell--today {
  box-shadow: inset 0 0 0 2px var(--accent);
}

.calendar-grid__cell.calendar-grid__cell--today .calendar-grid__daynum {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.5em;
  height: 1.5em;
  padding: 0 0.35em;
  border-radius: var(--radius-pill);
  background: var(--accent);
  color: var(--accent-contrast);
  font-weight: 700;
}

/* docs/calendar/calendar-legibility.md P8/P9: the day number and the peak
   badge share one head row, outside the scrolling rows box. align-items:
   center replaces the day-number pill's own align-self: flex-start (written
   for the old column-flex cell, where the daynum was a lone flex item) --
   here it centres the pill and the badge against each other on one line. */
.calendar-grid__dayhead {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  /* docs/calendar/calendar-entry-wash.md E9: matches .calendar-grid__game's
     own padding-left below, so the day number meets the row's text at the
     same x rather than the row's new inline padding un-aligning them.

     BOTH sides, not padding-left alone. The cell's own inline padding dropped
     0.4rem -> 0.15rem to pay for the row's, so a left-only rule moves the day
     number back to its old x and leaves the peak badge -- which is
     margin-left: auto against this box's RIGHT edge -- 4px further right than
     the hype numbers it heads. Measured at 1280px: badge right edge x=191
     against the rows' x=187, where both sat at 187 before. The badge is E6's
     primary channel sitting at the head of that column of numbers. */
  padding: 0 0.25rem;
}

/* docs/calendar/calendar-entry-wash.md E1: the ROW wash marks the month's
   BIGGEST releases and nothing else -- one tier, one step, no ramp. This rule
   used to live on .calendar-grid__cell--peak; E2 moved it here so the mark
   names the game that earned it rather than the up-to-five neighbours sharing
   its cell -- calendar-legibility P1's original reasoning below is what keeps
   this at one step, and it did not change when the selector did.

   It was a four-step ramp for exactly one day. Two measurements killed that.
   The ramp cannot separate its own steps over this palette: the shipped
   7/11/14 spacing put dark high -> extreme at a 1.001 luminance ratio (the
   same lightness, differing only in hue), and pushing every step to its AA
   ceiling still bought a whole-span deltaE of only 17.2 -- because --hype-*
   rises in SATURATION, not lightness, and what little lightness a low-alpha
   mix has is spent keeping text legible. Then, once the ramp gained a `low`
   step and lost its floor, EVERY day holding a release was tinted, and the
   month read as one block of colour in which no day could be told from the
   next (owner report, 2026-09-01). A channel that cannot rank its own steps
   and also fills the whole grid is worse than no channel.

   So the tint stops being a scale and becomes a scarcity mark. The tier still
   lives in the badge (P3/E6), where a solid fill measures 4.78-9.55:1 and
   carries the number besides -- and is now the ONLY day-level signal, because
   the wash speaks for a single row rather than the cell. Nine dated days over
   the next twelve months clear this tier (E1's own measurement); the rest are
   clean rows with the grid's own lines between them, which is what makes one
   game distinguishable from the next at all.

   E1 (fatal): no tier below the top one gets a background rule here. That is
   the whole point, and it is the line a later "bring the ramp back" would
   cross first -- calendar_wash_contrast_test.rb asserts it, and asserts the
   remaining step still clears AA for every foreground a row can carry. Keep
   the exact `color-mix(in srgb, var(--hype-X) N%, var(--surface-raised))`
   form, or that guard goes blind. */
.calendar-grid__game--scored.hype--extreme {
  background: color-mix(in srgb, var(--hype-extreme) 19%, var(--surface-raised));
}

/* docs/calendar/calendar-legibility.md P3/P4/P6/P9: the peak badge -- the
   PRIMARY tier channel, a solid tier-filled pill beside the day number, the
   .game-card__hype-badge idiom. It renders at every tier, `low` included
   (P6): suppressing it below the fold floor would recreate the exact
   ambiguity this contract removes.

   This rule declares NO color and NO background -- P4: the four tier fills
   below join .game-card__hype-badge's own comma-separated selector lists
   (~4,300 lines above this block) rather than fork a second family, and
   source order decides ties in this sheet, so a fill declared HERE would
   silently beat theirs. line-height: 1 in a box matching the day number's
   own 1.5em keeps the head row's height independent of whether a badge
   renders at all. */
.calendar-grid__peak {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.5em;
  height: 1.5em;
  padding: 0 0.35em;
  margin-left: auto;
  border-radius: var(--radius-pill);
  font-size: var(--text-sm);
  font-weight: 800;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

/* One release = one compact row: tiny square thumb + one-line name + a
   number-only hype badge (label moves to the title tooltip). Keeps many
   releases legible in a single cell without the page or cell ballooning. */
/* docs/calendar/calendar-entry-wash.md E9: padding on EVERY row, not on the
   --scored rule alone -- a wash-only padding would shift washed rows sideways
   against their unwashed neighbours, which is worse than the collar it fixes.
   Without any inline padding a background here hugs the text (a collar); with
   it, the row reads as a band, and the small radius below is what makes that
   reading land.

   Row content landing at exactly the same x requires matching inline padding
   on both row and dayhead. The row carries 0.25rem; .calendar-grid__dayhead
   above this rule takes 0.25rem (as padding: 0 0.25rem). The cell's own inline
   padding was cut 0.4rem -> 0.15rem to pay for the row's, so the net effect is
   that the row's content lands on exactly the same x it always did (measured
   x=203 at 1280px before and after), rather than moving right at all.
   Deliberately NOT a negative margin: .calendar-grid__rows (above) is
   overflow-y: auto, whose x-axis therefore computes to auto too, and a child
   bleeding past its left edge via a negative margin is a live way to hand it a
   horizontal scrollbar. This mechanism never moves the row's own box, so it
   carries no such risk. */
.calendar-grid__game {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  min-width: 0;
  padding: 0.15rem 0.25rem;
  border-radius: var(--radius-xs);
}

.calendar-grid__game-name {
  flex: 1 1 auto;
  min-width: 0;
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--text-xs);
  font-weight: 600;
  line-height: 1.2;
  color: var(--text);
}

/* Shrinks the reused .game-card__hype pill down to a compact number chip;
   the tier colour classes (hype--low, --moderate, --high, --extreme) still
   apply. */
/* docs/calendar/calendar-filters.md N1: the day cell's right-hand column is
   plain text -- a number for a game, a word for a marker row -- and nothing in
   it is a pill.

   The chips were three different shapes in one column: a hype pill tinted by
   tier, an outlined PORT pill, an outlined EARLY ACCESS pill, and, for an
   unscored game, no pill at all. In a ~150px cell that reads as clutter rather
   than as one column of facts. tabular-nums so the numbers line up as a column
   even in a proportional face. */
.calendar-grid__hype {
  flex: 0 0 auto;
  margin-left: auto;
  padding-left: 0.4rem;
  /* Bigger and heavier than the name it sits beside (--text-xs / 600), where it
     used to be smaller and lighter than it -- at --text-2xs and a normal weight
     there was too little ink on screen for a tier to be legible as a colour at
     all. Two digits cost almost nothing in width, and the name ellipsises. */
  font-size: var(--text-sm);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  /* The unscored dash and the fallback. A tier below overrides it. */
  color: var(--text-muted);
}

/* N1a: the tier ramp comes back, on the TEXT. Dropping the chip took the colour
   with it because the colour WAS the chip -- .game-card__hype's tinted pill --
   and losing the ramp was never the point; losing the pill was.

   -ink, not the bare swatch: the ramp's three members are a vivid swatch (3:1
   non-text against --surface-overlay), ink for a 22% tint of it, and ink for a
   solid fill. This is text on a surface that is either raised or lightly
   tinted, which is the tint member's case. Measured against the day cell,
   AA needs 4.5:1 for text this size:

     light   raised 7.22 / 8.42 / 8.27 / 9.15    peak-tinted 7.22 / 7.71 / 7.04 / 7.22
     dark    raised 7.38 / 10.55 / 9.28 / 8.07   peak-tinted 7.38 / 9.36 / 7.92 / 6.86

   The bare swatch would have passed too in most cells (light moderate 4.78) but
   only barely, and it is the wrong member of the ramp for text regardless.

   The classes are the app's own tier vocabulary. They carry no pill: every
   background rule in the ramp is scoped to .game-card__hype or its badge, so a
   bare hype--extreme is a name for a tier and nothing else. */
.calendar-grid__hype.hype--low {
  color: var(--hype-low-ink);
}

.calendar-grid__hype.hype--moderate {
  color: var(--hype-moderate-ink);
}

.calendar-grid__hype.hype--high {
  color: var(--hype-high-ink);
}

.calendar-grid__hype.hype--extreme {
  color: var(--hype-extreme-ink);
}

/* docs/phases/port-legibility.md G1/G2: a port row's marker chip rides the same slot
   the hype chip fills above -- hype is frozen for these games and would
   mislead, and a platform chip list there truncated the game's name to a
   single glyph and an ellipsis (the thing this chip replaces). Mirrors
   .game-card__port-tag's neutral OUTLINE pill (no new colour tokens: the
   same border/text-muted pair) at the cell's own compact scale.

   Unlike .calendar-grid__hype it sits on its OWN line, and the two rules
   below are what put it there. The hype chip is a two-digit number, ~24px in
   every locale. This chip is a WORD, and games.card.port is four characters
   in six locales and up to nine in the rest -- "Port" (en/de/es/pt/da)
   against "Portage" (fr), "Porting" (it), "Porttaus" (fi), "Portering"
   (sv/nb). Measured at 1100px the row gives a name ~88px beside its cover,
   and a Swedish chip claims ~63px of it: "Aeterna Noctis" rendered as "A..."
   -- the exact failure this chip was added to fix, reintroduced in four
   locales.

   So the name refuses to shrink past min-width and the chip is what gives
   way -- but only when it has to. Measured on /sv/calendar/2026/9 and its
   /de/ twin: in the desktop grid at 1100px the chip wraps in EVERY locale,
   the short-word ones included, because ~88px cannot hold a readable name
   and any of these words at once ("Angeline Era" over "PORT" beats
   "Angelin... PORT"). At 390px the grid collapses to the stacked list below,
   the row is ~330px, and the chip sits on the name's own line exactly where
   a hype chip sits -- "Super Woden: Rally Edge  PORTERING", nothing
   truncated, no second line anywhere.

   Tuning the arithmetic to keep the short-word locales on one line at 1100px
   was tried and rejected: it buys a truncated name and makes the row's shape
   depend on which language the site is read in. Width may decide this;
   language may not.

   docs/phases/release-phases.md R2: .calendar-grid__game--early-access
   shares this rule -- own class (several tests scan the response body for
   the literal "calendar-grid__game--port" string to count port rows, so an
   EA entry cannot reuse it), identical layout need. games.card.early_access_
   tag runs longer still than any port word measured above ("Accesso
   anticipato", "Acesso antecipado", 18-19 characters) -- the ellipsis rule
   on the tag itself, below, is what keeps ships from breaking this cell's
   layout outright where wrapping alone would not. */
.calendar-grid__game--port,
.calendar-grid__game--early-access {
  /* AMENDED by docs/calendar/calendar-filters.md S5: the chip TRUNCATES
     rather than wrapping onto its own line. Everything above still describes
     why the NAME must not be the thing that gives way, and that is unchanged --
     the name still refuses to shrink past its min-width and the chip is still
     what yields. What changed is where it yields TO.

     The cell is a strict square now (S1). Under the old growth model a wrapped
     chip cost the week row a few pixels of height and nothing else; under a
     fixed square it costs a whole ROW of the three or four a cell has, so a day
     with three marker rows showed three games where it could have shown five.
     A clipped "PORTERI..." in the hype chip's own slot says what kind of row
     this is, which is all G1 ever asked the chip to do, and it buys back the
     line. nowrap is inherited from .calendar-grid__game; this rule now only
     needs to stop opting out of it. */
  flex-wrap: nowrap;
}

/* docs/calendar/calendar-legibility.md SC6/SC7: the showcase row's OWN rule,
   the same single declaration as the port/EA pair above -- deliberately NOT
   joined to their selector list. calendar_grid_paint_test.rb looks that rule
   up by its exact two-line selector string, and a third member there would
   break the lookup with a message naming the wrong thing (release-phases R2
   gave EA its own class for the identical reason). A showcase row carries no
   tag, only a name, but it leads the cell (SC4) and can sit beside a marker
   row that DOES wrap its tag, so it keeps the same no-wrap behaviour rather
   than silently opting back into .calendar-grid__game's default. */
.calendar-grid__game--showcase {
  flex-wrap: nowrap;
}

/* docs/calendar/calendar-legibility.md SC7/MK1: the showcase tag's OWN rule,
   declarations identical to the port/EA pair below. It does NOT join their
   selector list, and neither does the .icon rule under it, for the reason SC7
   gives: calendar_grid_paint_test.rb looks both of those rules up by their
   exact two-line selector string, and a third member breaks the lookup with a
   message naming the wrong thing. Duplication here is the cheaper half of that
   trade -- the same call release-phases R2 made when EA refused to share
   --port's class. */
.calendar-grid__showcase-tag {
  flex: 0 1 auto;
  margin-left: auto;
  min-width: 0;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  padding-left: 0.4rem;
  color: var(--text-muted);
  white-space: nowrap;
  display: inline-flex;
  align-items: center;
}

.calendar-grid__showcase-tag .icon {
  width: 0.85em;
  height: 0.85em;
}

/* AMENDED with S5's nowrap: basis auto and NO grow, where this used to be
   `flex: 1 1 0`.

   Basis 0 was right while the row wrapped -- a wrapping flex container breaks
   lines on each item's BASE size before it shrinks anything, so a name asking
   for its full content width sent the chip to the next line and could leave the
   cover stranded above it. Basis 0 made the name ask for nothing and grow into
   what was left.

   With nowrap that same grow is a bug: the name expands to fill the row however
   short its text, so the chip was squeezed to its minimum EVERY time and read
   "PO..." beside "Alien Bloom" with room to spare. Basis auto asks for exactly
   the text's width, so a short name leaves the chip whole and only a genuinely
   long one starts taking characters off it. min-width is unchanged and still
   the floor the name will not go below -- the chip is still what gives way. */
.calendar-grid__game--port .calendar-grid__game-name,
.calendar-grid__game--early-access .calendar-grid__game-name {
  flex: 0 1 auto;
  /* 3.5rem, down from 4.5rem with N1's pill removal. The floor is a floor, not
     a preference: a name shorter than it still OCCUPIES it, so while the marker
     was a padded uppercase pill the two together overflowed a ~150px cell even
     when neither needed to, and the word came back as "Po...". A glyph is the
     same width in all ten locales, so "Grounded 2" and long names now have a
     consistent floor; the floor still stops a long name from squeezing the
     glyph out of existence, which is what port-legibility G1 put it there for. */
  min-width: 3.5rem;
}

/* max-width + ellipsis is the EA tag's own addition to the port-tag shape:
   "Accesso anticipato"/"Acesso antecipado" (18-19 characters) are roughly
   twice "Portering"'s 9, long enough that wrapping onto the chip's own line
   is not enough by itself at a narrow cell width -- the pill would still
   claim more horizontal space than the cell has. Capped at the cell's own
   content width (100%) so it can only ever shrink to fit, never widen the
   cell. .calendar-grid__port-tag never needed this (no port word measured
   past 9 characters), so the cap sits on the EA tag alone. */
/* One rule for both tags now: the ellipsis treatment used to sit on the EA tag
   alone, because only its 18-19-character strings ("Accesso anticipato",
   "Acesso antecipado") could outrun a cell on their own. With S5's nowrap every
   tag can be asked to give way, so every tag needs to be able to.

   flex: 0 1 auto with min-width: 0 -- shrink, but only under protest. The name
   carries flex-basis 0 and a min-width, so it claims what is left after this
   chip's content width and stops at its floor; the chip only starts losing
   characters once the name has hit that floor. At full desktop width nothing
   truncates at all. */
.calendar-grid__port-tag,
.calendar-grid__early-access-tag {
  flex: 0 1 auto;
  /* Flush right, in the same column the hype chip occupies on an ordinary row.
     The plain .calendar-grid__game-name grows (flex: 1 1 auto) and pushes its
     chip to the edge for free; S5's marker-row name deliberately does NOT grow,
     so without this the chip hugs the end of the name and every row starts its
     chip at a different x. An auto margin takes the free space instead, and
     collapses to 0 the moment there is none -- so the shrink-and-ellipsise
     behaviour above is untouched. */
  margin-left: auto;
  min-width: 0;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  /* N1: no border, radius, weight, letter-spacing or uppercasing -- the pill is
     gone and only the word remains, in the same column and the same ink as a
     hype number. The shrink-and-ellipsise behaviour above is untouched: a long
     stage word in a narrow cell still gives way rather than wrapping (S5). */
  padding-left: 0.4rem;
  color: var(--text-muted);
  white-space: nowrap;
  /* docs/calendar/calendar-legibility.md MK3: the word is now
     .visually-hidden (MK1/MK2), so the absolute font-size this tag used to
     carry is gone -- nothing here sets pixel text any more. Vertically
     centres the glyph against the name it sits beside. */
  display: inline-flex;
  align-items: center;
}

/* MK1/MK3: the glyph's own size, an em value off .icon's inherited
   font-size (control_system_test.rb freezes the type scale to --text-* steps
   or an em value -- this is the em half). 0.85em mirrors
   .chart-entry__move .icon, the app's other compact inline glyph. */
.calendar-grid__port-tag .icon,
.calendar-grid__early-access-tag .icon {
  width: 0.85em;
  height: 0.85em;
}

/* A chart row's phase word, beside .chart-entry__date inside the load-bearing
   .chart-entry__meta wrapper -- same neutral outline pill as the calendar tag
   above, so a reader learns one shape for "this date belongs to a named
   phase" rather than two. The ellipsis cap is the same defensive one the EA
   calendar tag carries: "Accesso anticipato"/"Acesso antecipado" run 18-19
   characters, long enough to want a cap even beside a name column that
   already absorbs the row's slack. */
.chart-entry__phase-tag {
  flex: 0 1 auto;
  min-width: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  padding: 0.05rem 0.32rem;
  font-size: var(--text-2xs);
  font-weight: 600;
  text-transform: uppercase;
  color: var(--text-muted);
  white-space: nowrap;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The bare .chart-entry__meta is a plain inline span (the ports branch
   depends on that flow), so the ellipsis trio above would be inert under
   it: overflow needs a block-level box. The phase variant blockifies its
   children with an inline flex row, which is what lets the tag actually
   truncate instead of pushing the date off the row. */
.chart-entry__meta--phase {
  display: inline-flex;
  align-items: baseline;
  gap: 0.35rem;
  min-width: 0;
  max-width: 100%;
}

/* docs/phases/tba-legibility.md Z2: the short "1.0 TBA" marker, a
   third child of .chart-entry__meta--phase beside the phase tag and its
   date -- same muted ink and small size as .chart-entry__date so it reads
   as a footnote to it, not a second fact competing for attention. */
.chart-entry__tba-note {
  flex: 0 0 auto;
  font-size: var(--text-2xs);
  color: var(--text-muted);
  white-space: nowrap;
}

/* docs/calendar/calendar-volume.md A1/A4: the day cell's drilldown link,
   the cap's final row when it renders at all. A plain row, same rhythm as
   .calendar-grid__game above it.
   docs/calendar/calendar-legibility.md P11: an <a> with no colour of its own
   takes --accent, which measures 3.89:1 on the 19% extreme wash -- below AA
   in the light theme. --accent-strong clears 6.09:1 at the strongest wash. */
.calendar-grid__more {
  display: block;
  font-size: var(--text-2xs);
  font-weight: 600;
  color: var(--accent-strong);
  /* docs/calendar/calendar-entry-wash.md E9: "same rhythm as
     .calendar-grid__game above it" (comment above), kept true now that a row's
     own inline padding moved its text 0.25rem right of this link's own edge. */
  padding-left: 0.25rem;
}

/* docs/calendar/calendar-legibility.md MK4: the legend, between the grid and
   the rails on the month page. Panel chrome (background/border/radius) comes
   from the shared PANEL selector list above -- this rule keeps only layout,
   per stylesheet_dedupe_test.rb's own instruction ("keep padding, layout and
   overflow in your own rule, because that is where a panel SITS rather than
   what it IS"). Two groups side by side, wrapping onto their own lines with
   no media query needed -- a plain flex-wrap survives the 640px collapse the
   same way every other flex row in this file does. A plain block, never a
   <details>: ::details-content wraps the body in ONE flex item, so the gap
   below would never reach the second group. */
.calendar-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem 1.5rem;
  margin: 0 0 1.5rem;
  padding: 0.75rem 1rem;
}

.calendar-legend__group {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.6rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

.calendar-legend__item {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
}

.calendar-legend__word {
  font-size: var(--text-xs);
  color: var(--text-muted);
}

/* narrow viewports collapse the grid to a stacked list of only the
   days that have releases; filler and empty in-month cells are hidden.
   Same markup, CSS-only -- no horizontal scroll at 375px.
   640px rather than the 600 this shipped with: 600 was a fourth value that
   agreed with none of the others (the set is 480/560/640/900). A seven-column grid has
   nothing to gain from the extra 40px, so it collapses slightly sooner. */
@media (max-width: 640px) {
  /* M1a: the summary drops to its applied VALUE alone -- "PlayStation 5", not
     "Platform: PlayStation 5". The value alone still says what the control is
     doing (H3), and the summary's aria-label keeps the full form for anyone who
     is not reading it by eye. */
  .calendar-platform-menu__label {
    display: none;
  }

  /* M9a: two rows at this width, and exactly two. 390px cannot hold the scope
     tabs, the summary and a two-pill exclusivity toggle on one line -- with a
     platform applied the filter wrapped to three stacked rows and squeezed the
     tabs to a sliver, which is the "cramped" this rule answers.

     The filter takes a full row of its own under the tabs, and that row
     SCROLLS rather than wrapping, exactly as the tab row above it does and for
     the same reason: a wrapping row inside a sticky header grows without bound
     and pushes the page down as it does. Scrolling is bounded at two rows
     whatever the applied platform's name length happens to be in the reader's
     language -- which is not a thing the layout may depend on. */
  /* One control in this row now that the exclusivity pair moved into the panel
     (M6a), so it needs neither the horizontal scroll nor the shrink guards it
     carried while it held three things. It sits under the h1 rather than
     opposite it, because the heading wraps at this width.

     flex-basis: 100% is what MAKES that true, and it is load-bearing rather
     than decorative: margin-left: 0 alone only positions the control once the
     heading has already wrapped, and whether it wraps depends on how wide the
     h1's stamp happens to be. "maj 2026" fits beside the summary at 390px and
     "september 2026" does not, so the trigger sat at the right end of the
     heading row in some months and at the left in others -- and the panel
     below, anchored to one of those two edges, opened off-screen in the other
     half of the year. A full-width basis puts the control on its own row in
     every month and every language, which is what the panel's anchor can then
     be written against. */
  .calendar-filters {
    margin-left: 0;
    flex-basis: 100%;
  }

  /* ...and the row above needs its spacing re-homed, or the control arrives
     with 28px over it and 0 under it. Measured at 390px before this rule: the
     h1's own `margin: 0 0 0.5em` (16px) is what separated the HEADING from the
     nav below while the two shared a line. Give the filter a line of its own
     and that margin stops being the heading's bottom edge and becomes the
     space between its two rows -- 16px of h1 margin plus the heading's 12px
     row-gap above the pill, and the .calendar-nav sitting flush against it
     below, since that row carries margin-bottom only.

     So the h1 gives its margin up here and the heading takes it: 12px above
     the control, 1rem below the pair. The proximity then says what is true --
     the filter belongs to the heading it narrows, not to the month stepper
     under it -- which is the reading the old numbers inverted. 1rem is the
     same 16px the h1 was contributing (its 0.5em of the browser's 2em default,
     which no breakpoint in this sheet rescales), so the page below the heading
     does not move at all. */
  .calendar-heading {
    margin-bottom: 1rem;
  }

  .calendar-heading > h1 {
    margin-bottom: 0;
  }

  /* The panel keeps the header's right: 0 at every other width, because the
     trigger is right-aligned there (.calendar-filters' margin-left: auto) and
     a panel wider than its pill has to grow leftwards, into the page. Here the
     rule above pins the trigger to the LEFT of its own row, so the same anchor
     grows the panel out through the left edge of the viewport: measured at
     390px, the radios, the platform glyphs and the first characters of every
     name sat at negative x, and the control could be opened but not used. It
     grows rightwards instead -- min-width 15rem from a 1.25rem gutter is well
     inside 390px, and the shared max-width: calc(100vw - 2rem) holds the wider
     languages. */
  .calendar-platform-menu .nav-menu__panel {
    left: 0;
    right: auto;
  }

  .calendar-grid__weekdays {
    display: none;
  }

  .calendar-grid__week {
    display: block;
  }

  .calendar-grid__cell {
    display: none;
    /* docs/calendar/calendar-filters.md G2 (measured): height: auto alone
       does NOT undo aspect-ratio: 1 -- below this breakpoint the week is
       display: block, so a cell is a full-width block box and the ratio
       derives its auto height straight from that width, ~340px at 390px.
       A stacked day's height must be its content's, not its width's. */
    aspect-ratio: auto;
    height: auto;
    border-right: none;
    border-bottom: 1px solid var(--border);
  }

  /* docs/calendar/calendar-legibility.md M1 (fatal): a day holding nothing
     stays hidden -- that is the volume rule the collapse exists for -- but
     TODAY renders whatever it holds. It is the reader's only landmark in a
     list that can run several screens, and hiding it does not make today hard
     to find, it makes it impossible. */
  .calendar-grid__cell:has(.calendar-grid__game),
  .calendar-grid__cell--today {
    display: flex;
  }

  /* M3: the column that said which weekday this is has gone with the grid. */
  .calendar-grid__dayname {
    display: inline;
  }

  /* M4: on the grid the day number is a minor label, because POSITION carries
     the date. In the list it is the only thing that does -- so it stops being
     the quietest mark in a cell that also holds a bold tier badge. */
  .calendar-grid__daynum {
    font-size: var(--text-md);
    font-weight: 700;
    color: var(--text);
  }

  .calendar-grid__cell:last-child {
    border-bottom: none;
  }

  /* Every cell is hidden above unless it holds a game, so an empty month would
     otherwise render this panel as a bare rounded bar above the empty state --
     newly common now that a platform filter can empty any month. */
  /* M1 (fatal): ...and the grid itself survives a month whose only content is
     today. This hide exists so an empty month renders no bare rounded bar, and
     a platform filter can empty any month -- but "empty" must not include the
     one cell the reader is looking for. */
  .calendar-grid:not(:has(.calendar-grid__game)):not(:has(.calendar-grid__cell--today)) {
    display: none;
  }
}

/* ---------- Calendar precision lists
 ---------- One shared card list (calendar/_window_games) for all
   four of: the month page's month-precision rail, the quarter page's list,
   the year page's and the day drilldown's. There is no day cell to file
   these under, so the card carries what a day cell implies and more: cover,
   name, studio, summary, platforms, hype.

   A full-width stack, not the 200px auto-fill grid it used to be. That grid
   inherited the day cell's scarcity -- thumb, ellipsised name, a number --
   onto pages that have the whole page width to spend. Absent from the DOM
   when empty, so there is no empty-state rule to write here. */
.calendar-window {
  margin-bottom: 1.5rem;
}

.calendar-window__heading {
  font-size: var(--text-xl);
  font-weight: 700;
  margin: 0 0 0.6rem;
  color: var(--text-muted);
}

.calendar-window__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.6rem;
}

.calendar-window__item {
  display: flex;
  gap: 0.85rem;
  min-width: 0;
  padding: 0.75rem;
}

.calendar-window__cover-link {
  flex: 0 0 auto;
  line-height: 0;
}

/* 3:4, the aspect IGDB's t_cover_big actually is -- the old 32px square
   cropped every cover to a letterbox of its middle. */
.calendar-window__cover {
  width: 72px;
  height: 96px;
  flex: 0 0 auto;
  border-radius: var(--radius-xs);
  object-fit: cover;
  background: var(--surface-overlay);
}

/* 2% of the catalogue has no cover, and on a stacked card an absent one left
   the whole body hanging off the card's left edge. Same initial-letter tile
   as .chart-entry__cover--placeholder. */
.calendar-window__cover--placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--text-2xl);
  font-weight: 700;
  color: var(--text-muted);
}

.calendar-window__body {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

/* The hype chip rides the name's line, pinned right -- it is what the list is
   ordered by, so it belongs beside the thing it ranks. Baseline alignment, not
   centre: the name can wrap to two lines, and a centred chip would drift down
   the card with it. */
.calendar-window__headline {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.6rem;
}

/* Wraps rather than ellipsising: the card is as wide as the page, so a title
   that does not fit is a genuinely long title and cutting it buys nothing.
   `anywhere` for the unbroken-string case. */
.calendar-window__name {
  min-width: 0;
  font-size: var(--text-2xl);
  font-weight: 600;
  color: var(--text);
  overflow-wrap: anywhere;
}

/* The studio line, one line only: two studios with long names would otherwise
   push the summary down and make the cards ragged for no gain. */
.calendar-window__meta {
  margin: 0;
  font-size: var(--text-xs);
  color: var(--text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Clamped to two lines here, NOT truncated in the partial: the whole summary
   stays in the DOM for crawlers and a cut never lands mid-word. Real
   summaries run to 500 characters.

   The measure is capped because the card is not: at 1280px the body is
   ~1130px wide, which is ~170 characters a line -- two lines of that is a
   paragraph you have to track across the whole page. The game hero caps its
   own summary the same way (60ch there, where the column is narrower). */
.calendar-window__summary {
  margin: 0;
  max-width: 95ch;
  font-size: var(--text-sm);
  color: var(--text-muted);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
}

/* margin-top: auto pins the chips to the card's bottom edge (the item's
   default cross-axis stretch gives the body the cover's full height), so the
   glyph row lands in the same place whether or not the game has a summary or a
   studio line. */
.calendar-window__platforms {
  display: flex;
  align-items: center;
  margin-top: auto;
  padding-top: 0.1rem;
}

/* The reused .game-card__hype pill, sized for a card headline rather than the
   compact number chip a day cell needs (.calendar-grid__hype above). */
.calendar-window__hype {
  flex: 0 0 auto;
  margin: 0;
  padding: 0.1rem 0.4rem;
  font-size: var(--text-xs);
}

/* Narrow: the stack already fits, so this only reclaims the width the cover
   and the padding spend. Nothing is hidden -- the studio line ellipsises and
   the summary clamps, which they already do at every width. */
@media (max-width: 480px) {
  .calendar-window__item {
    gap: 0.6rem;
    padding: 0.6rem;
  }

  .calendar-window__cover {
    width: 52px;
    height: 69px;
  }
}

/* ---------- Charts ---------- */

/* The measure a ranked column reads at, shared by /charts and /reception --
   which reuse these rows and so would otherwise inherit the container's full
   1500px. A row spends its width on fixed furniture at both ends (rank, cover,
   then the glyph, spark and metric columns) and gives the name whatever is
   left, so a wider column only moves a name further from the value it is
   ranked by. 56rem is the width at which the longest catalogue titles still
   set on one line, which keeps every row the same height to scan down.
   The wrapper is bounded rather than the list alone: heading, blurb, controls
   and stepper take the same right edge, so the column reads as one thing.
   Left-aligned, because the header's brand and nav are flush left. */
.chart-column {
  max-width: 56rem;
}

.chart-heading {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 0.5rem 1rem;
}

.chart-heading__stamp {
  font-size: var(--text-lg);
  font-weight: 700;
  color: var(--text-muted);
  margin: 0;
}

/* calendar-blurb joins here: the
   quarter/year pages' explanatory line, same treatment as a chart's. */
.chart-blurb,
.calendar-blurb {
  color: var(--text-muted);
  max-width: 62ch;
  margin-block: 0.25rem 0.5rem;
}

/* the period tab row (the kind "categories" now ride the secondary nav
   bar in the site header, .charts-nav). Pill vocabulary shared with .charts-nav
   (inherited from the retired .view-toggle), with the active
   state hung off aria-current so the CSS hook and the a11y attribute can never
   drift apart. */
.chart-tabs,
.calendar-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-block: 1rem 0.25rem;
}

/* Pill-tab chrome comes from the shared rule with .game-tabs. */

/* The period pills are the right-hand half of .chart-controls now, not a row
   of their own, so the row owns the vertical spacing: an asymmetric margin
   under align-items: center is centred as a margin box and would push the
   pills off the trigger's centre line (the same trap .chart-tabs--exclusive
   documents below). */
.chart-tabs--periods {
  margin-block: 0;
  font-size: 0.95em;
}

/* The exclusivity toggle is the one .chart-tabs row that lives INSIDE another
   flex row rather than standing on its own, so .chart-tabs' vertical margins
   are the wrong ones twice over: .chart-filters owns the spacing, and an
   asymmetric margin under align-items: center is centred as a margin box --
   which dropped the pills ~6px below the menu trigger's centre line and made
   the row 20px taller the moment a platform was applied.
   docs/calendar/calendar-filters.md G4: .calendar-tabs--exclusive joins
   here rather than restating the rule -- same row, same trap. */
.chart-tabs--exclusive,
.calendar-tabs--exclusive {
  margin-block: 0;
}

.chart-tabs--periods a[aria-current="page"],
.chart-tabs--exclusive a[aria-current="page"],
.calendar-tabs--exclusive a[aria-current="page"] {
  color: var(--accent-strong);
  background: color-mix(in srgb, var(--accent) var(--tint-soft), transparent);
  border-color: color-mix(in srgb, var(--accent) 40%, transparent);
}

/* ---------- Chart controls (filters + period pills, H1) ----------

   ONE row between .chart-blurb and the stepper: filters left, period pills
   right, justified apart. Two rows of small pills stacked flush left read as
   one ambiguous block -- the space between is what tells you these are two
   different axes.

   space-between, not an auto margin on the pills: when the row wraps (narrow
   viewports, a long applied platform name) each line justifies on its own, so
   the pills land at the LEFT of their own line rather than stranded right,
   under a left-aligned filter. A lone half -- a periodless kind, or a
   catalogue with no platform to offer -- sits left for the same reason. */
.chart-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem 1rem;
  margin-block: 0.75rem 0.5rem;
}

/* The left half: the platform disclosure menu and the exclusivity pill pair,
   which stay a group of their own so they wrap together. */
.chart-filters {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.6rem;
  margin-block: 0;
}

/* .nav-menu__panel is right: 0 for the header's own menus, anchored under a
   right-aligned trigger; this is the only reason .chart-platform-menu exists
   at all (H2) -- ITS trigger sits at the LEFT of a content row, so its panel
   must open the same way. No surface styling here: that is the shared PANEL
   primitive's job.

   docs/calendar/calendar-filters.md M9: .calendar-platform-menu is NOT on
   this rule, and that is the one selector it does not share. Its trigger moved
   to the right end of the secondary bar, so a left-anchored panel opened away
   from the viewport -- measured at 1200px, the panel ran off the right edge and
   took the counters (M10, right-aligned in each row) with it. It keeps the
   header's own right: 0 default instead. Everything else about the control is
   still shared, deliberately. */
.chart-platform-menu .nav-menu__panel,
.chart-genre-menu .nav-menu__panel {
  left: 0;
  right: auto;
}

/* The panel has to hold a name and a count on one row without the count
   wrapping under the name, and the widest catalogue name ("PC (Microsoft
   Windows)") plus a four-digit count is the case that decides it. */
.calendar-platform-menu .nav-menu__panel {
  min-width: 15rem;
}

/* The glyph sits beside the full platform name, same shape the retired
   .chart-tabs--platforms pill row used -- .platform-icon already reserves its
   own 1.15em box (see .platform-icon), so nothing further is needed. */
.chart-platform-menu .nav-menu__panel a,
.calendar-platform-menu .nav-menu__panel a {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
}

/* Only one neighbour exists at the ends of the bounded period range; keep a
   lone "next" on the right rather than letting space-between park it left.
   The calendar's quarter/year pages need the same, but ONLY there: the
   selector is .calendar-nav--periods, never a bare .calendar-nav__next. The
   month page's .calendar-nav renders three children (prev / Today / next),
   and an auto main-axis margin absorbs all free space before space-between
   ever runs -- it would pack "Today" against "prev".

   .calendar-nav is a three-column grid (see it, above), and
   grid-column: 3 already holds a lone "next" right, so the calendar half of
   this rule is belt-and-braces -- it agrees with justify-self rather than
   fighting it. .chart-nav is still flex, and still needs it. */
.chart-nav__next,
.calendar-nav--periods .calendar-nav__next {
  margin-left: auto;
}

.chart-list {
  list-style: none;
  margin: 1.25rem 0 2rem;
  padding: 0;
  overflow: hidden;
}

/* The tail of a ranked list. Its own <ol> because an <ol> cannot hold a
   <details>; the panel treatment it inherits from .chart-list is what makes
   the revealed rows read as a continuation of the list above rather than a
   second, unrelated one. */
.chart-fold {
  margin-bottom: 2rem;
}

/* A quiet text control, and the UA marker is deliberately left drawn: the
   label names the ranks it holds instead of an action, so nothing else in the
   row would say whether it is open. */
.chart-fold__toggle {
  cursor: pointer;
  padding: 0.35rem 0;
  color: var(--text-muted);
  font-size: var(--text-sm);
  font-weight: 600;
}

.chart-list--rest {
  margin-top: 0.5rem;
}

/* The open list hands its bottom margin to the fold below it, so the control
   sits with the rows it belongs to instead of floating between two panels. */
.chart-list:has(+ .chart-fold) {
  margin-bottom: 0;
}

/* A ranked row is a two-line GRID, and the two lines ARE the design.
   docs/charts/two-line-row.md T1: the rank and the score sit on line one,
   the rank movement and the hype step on line two, each directly under the
   number it modifies. Reading straight across line two gives the whole
   day-over-day story at once -- which the previous layout, with the two
   indicators at opposite ends of a flex row and at different heights, could
   not do at all (chart-movement D1 and D2, superseded here).

   `display: contents` on .chart-entry__standing, __body, __metric and
   __trail is what buys that alignment: their children become items of the
   ROW's grid, so "the same line" is a grid row rather than a coincidence of
   two independently centred blocks.

   FOUR TRACKS, five when a sparkline is drawn. The last track is
   minmax(floor, max-content): every normal row sits on the floor, and the
   one row that cannot -- an unscored `ports` phase whose whole metric is a
   date, "18. September 2026" -- grows its own track rather than overflowing
   into what sits beside it. That is chart-row-platforms E2b's rule kept
   ("ragged degrades, clipped does not"), in the grid idiom.

   Shared with /reception and /attention, which take the four-track form and
   stack their two pills in the last track. */
.chart-entry {
  display: grid;
  grid-template-columns: 2.6rem auto minmax(0, 1fr) minmax(6rem, max-content);
  /* FIVE ROWS, AND THE FIRST AND LAST ARE SPACERS. The cover spans the whole
     row and is 59px against a text block of ~44px, and a grid distributes a
     spanning item's excess height across the tracks it spans -- so with two
     auto rows the cover silently pushed the title and the date apart, by
     7px each, and no amount of tightening line-height or row-gap could reach
     it. The 1fr rows absorb that excess instead, splitting it evenly above
     and below, which both keeps rows 2-4 at their own content height and
     centres the text block against the cover.
     Rows: spacer / name / date / platform words (480 only) / spacer. */
  grid-template-rows: 1fr auto auto auto 1fr;
  align-items: center;
  column-gap: 0.9rem;
  row-gap: 0.15rem;
  padding: 0.6rem 0.95rem;
  border-top: 1px solid var(--border);
}

.chart-entry:first-child {
  border-top: none;
}

/* A chart row's metric is a figure; a verdicts or attention pill is a word
   ("Herausragend", "Efterfragan 89"), so the two need different floors. */
.chart-entry--ranked {
  grid-template-columns: 2.6rem auto minmax(0, 1fr) 8.2rem minmax(3.6rem, max-content);
}

/* The sparkline earns a track only where one is drawn. `reviewed` is a
   ranked chart with no series (Chart::SPARKLINE_KINDS), and a fixed fifth
   track there would be 72px of nothing on all fifty rows. */
.chart-entry--ranked:has(.chart-entry__spark) {
  grid-template-columns: 2.6rem auto minmax(0, 1fr) 8.2rem 6rem minmax(3.6rem, max-content);
}

/* The rank, RIGHT-aligned in its track. Tabular figures plus a right edge is
   what puts the ones column of 1, 10 and 100 on one vertical -- centring
   staggers them by half a digit each time the rank gains one, which down
   fifty rows reads as the column wobbling. It also sets the rank against the
   cover beside it rather than floating in the middle of its own track.

   On /reception and /attention it is the whole of track 1 -- no movement sits
   under it there -- so it centres vertically across both lines. */
.chart-entry__rank {
  grid-column: 1;
  grid-row: 1 / -1;
  justify-self: end;
  font-size: var(--text-5xl);
  /* THE ROW'S FIRST LINE IS AS TALL AS THIS BOX. A 24px figure inheriting
     body's 1.5 gets a 36px line box, and grid row 1 sizes to its tallest
     item -- so the 20px title beside it floated with 8px of dead space above
     and below, which reads as a gap between the title and the date and is
     not row-gap (2.4px) or the title's own leading. Digits have no
     descenders, so 1 crops nothing. */
  line-height: 1;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
}

/* The top three carry the chart's accent -- the one bit of hierarchy a
   numbered list can't express on its own. Scoped away from a folded tail:
   nth-child counts within its own <ol>, so the first three rows behind a
   fold are ranks 21, 22 and 23 and are not the top of anything. */
.chart-list:not(.chart-list--rest) .chart-entry:nth-child(-n + 3) .chart-entry__rank {
  color: var(--accent-strong);
}

/* docs/charts/two-line-row.md T2: the standing block keeps its element --
   chart-movement D1's "the rank and its movement are ONE piece" is still
   true, and chart_movement_display_test still pins the direct-child
   relationship -- but it generates no box, so the rank takes line one and
   the chip line two of the row's own grid. That is a stronger form of D1's
   claim than a wrapper could make: the chip is under the rank AND level
   with the hype step it is a pair with. */
.chart-entry__standing {
  display: contents;
}

.chart-entry__standing > .chart-entry__rank {
  grid-row: 2;
}

/* Right-aligned with the rank above it, so track 1 has ONE edge rather than
   a centred number over a centred chip that agree only when they happen to
   be the same width. */
.chart-entry__move {
  grid-column: 1;
  grid-row: 3;
  justify-self: end;
  display: inline-flex;
  align-items: center;
  gap: 0.08rem;
  font-size: var(--text-sm);
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  /* The track is a fixed 2.6rem (2.4 below 480), so a long word here wraps to
     two lines and makes that ONE row taller than every other -- the "same
     height by construction" this layout is built on. Overflowing into the
     0.9rem column gap is the lesser failure and the one a reader cannot see.
     The locale cap in i18n_chart_movement_parity_test is the other half. */
  white-space: nowrap;
  color: var(--text-muted);
}

.chart-entry__move .icon {
  width: 0.85em;
  height: 0.85em;
}

/* docs/charts/chart-movement.md D3: the movement scale, green up and red
   down -- the convention every reader of a ranked list already has. Flat
   and new keep the muted default above, so colour is spent only where there
   is a direction to report.

   THIS PAIR MUST STAY BELOW .chart-entry__move AND AT ONE CLASS. Both are
   (0,1,0), so source order alone decides: moved above it, or written with
   one class fewer than whatever sets the muted default, every figure
   renders grey and nothing anywhere reports it. Measured the hard way
   against a mock-up where a two-class default silently ate a one-class
   colour. The same applies to .chart-metric__change--up further down. */
.chart-move--up {
  color: var(--move-up);
}

.chart-move--down {
  color: var(--move-down);
}

/* A debut has no figure to align, so it reads as a word rather than a
   number and gets the row's smallest step to stay inside track 1. It keeps
   the family's weight -- uppercase and the smaller step are already two
   cues, and a third would make the one row that did NOT move the loudest
   thing in the column. */
.chart-move--new {
  font-size: var(--text-2xs);
  text-transform: uppercase;
  letter-spacing: 0.02em;
}

/* docs/charts/two-line-row.md T9: "we cannot compare this row", on a page
   where the rows around it ARE compared. The quietest thing in the column
   by design -- it is the absence of a fact, not a fact -- so it takes the
   smallest step and the lightest weight the scale has, and no colour of its
   own beyond the muted default above.

   FADED BY SIZE, NEVER BY `opacity`. An opacity on --text-muted composites
   to something palette_contrast_test cannot see (it reads declared tokens,
   not rendered pixels), so a legible token would ship illegible with the
   guard green. Same trap as the nested tint. Weight carries nothing here:
   the whole movement family sits at 500, so the smaller step is the only
   thing separating this from a figure. */
.chart-move--none {
  font-size: var(--text-2xs);
  letter-spacing: 0.02em;
}

.chart-entry__cover-link {
  grid-column: 2;
  grid-row: 1 / -1;
  align-self: center;
  line-height: 0;
}

.chart-entry__cover {
  width: 44px;
  height: 59px;
  object-fit: cover;
  border-radius: var(--radius-xs);
  background: var(--surface-overlay);
}

.chart-entry__cover--placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--text-2xl);
  font-weight: 700;
  color: var(--text-muted);
}

/* The body generates no box either: its lines are the row's own grid rows,
   which is what keeps the name level with the score and the meta line level
   with the hype step. */
.chart-entry__body {
  display: contents;
}

/* Line one. Ellipsised at EVERY width now, not only below 480: a title that
   wrapped would make row one taller on that row alone and the two number
   lines would stop being lines. The measure pays for it -- list-legibility
   M4's 56rem leaves the name 591px here, against the 321px the five-column
   layout left it, so far fewer titles reach the ellipsis than did. */
.chart-entry__name {
  grid-column: 3;
  grid-row: 2;
  min-width: 0;
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Line two of a chart row, and the reason the platform column is gone. */
.chart-entry__line {
  grid-column: 3;
  grid-row: 3;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  min-width: 0;
}

/* /reception and /attention build no second body line -- their date, or
   their phase note, IS line two. Matched with `>` so a chart row's own
   .chart-entry__meta, nested inside .chart-entry__line above, is not placed
   a second time. */
.chart-entry__body > .chart-entry__date,
.chart-entry__body > .chart-entry__meta {
  grid-column: 3;
  grid-row: 3;
}

/* One line, ellipsised, wherever line two's text lives. Blockification does
   half the work: .chart-entry__meta is a plain inline span and `overflow`
   does not apply to a non-atomic inline box, but as a flex item (of the
   line) or a grid item (of the row) it is blockified and clips. */
.chart-entry__line > .chart-entry__date,
.chart-entry__line > .chart-entry__meta,
.chart-entry__body > .chart-entry__date,
.chart-entry__body > .chart-entry__meta {
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* docs/charts/two-line-row.md T3b: the badges get a track of their own again,
   spanning both lines and centred on the row.

   T3 put them on line two's trailing edge to reclaim the 8.5rem column, and
   the width argument still holds -- but it tied them to the DATE's baseline,
   and a badge is a property of the row, not of its second line. With the
   cover and the sparkline either side of them both centred on the row, the
   one cluster hanging off a text baseline is the thing the eye catches.

   8.2rem, not 8.5: the badges became true circles of a known 2.2em, so the
   widest cluster this can render is now arithmetic rather than a measurement
   -- three circles at 25.3px, the "+N" chip at ~35px, three 0.4rem gaps,
   129.9px. It buys back the scannable LEFT edge E2 wanted, which the
   trailing-edge version could not give at all. */
.chart-entry__platforms {
  grid-column: 4;
  grid-row: 1 / -1;
  align-self: center;
  display: flex;
  align-items: center;
}

/* Its own track, spanning both lines. Emitted on every row of a sparkline
   kind including rows with too little history to draw, so an empty one
   still holds the track open.

   The track is 6rem, up from the 4.5rem it carried as a flex column. The
   series draws the same number of points into whatever box it is given, so
   the track's width is the only thing deciding how much SHAPE a month of
   movement has rather than slope. It comes out of the title's 1fr and
   nothing else -- 447px to 423px at the 896px measure -- which is the trade
   to re-measure if titles start truncating. */
.chart-entry__spark {
  grid-column: -3;
  grid-row: 1 / -1;
  align-self: center;
  display: flex;
  align-items: center;
}

.chart-entry__spark .hype-spark {
  width: 100%;
}

/* Shorter than on the game card: a chart row is 59px of cover art tall, and
   a 2rem line inside it crowds the padding. */
.chart-entry__spark .hype-spark__svg {
  height: 1.5rem;
}

.chart-entry__trail {
  display: contents;
}

.chart-entry__metric {
  display: contents;
}

.chart-entry__metric > .chart-metric {
  grid-column: -2;
  grid-row: 2;
  justify-self: end;
}

/* docs/charts/chart-movement.md D2, re-sited: the hype step is no longer
   beside the pill in a fixed-width slot but directly UNDER it, level with
   the rank movement at the other end of the row. It is still emitted on
   every row of a movement kind, empty included -- a cell that appeared and
   vanished would change the row's HEIGHT, which is D2's defect one axis
   over -- and min-height is what holds the empty one open. */
.chart-metric__change {
  grid-column: -2;
  grid-row: 3;
  justify-self: end;
  min-height: 1rem;
  font-size: var(--text-sm);
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
}

/* Same one-class specificity and the same source-order dependency the
   .chart-move--up pair above documents. */
.chart-metric__change--up {
  color: var(--move-up);
}

.chart-metric__change--down {
  color: var(--move-down);
}

/* T9's other half, and it MUST stay below .chart-metric__change and at one
   class -- the same source-order dependency the --up pair above documents.
   Filed with .chart-move--none by topic instead, it lost its font-size to
   .chart-metric__change's --text-sm and the metric-side N/A rendered at
   exactly a figure's size, which is the one thing separating it from one. */
.chart-metric__change--none {
  font-size: var(--text-2xs);
  letter-spacing: 0.02em;
}

/* /reception and /attention put two bare pills in the last track, stacked
   -- which is exactly the layout docs/open-questions/known-gaps.md recorded
   as the real fix for a long title wrapping a verdicts row ("break before
   the pills, needs a wrapper element"). The wrapper is .chart-entry__trail
   and the break is a grid row. */
.chart-entry__trail > .chart-metric:first-child {
  grid-column: -2;
  grid-row: 2;
  justify-self: end;
}

.chart-entry__trail > .chart-metric:last-child {
  grid-column: -2;
  grid-row: 3;
  justify-self: end;
}

.chart-entry__date {
  font-size: var(--text-md);
  color: var(--text-muted);
}

/* docs/phases/port-legibility.md C1: the ports row's platform text, beside
   .chart-entry__date inside the load-bearing .chart-entry__meta wrapper --
   same size, same ink, so the separator between them joins two facts into
   one line. The wrapper is what keeps them on one line at all: a bare
   sibling span and a bare separator text node would each take a line. */
.chart-entry__ports {
  font-size: var(--text-md);
  color: var(--text-muted);
}

/* docs/signals/series-pedigree/00-contract.md C4: the awaited row's
   prior-entry-count and hypemeter halves, beside .chart-entry__date on the
   same .chart-entry__meta line, for the same reason and with the same ink. */
.chart-entry__series-wait,
.chart-entry__series-hype {
  font-size: var(--text-md);
  color: var(--text-muted);
}

/* The platforms in WORDS -- the row's THIRD line, below 480px only, where
   the glyphs are off and a 17px silhouette is a guessing game at arm's
   length. Hidden by default rather than rendered conditionally, because
   "which platforms" is one fact with two presentations and a server-side
   branch on viewport width is a guess this app has no way to make. */
.chart-entry__platform-text {
  display: none;
  grid-column: 3;
  grid-row: 4;
  min-width: 0;
  font-size: var(--text-md);
  color: var(--text-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The row's text lines carry the app's default 1.45 leading, which is prose
   spacing on what is really a table: at --text-2xl that is 23.2px of line box
   around 16px of type, and the half-leading above and below EACH line is what
   a reader sees as the gap between the title and the date -- not row-gap,
   which is 2.4px and was never the thing to tune. 1.25 takes ~6px out of that
   gap without moving either line's own size.

   Top-level, not inside the 480 block where it started: a three-line body
   needing to fit the cover's height was the first place it mattered, but the
   leading was prose spacing at every width.

   Do not reach for row-gap to tighten this further. The two lines are 2.4px
   apart already; everything else a reader reads as space is leading. */
.chart-entry__name,
.chart-entry__date,
.chart-entry__meta,
.chart-entry__platform-text {
  line-height: 1.25;
}

/* the metric pill. Tier modifiers are the class names hype_level and
   critic_tier already produce, so a score is never labelled one tier on a
   game page and another here. */
.chart-metric {
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.02em;
  padding: 0.2rem 0.55rem;
  border-radius: var(--radius-pill);
  white-space: nowrap;
}

/* A ranked row states its measure once. docs/hypemeter/hype-recalibration.md R4 re-pinned
   the tier bands to the measured distribution, and the consequence lands
   hardest here: the anticipated chart's top 25 ARE the catalogue's extreme
   set (34 games clear the 48 threshold), so the word repeated down every row
   said the same thing 25 times. Rank already conveys standing on a chart --
   the tier word earns its place on a game page and a calendar cell, where
   nothing else does.

   HIDDEN, NOT REMOVED, and that is load-bearing twice over. The tier stays a
   separate element because T4 below needs the split to render the figure
   alone at size; and clipping rather than display:none keeps a screen reader
   hearing "High 73" instead of a bare number that reads as a score it is not
   -- the distinction chart_metric_split_test.rb pins. The tier COLOUR is
   untouched: it is doing the work the word was. */
.chart-metric--scored .chart-metric__tier {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  clip-path: inset(50%);
  overflow: hidden;
  white-space: nowrap;
}

/* Hype tiers and critic tiers do not share one rule just because they share a
   colour: they draw from different token families. The four hype tiers are NOT a
   coincidence of that kind, though: a chart metric and a card chip are the
   same tier of the same number on two surfaces, so they share one rule with
   .game-card__hype.hype--* rather than two rules that have to be kept in
   step. critic--* keeps its own rules below -- "a mediocre
   score" and "a delayed release" are two meanings that merely happen to be
   amber, and merging those would move both the next time either is tuned.
   palette_semantics_test.rb asserts the SHARED-RULE shape for hype, because
   once they are one rule the old ink-comparison could never fail. */

/* critic--* takes the reception tokens, and each ink here must equal the ink
   the SAME tier resolves to on /games/:slug (.reception__score.critic--X
   .reception__badge above) -- a score must never be coloured one way on the
   game page and another on /charts. */
.chart-metric.critic--weak {
  color: var(--text-muted);
}

.chart-metric.critic--fair {
  background: color-mix(in srgb, var(--warn) var(--tint-strong), transparent);
  color: var(--warn);
}

.chart-metric.critic--strong {
  background: color-mix(in srgb, var(--good) var(--tint-strong), transparent);
  color: var(--good);
}

.chart-metric.critic--mighty {
  background: color-mix(in srgb, var(--good) var(--tint-strong), transparent);
  color: var(--good-strong);
  /* In the light theme, --good (#095231) and --good-strong (#063d24)
     compress to 1.33:1 apart, so the two "good" tiers are visually identical except
     for ink shade. The border (not the colour) distinguishes Mighty from Strong. */
  border: 1px solid var(--good-strong);
}

/* A riser is good news. */
.chart-metric--delta {
  background: color-mix(in srgb, var(--good) var(--tint-strong), transparent);
  color: var(--good);
}

/* Series pedigree ranks the `awaited` chart, and it is NOT the hypemeter, so
   it deliberately does NOT borrow hype_level's warm tier colours -- that would
   imply the two scales are comparable when the same row shows both. --accent
   is the site's own-quantity colour and is one of the governed ink tokens, so
   both the contrast guard and stylesheet_dedupe's C4 tint gate see this chip;
   an ungoverned token satisfies one parser and not the other, and the chip
   silently stops being contrast-checked. */
.chart-metric--pedigree {
  background: color-mix(in srgb, var(--pedigree) var(--tint-soft), transparent);
  color: var(--pedigree-ink);
}

/* docs/charts/two-line-row.md T4: the scored metric stops being a pill at
   EVERY width, not only below 480 where it already did. It is one of the
   row's two line-one numbers now, and answering a bare rank with a lozenge
   reads as a badge rather than a figure. No fill, no padding, no border; the
   tier COLOUR stays, which is what still reads the figure as high or low
   without a box around it.

   TWO THINGS DECIDE THIS RULE AND BOTH ARE EASY TO GET WRONG.

   Doubled class, not a single one: every tier rule is `.chart-metric.X` at
   (0,2,0), so a (0,1,0) selector here loses to them and the fill stays.

   And it must sit AFTER all of them, because at (0,2,0) each way the cascade
   falls to source order. Placed above the critic tiers it lost to
   `.chart-metric.critic--mighty`'s border and tint, and every 100 on
   /charts/reviewed rendered as a green ring around a 1.5rem figure -- while
   the hype tiers, which are declared far earlier in the sheet, looked
   perfect. Found by screenshot.

   Those tier rules must go on declaring a fill nobody sees here:
   palette_semantics_test requires the four hype tiers to be ONE comma-joined
   rule shared with .game-card__hype, where the fill is live. Do not "clean
   up" the background there. */
.chart-metric.chart-metric--scored {
  padding: 0;
  border: 0;
  background: none;
  font-size: var(--text-5xl);
  /* The row's other line-one figure, held to the rank's box for the same
     reason -- also digits only, so also safe at 1. */
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

/* The line goes first when width runs short. It is the only column on the row
   carrying no number and no name, and at 640 it is already stealing width from
   the title. 640 is one of the app's four breakpoints, and a second @media
   block for it here is deliberate: this sheet is organised by family, and a
   charts rule filed inside the calendar's 640 block is a rule nobody editing
   charts would find. The line stays on the game page at every width. */
@media (max-width: 640px) {
  .chart-entry__spark {
    display: none;
  }

  /* BOTH trailing tracks go, not just the sparkline's. Keeping the 8.2rem
     badge track here was T3's own trap one breakpoint down: measured across
     this band the name track fell to 220px at 640, 141px at 560 and 62px at
     481, against a badge track holding its fixed 131px throughout -- every
     title AND every date clipped at 481, which is worse than the same row
     gets at 390. A fixed track beside a 1fr one does not shrink; the 1fr
     does, all of it.

     So the glyphs-to-words swap belongs here rather than at 480: this block
     matches every width below it, so 390 inherits the swap and the whole
     481-640 band becomes the three-line row the five-row grid already
     supports (row 4 exists at every width). */
  .chart-entry--ranked,
  .chart-entry--ranked:has(.chart-entry__spark) {
    grid-template-columns: 2.6rem auto minmax(0, 1fr) minmax(3.6rem, max-content);
  }

  /* THE PLATFORM GLYPHS BECOME WORDS. Same cap, same order, same helper --
     see charts/_entry.html.erb.

     One class, matching the base rule's own one class, and later in the sheet
     -- which is the ONLY reason it wins, because a media query adds no
     specificity of its own. A stale selector here does not error, it just
     stops matching: the glyphs come back, the row says its platforms twice --
     once in silhouette, once in words on line three -- and nothing reports
     it. */
  .chart-entry__platforms {
    display: none;
  }

  .chart-entry__platform-text {
    display: block;
  }
}

/* docs/charts/two-line-row.md T5: a THIRD line opens for the platforms, in
   words. The two NUMBER lines are untouched -- rank over move at the left,
   score over step at the right, exactly as they are at 896 -- so this width
   is the same row with one more line of text, not a second layout that has
   to be kept in step with the first. That is the whole point of the grid:
   the previous design swapped between a wrapping flex row and a four-track
   fixed-height grid, and the two had to be reasoned about separately.

   The fixed 5rem height is gone with it. Three rows, each clamped to one
   line, make every row the same height BY CONSTRUCTION, which is what that
   height was approximating.

   The cover comes back at 56x75 -- bigger than the body needs (three text
   lines are ~61px) on purpose. At 48px two dark fantasy covers are the same
   picture, and cover art is most of what a reader recognises a game by on a
   phone. */
@media (max-width: 480px) {
  .chart-entry {
    grid-template-columns: 2.4rem auto minmax(0, 1fr) minmax(5.5rem, max-content);
    column-gap: 0.6rem;
    row-gap: 0.08rem;
    padding: 0.55rem 0.75rem;
  }

  .chart-entry--ranked,
  .chart-entry--ranked:has(.chart-entry__spark) {
    grid-template-columns: 2.4rem auto minmax(0, 1fr) minmax(3.4rem, max-content);
  }

  .chart-entry__cover {
    width: 56px;
    height: 75px;
  }

  /* Line two becomes ONE INLINE FLOW here, so the line's own ellipsis is the
     only one and it eats the END of the line rather than the start of a
     label. As a flex row the phase tag was a shrinkable item and truncated
     ITSELF: "EARLY ACCESS May 14, 2026 1.0 TBA" rendered as "E May 14, 2026
     1.0 TB". `display: inline` on the meta un-flexes it too -- overflow does
     not apply to a non-atomic inline box, so the tag stops clipping and the
     tail is what goes. This is chart-row-platforms E4a's behaviour restored,
     one wrapper deeper than it used to live. */
  .chart-entry__line {
    display: block;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .chart-entry__line > .chart-entry__meta {
    display: inline;
  }

  /* The tag is an inline box in a line of text now, not a flex item with a
     gap, so its spacing has to come from a margin. */
  .chart-entry__phase-tag {
    margin-inline-end: 0.2rem;
  }

  /* THE TWO-PILL ROWS BREAK BEFORE THEIR PILLS HERE. A chart row's metric is
     a figure and fits a 3.4rem track; /reception and /attention carry two
     WORDED pills instead -- "Mighty", "Demand 70", and sv's "Efterfragan 89"
     at ~95px -- and stacked in the last track they squeezed the name to
     "Beyond Goo..." and "Fields of Mi...". Measured by screenshot at 390.

     So below 480 those two surfaces put the pills on a line of their own
     under the body and give the whole width back to the name. That is
     literally the fix docs/open-questions/known-gaps.md asked for when it
     recorded this as unfixed -- "break before the pills, needs a wrapper
     element" -- and the wrapper is .chart-entry__trail, which stops being
     `display: contents` and becomes the single item holding them (the pills'
     own placement rules go inert once they are flex items).

     Scoped by :not(--ranked) rather than by naming both surfaces: the rule
     is not "attention and verdicts", it is "a row whose trail holds pills
     instead of a metric", and that is exactly the set the modifier splits. */
  .chart-entry:not(.chart-entry--ranked) {
    grid-template-columns: 2.4rem auto minmax(0, 1fr);
  }

  .chart-entry:not(.chart-entry--ranked) .chart-entry__trail {
    display: flex;
    grid-column: 3;
    grid-row: 4;
    align-items: center;
    gap: 0.4rem;
  }
}

/* ---------- Verdicts ---------- */

/* Reuses .chart-list/.chart-entry/.chart-metric wholesale; a verdicts row
   differs only by carrying two metric pills instead of one, and this row
   accent -- same meaning as the per-game verdict line
   (.reception__verdict.verdict--over/--under): an inset shadow rather than a
   border so the row keeps its existing padding. */
.verdicts-list {
  margin-bottom: 2rem;
}

.chart-entry.verdict--over {
  box-shadow: inset 3px 0 0 var(--good);
}

.chart-entry.verdict--under {
  box-shadow: inset 3px 0 0 var(--danger);
}

/* ---------- Attention quadrant grid (/attention) ----------
   docs/site/attention-surface.md S3: a compact four-cell summary,
   lists of names rather than a scatterplot. Two columns on desktop -- the
   natural shape of "demand high/low" against "campaign high/low" -- one on
   narrow layouts, same idiom as .reception__scores. */
.quadrant-grid-section {
  margin-bottom: 2rem;
}

.quadrant-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1rem;
}

.quadrant-cell {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  min-width: 0;
  padding: 0.85rem 1rem;
  border: 1px solid var(--border);
}

.quadrant-cell__heading {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.5rem;
  margin: 0;
  font-size: var(--text-md);
}

.quadrant-cell__label {
  font-weight: 700;
}

.quadrant-cell__count {
  color: var(--text-muted);
  font-size: var(--text-sm);
}

.quadrant-cell__list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

/* ---------- Showcases (/showcases, /showcases/:slug) ---------- */

.showcase-list {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  list-style: none;
  margin: 1.25rem 0 2rem;
  padding: 0;
}

.showcase-list__entry {
  border-bottom: 1px solid var(--border);
}

.showcase-list__link {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  padding: 0.75rem 0;
  color: inherit;
}

.showcase-list__logo {
  flex: 0 0 auto;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: var(--radius-sm);
  object-fit: cover;
}

.showcase-list__name {
  flex: 1 1 auto;
  font-weight: 600;
  min-width: 0;
}

.showcase-list__date {
  flex: 0 0 auto;
  color: var(--text-muted);
  font-size: var(--text-sm);
}

.showcase-header {
  display: flex;
  align-items: flex-start;
  gap: 1.1rem;
  margin-top: 1.25rem;
}

/* The logo's top aligns with the title's: the h1's own margin would open a
   gap the body's flex gap already provides, so the gap governs alone. */
.showcase-header__body h1 {
  margin: 0;
}

.showcase-header__logo {
  flex: 0 0 auto;
  width: 4.5rem;
  height: 4.5rem;
  border-radius: var(--radius);
  object-fit: cover;
}

.showcase-header__body {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  min-width: 0;
}

.showcase-header__dates {
  margin: 0;
  color: var(--text-muted);
}

.showcase-header__description {
  margin: 1rem 0;
  max-width: 640px;
  line-height: 1.55;
}

.showcase-header__roster {
  display: flex;
  gap: 0.75rem;
  flex-wrap: wrap;
  color: var(--text-muted);
  font-size: var(--text-sm);
}

/* docs/signals/showcases.md U9: the live badge + "Live now" line, sat
   beside the dateline the same way the roster line sits below it. */
.showcase-header__live {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0;
}

/* docs/signals/showcases.md U10: margin only -- the facade and its
   injected iframe come free from .game-trailer (~L3125), 16:9 at 100% of
   this wrapper, and the wrapper spans the full content column: it is a
   theater, not a thumbnail. */
.showcase-theater {
  margin: 1.25rem 0 0;
}

/* docs/signals/showcases.md U8: the on-air-and-upcoming block, above the
   past .showcase-list on page 1 only. Same row shape as .showcase-list,
   plus room for the live badge and an optional watch link. */
.showcase-upcoming {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  list-style: none;
  margin: 1.25rem 0 2rem;
  padding: 0;
}

.showcase-upcoming__entry {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.75rem;
  border-bottom: 1px solid var(--border);
  padding: 0.75rem 0;
}

/* docs/signals/showcases.md U8 (amended 2026-09-02): a grid, not a wrapping
   flex row. Logo | name | start instant | relative phrase or live badge on
   one line, with the name as the only column that gives -- it wraps inside
   itself the way .showcase-list__name does, so the time never jumps to a
   fresh line at the left edge under the logo, which is what the flex wrap
   did whenever a name ran long. The logo and name are pinned to columns 1
   and 2; the rest flow into implicit content-sized columns, so a row without
   a phrase or badge leaves no empty gapped column behind the time. */
.showcase-upcoming__link {
  display: grid;
  grid-template-columns: auto 1fr;
  grid-auto-flow: column;
  grid-auto-columns: auto;
  align-items: center;
  column-gap: 0.85rem;
  flex: 1 1 auto;
  min-width: 0;
  color: inherit;
}

.showcase-upcoming__logo {
  grid-column: 1;
  width: 2.5rem;
  height: 2.5rem;
  border-radius: var(--radius-sm);
  object-fit: cover;
}

.showcase-upcoming__name {
  grid-column: 2;
  font-weight: 600;
  min-width: 0;
}

.showcase-upcoming__time {
  color: var(--text-muted);
  font-size: var(--text-sm);
}

.showcase-upcoming__relative {
  color: var(--text-muted);
  font-size: var(--text-sm);
}

/* No phone line holds a name, "September 3, 2026 at 9:00 PM GMT+2" and
   "starts in about 23 hours", so below the widest house breakpoint the row
   stacks on purpose instead of by accident: the logo spans the first two
   lines, and the name, the instant and the phrase (or badge) take a line
   each, every one aligned under the name. Two lines on the front door, three
   on /showcases -- the same for every row, whatever the name's length. */
@media (max-width: 640px) {
  .showcase-upcoming__link {
    grid-auto-flow: row;
    row-gap: 0.15rem;
  }

  .showcase-upcoming__logo {
    grid-row: 1 / span 2;
  }

  .showcase-upcoming__time,
  .showcase-upcoming__relative,
  .showcase-upcoming__link > .badge {
    grid-column: 2;
  }

  /* A grid item is blockified: without this the pill would stretch to the
     column's width. */
  .showcase-upcoming__link > .badge {
    justify-self: start;
  }
}

.showcase-header__relative {
  color: var(--text-muted);
  font-size: var(--text-sm);
}

/* docs/signals/showcases.md U14: the front-door spotlight reuses the
   .showcase-upcoming row rules above; only the trailing link is its own. */
.showcase-spotlight__more {
  margin-top: 0.5rem;
}

/* docs/signals/showcases.md U10 fourth batch: the reserved stage on an
   upcoming page with no stream URL yet. Visibly inert -- a promise, not a
   control -- so no shadow, a dashed border, muted ink. */
.showcase-theater__placeholder {
  aspect-ratio: 16 / 9;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  border: 1px dashed var(--border);
  border-radius: var(--radius);
  color: var(--text-muted);
  background: var(--surface-raised);
}

/* tabular-nums: the ticking digits must not jitter in width. */
.showcase-theater__countdown {
  font-size: var(--text-5xl);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}

.showcase-upcoming__watch {
  flex: 0 0 auto;
}

/* ---------- Admin ---------- */

/* Shared sub-nav: a horizontal row
   of tab-style links across the top of every admin page, the current section
   picked out via aria-current="page" (also the CSS hook, so the active state
   never drifts from the a11y attribute). */
/* Secondary nav bar: a second row inside .site-header on admin pages -- and,
   sharing the exact same treatment, on /charts pages (.charts-bar/.charts-nav,
   app/views/charts/_nav) and on /calendar pages (.calendar-bar/
   .calendar-scopes, app/views/calendar/_nav) --
   so it sticks with the main nav rather than
   scrolling away with the page. The inner nav repeats .container's width and
   centering (it can't be a child of one -- the bar's tint has to run edge to
   edge) and trims its own gutter by the links' padding, so the first link's
   text lines up with the brand above it and the h1 below it. */
.admin-bar,
.charts-bar,
.calendar-bar {
  border-top: 1px solid var(--border);
  background: color-mix(in srgb, var(--surface-overlay) 55%, transparent);
}

.admin-nav,
.charts-nav,
.calendar-scopes {
  width: 100%;
  max-width: 1500px;
  margin-inline: auto;
  padding-inline: calc(1.25rem - 0.7rem);
  display: flex;
  align-items: stretch;
  gap: 0.15rem;
  /* One row always: below ~900px there isn't room for eight sections, and a
     wrapped second row inside a sticky header would push the page content
     down as it grew. Scroll it instead. */
  overflow-x: auto;
  scrollbar-width: thin;
}

/* the link shape (underline, hover, aria-current) comes from .tabs.
   What stays here is what a bar tab does differently: it fills the bar's full
   height, so its underline sits on the bar's own bottom edge. */
.admin-nav a,
.charts-nav a,
.calendar-scopes a {
  display: inline-flex;
  align-items: center;
  padding: 0.55rem 0.7rem;
}

.comparison-tabs {
  display: flex;
  gap: 1rem;
  margin-bottom: 1.25rem;
}

.comparison-tabs a {
  padding-block: 0.35rem;
}

/* ---------- Mobile ---------- */

@media (max-width: 480px) {
  .container {
    padding-inline: 1rem;
  }

  /* The gutter this row bleeds through is 1rem here, not 1.25rem (.container
     above). Same rule as at 560, re-declared for the narrower gutter -- get
     this wrong by 0.25rem in either direction and the bar either stops short
     of the brand or gives the page a horizontal scrollbar of its own. */
  .site-nav__links {
    margin-inline: -1rem;
    padding-inline: 1rem;
  }

  /* Same correction, same reason, for the card rails: .container's gutter is
     1rem here, so the 1.25rem pair the 900 block sets would leave the rail's
     border box 8px wider than the viewport, hanging 4px off each side. The
     cards would still LOOK aligned -- the two errors cancel -- and only
     html{overflow-x:hidden} would hide the rest. What it costs is the fade:
     the mask's 100% resolves to the over-wide box, so the trailing gradient
     never reaches transparent at the screen edge. */
  .card-grid--rail {
    margin-inline: -1rem;
    padding-inline: 1rem;
  }

  /* One column: two score cards side by side leave neither enough room for a
     5xl badge and its label on one line. */
  .reception__scores {
    grid-template-columns: 1fr;
  }

  /* Same reflow for the quadrant grid -- four cells in two columns crowd a
     narrow layout the same way two score cards do. */
  .quadrant-grid {
    grid-template-columns: 1fr;
  }
}

/* ---------- Breadcrumbs ---------- */

/* The trail above a page inside a hierarchy (shared/_breadcrumbs). Muted and
   small on purpose: it is orientation, not content, and must never compete
   with the h1 or the game hero directly below it. */
.breadcrumbs {
  margin-block-end: 0.9rem;
}

.breadcrumbs__list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.35rem;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--text-md);
  color: var(--text-muted);
}

/* The separator is generated, not a text node in the markup: a screen reader
   then reads the labels rather than a run of chevrons, and the character can
   change without touching ten locale files. aria-hidden because ::before
   content IS announced by some engines. */
.breadcrumbs__item + .breadcrumbs__item::before {
  content: "/";
  margin-inline-end: 0.35rem;
  color: var(--border);
}

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

.breadcrumbs__link:hover {
  color: var(--text);
}

/* The last crumb is where you already are, so it is the one that reads as
   text: no underline, no hover, and it carries aria-current="page". */
.breadcrumbs__current {
  color: var(--text);
  font-weight: 600;
}

/* ---------- Utilities ---------- */

/* Visually hidden but available to screen readers / SEO (e.g. a page h1 that
   the design conveys with section headings). */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  overflow: hidden;
  white-space: nowrap;
}

/* ---------- Icons (icon_helper.rb inline SVGs) ---------- */

.icon {
  flex: none;
  vertical-align: middle;
}

/* ---------- Reduced motion ---------- */

/* The whole of the app's motion, honoured in one place. There are exactly two
   animated declarations in this sheet and none in admin.css, so this is a
   complete list rather than a blanket `* { transition: none }` -- which would
   also be a lie the moment someone adds motion inside a component that needs
   its transition kept.
   Both are decorative: the drawer chevron and the hypemeter fill both arrive
   at the same final state instantly, so nothing is lost but the travel.
   LAST in the file on purpose -- these selectors match the originals exactly,
   so the override rests on source order, and admin.css (which loads after this
   file) declares no motion to escape it.
   New motion ships with its entry here; that is the rule this block replaces
   the old "the app does not handle it yet" note with. */
@media (prefers-reduced-motion: reduce) {
  .filter-drawer > summary::before,
  .chart-platform-menu > summary::before,
  .chart-genre-menu > summary::before,
  .calendar-platform-menu > summary::before {
    transition: none;
  }

  .hypemeter__fill {
    transition: none;
  }

  /* docs/signals/showcases.md U15: the dot stays, only the pulse stops. */
  .nav-live::before {
    animation: none;
  }
}
