/*
 * 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);

  /* Third-party brand marks (same category as the --pf-* tints above): a
     colour that IDENTIFIES a thing, never one that encodes a number. This is
     the RSS format's own orange, for the feed glyph on the game hero. The
     "a warm colour always means data" still holds for every --hype-* scale --
     , which names this exception. Light half is
     deepened for >=3:1 against --surface-raised; the dark half is re-declared
     inside .game-hero below, where the button actually lives. */
  --rss:             light-dark(#c2410c, #f97316);

  /* 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);

  /* 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 .admin-chip--live, .probe-report--ok and
     .probe-report--warning 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-shots,
.game-hype-card,
.reception,
.release-history,
.admin-pipeline__node,
.admin-review-row,
.admin-suggestion-row,
.admin-menu,
.admin-modal__panel,
.stage-card,
.trace-stage,
.entry,
.pillar,
.calendar-grid,
.calendar-window__item,
.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-shots,
.game-hype-card,
.reception,
.release-history,
.admin-pipeline__node,
.admin-review-row,
.admin-suggestion-row,
.admin-menu,
.admin-modal__panel,
.stage-card,
.trace-stage {
  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,
details.admin-explainer,
.trace-verdict,
details.admin-json > pre {
  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 (extracted_via and trace knob
   chips), 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,
.badge--review,
.badge--neutral,
.game-card__hype.hype--unscored,
.reception__verdict.verdict--met,
.chart-metric,
.admin-status--never,
.admin-pipeline__cadence,
/* 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,
.stage-card__via {
  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 */
}

.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;
}

.site-nav a[aria-current="page"] {
  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;
}

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

   Shared by the theme menu (.nav-theme) and the Language menu (.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;
}

@media (max-width: 560px) {
  /* Icon-only nav below the compact breakpoint -- text stays in the DOM
     (screen readers + aria-label/title still name the link) but is visually
     hidden so the header doesn't wrap awkwardly at narrow widths.
     The theme menu's label is listed explicitly: it is a <summary> child, not a
     direct <a> child, so `.site-nav > a` does not reach it and it would be the
     one label still taking horizontal space on a phone. */
  .site-nav > a .nav-label,
  .site-nav .nav-theme .nav-label,
  .site-nav .nav-locale .nav-label {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    border: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

/* ---------- 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;
}

/* ---------- Theme menu (details.nav-theme, shared/_theme_menu) ----------

   The header switch borrows details.nav-menu wholesale -- same trigger, same
   absolutely-positioned menu, same dropdown controller -- so only the two
   things that genuinely differ live here.

   .nav-theme is rendered for EVERY visitor, admin or anonymous -- nothing
   below depends on authentication (there is no other kind of visitor left). */

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

/* The trigger glyph already names the active theme, but the open menu must say
   so too -- otherwise the only feedback for "which one am I on?" is dismissing
   the menu and re-reading the icon. aria-current carries it for assistive tech;
   this is the visible half of the same fact. */
.nav-theme__choice[aria-current="true"] {
  color: var(--accent-strong);
  font-weight: 600;
}

/* ---------- Locale menu (details.nav-locale, shared/_locale_menu) ----------

   Same borrowed .nav-menu chrome and aria-current highlight as the theme menu
   above. Rendered for every visitor, admin or anonymous -- but only on routes
   that carry a :locale segment (contract M6), so not on /admin.

   Unlike .nav-theme this menu has NO form: the choices became plain links when
   the locale moved into the URL path, so the "buttons in a form need
   display: contents" fix the theme menu still needs does not apply here. */
.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(180px, 1fr));
  gap: 1.25rem;
  margin-block: 1.25rem 2rem;
}

/* 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));
}

.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 runs
     19-24px, so the taller case is the reservation. */
  min-height: 1.5rem;
}

.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;
}

.game-card__hype-badge.hype--low {
  background: var(--hype-low);
  color: var(--hype-contrast);
}

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

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

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


/* ---------- 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);
}

/* 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. */
.chip--platform-more {
  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. Square padding keeps the chip a circle-ish badge rather than a
   pill with an empty right half; the name lives in title/aria-label, and the
   game page's hero still renders the full labelled list. */
.chip--platform-icon {
  padding: 0.15rem 0.3rem;
}

/* 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. The chip stays
   inside .game-card__meta's 1.5rem reservation -- 1.45em of the chip's
   0.72rem font is ~17px, plus 0.15rem padding either 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, beta -- the delay-wears-warn idiom, mirrored */
.badge--release,
.badge--beta {
  background: color-mix(in srgb, var(--good) var(--tint-strong), transparent);
  color: var(--good);
}

/* Media: trailer, trailer_million */
.badge--trailer,
.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 */
.badge--steam_listing,
.badge--gog_listing,
.badge--wikipedia_listing,
.badge--platform {
  background: color-mix(in srgb, var(--event-presence) var(--tint-strong), transparent);
  color: var(--event-presence);
}

/* Our own rankings: chart_entry, reviewed_chart_entry */
.badge--chart_entry,
.badge--reviewed_chart_entry {
  background: color-mix(in srgb, var(--accent) var(--tint-strong), transparent);
  color: var(--accent-strong);
}

/* Cautionary: delay, cancellation */
.badge--delay {
  background: color-mix(in srgb, var(--warn) var(--tint-strong), transparent);
  color: var(--warn);
}

.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. */
.entry__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
}

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

/* 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;
}

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

.search-form {
  display: flex;
  gap: 0.6rem;
  margin-block: 1rem 0.5rem;
  flex-wrap: wrap;
}

/* 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. */

.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;
  }
}

/* the filter drawer: platform/genre/status/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). */
.filter-drawer > 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 replaced (not just hidden) with a
   chevron that rotates on open, so the closed/open state is legible too. */
.filter-drawer > summary::-webkit-details-marker {
  display: none;
}

.filter-drawer > summary::marker {
  content: "";
}

.filter-drawer > 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 {
  transform: rotate(45deg) translate(-0.05em, -0.05em);
}

.filter-drawer > 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. */
.intro-band {
  margin-block: 0 2rem;
}

.intro-band__title {
  font-size: var(--text-hero);
  margin: 0 0 0.5rem;
}

.intro-band__lede {
  max-width: 60ch;
  color: var(--text-muted);
  font-size: var(--text-xl);
  margin: 0 0 1.5rem;
}

.intro-band__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-top: 1.5rem;
}

/* 3 -> 2 -> 1 columns on its own via auto-fit, zero new media queries. */
.pillars {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 1.25rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.pillar {
  padding: 1.1rem 1.25rem;
}

.pillar__title {
  font-size: var(--text-2xl);
  margin: 0 0 0.35rem;
}

.pillar__body {
  color: var(--text-muted);
  font-size: var(--text-lg);
  margin: 0 0 0.6rem;
}

.pillar__link {
  color: var(--accent-strong);
  font-weight: 600;
}

.pillars-footnote {
  margin: 0.25rem 0 0;
  color: var(--text-muted);
}

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

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

.news-feed__actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin: 1.25rem 0 0.4rem;
}


/* ---------- 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;
}

/* ---------- 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 RSS button on a game page 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 costs nothing at
   prefers-reduced-motion, which the app does not handle yet and should not be
   quietly acquiring a debt in. */
.btn:active,
.filter-drawer > summary:active {
  filter: brightness(0.94);
}

/* A label wrapping a checkbox or a radio. .admin-review-candidate is already
   on all five radio labels in the review queue and had no layout of its own,
   so those rows sat on the text baseline instead of centred on the control.
   Each keeps its own font-size; only the geometry is shared. */
.checkbox-label,
.admin-review-candidate {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-weight: 400;
}

/* ---------- 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 RSS link on the game page). */
.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;
}

.checkbox-label {
  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;
}


/* ---------- 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 and --accent 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. */
.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;
  --rss: #f97316;
}

/* 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);
}

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

.game-hero__actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-top: 0.4rem;
}

/* The RSS button sits on the dark band -- override its default
   (theme-dependent) colours so it stays legible over the scrim regardless
   of light/dark mode. */
.game-hero__actions .btn--ghost {
  border-color: rgba(255, 255, 255, 0.35);
  color: #fff;
}

.game-hero__actions .btn--ghost:hover {
  background: rgba(255, 255, 255, 0.14);
}

/* the feed glyph carries the RSS format's
   own orange. The icon is stroke="currentColor", so `color` is the whole
   change; the border and label stay white against the scrim (above). */
.game-hero__actions .btn--ghost .icon {
  color: var(--rss);
}


/* External links row -- deliberately
   lighter than the RSS button above: small text links, not a second button.
   Same dark-scrim override rationale as
   .game-hero__actions above: 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;
}

/* ---------- 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 -> hype card -> trailer. */
.game-content {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
  margin-bottom: 2rem;
}

.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;
}

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

/* The game page's panel card. These four 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-shots,
.game-hype-card,
.reception,
.release-history {
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
  padding: 1.25rem 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 both of the app's two
   dialogs want none of that. The admin modal (below) wanted the identical
   eight declarations and had its own copy. What differs -- the backdrop
   treatment, and whether the panel is centred -- stays with each. */
.game-lightbox,
.admin-modal {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  max-width: 100%;
  max-height: 100%;
  margin: 0;
  border: 0;
}

.game-lightbox {
  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;
  font-size: var(--text-5xl);
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
}

.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;
}

/* Panel-card chrome comes from the shared rule above. */
.game-hype-card .hypemeter {
  max-width: none;
  margin-top: 0;
}

/* ---------- 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);
}





/* ---------- Release-date tracker
   A peer of .game-hype-card / .reception, and now says so in CSS rather than
   in this comment: panel-card chrome comes from the shared rule above. ---------- */

.release-history__list {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

.release-history__entry {
  padding-top: 1rem;
  border-top: 1px solid var(--border);
}

.release-history__entry:first-child {
  padding-top: 0;
  border-top: none;
}

.release-history__kind {
  display: inline-block;
  font-size: var(--text-sm);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--text-muted);
}

.release-history__change {
  margin: 0.35rem 0;
  font-size: var(--text-2xl);
  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);
}


/* ---------- 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 ---------- */

.hypemeter {
  margin-top: 1rem;
  max-width: 320px;
}

.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__level {
  color: var(--text-muted);
}

/* The frozen-at-release stamp (docs/hype-lock/ 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;
}

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

/* 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);
}

/* Small anchor from the compact meter down to the live
   breakdown's <details> (plain #fragment jump, no JS). */
.hypemeter__why {
  font-size: var(--text-sm);
  margin-left: auto;
}

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

details.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 inside the hype card -- opening pushes the page down.
   Hidden until [open] by the browser's default details styling. */
.hype-breakdown__rows {
  margin-top: 0.6rem;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.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;
}

.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);
}

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

.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);
}

/* ---------- 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;
}

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

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

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

/* 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 {
  height: 150px; /* every cell identical; busy days scroll within the cell */
  overflow-y: auto;
  padding: 0.4rem;
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

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

.calendar-grid__cell--filler {
  background: var(--surface-overlay);
  color: var(--text-muted);
}

.calendar-grid__daynum {
  font-size: var(--text-sm);
  font-weight: 600;
  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: accent tint + inset ring (no layout shift, keeps cells the
   same size) and a filled pill on the day number. The daynum selector carries
   equal specificity to the Sunday rule and sits after it, so today wins even
   when today falls on a Sunday. */
.calendar-grid__cell--today {
  background: color-mix(in srgb, var(--accent) 12%, var(--surface-raised));
  box-shadow: inset 0 0 0 2px var(--accent);
}

.calendar-grid__cell.calendar-grid__cell--today .calendar-grid__daynum {
  align-self: flex-start; /* don't stretch across the flex-column cell */
  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;
}

/* Peak-hype days: tint the cell by the highest tier releasing that day, so the
   month scans at a glance. Graduated (moderate faint -> extreme strong) to keep
   the common low days quiet. Two-class specificity wins the background over
   --today's tint; today still shows its ring + day-number pill.
   the 7/11/14% ramp is unchanged; only the
   token belongs to the hype family, matching the meter fill. */
.calendar-grid__cell--peak.hype--moderate {
  background: color-mix(in srgb, var(--hype-moderate) 7%, var(--surface-raised));
}

.calendar-grid__cell--peak.hype--high {
  background: color-mix(in srgb, var(--hype-high) 11%, var(--surface-raised));
}

.calendar-grid__cell--peak.hype--extreme {
  background: color-mix(in srgb, var(--hype-extreme) 14%, var(--surface-raised));
}

/* 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. */
.calendar-grid__game {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  min-width: 0;
}

.calendar-grid__cover {
  width: 26px;
  height: 26px;
  flex: 0 0 auto;
  border-radius: var(--radius-xs);
  object-fit: cover;
}

.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. */
.calendar-grid__hype {
  flex: 0 0 auto;
  margin: 0;
  padding: 0.05rem 0.32rem;
  font-size: var(--text-2xs);
}

/* 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: the app has three breakpoints
   and 600 was a fourth that agreed with none of them. A seven-column grid has
   nothing to gain from the extra 40px, so it collapses slightly sooner. */
@media (max-width: 640px) {
  .calendar-grid__weekdays {
    display: none;
  }

  .calendar-grid__week {
    display: block;
  }

  .calendar-grid__cell {
    display: none;
    height: auto;
    overflow-y: visible;
    border-right: none;
    border-bottom: 1px solid var(--border);
  }

  .calendar-grid__cell:has(.calendar-grid__game) {
    display: flex;
  }

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

/* ---------- Calendar precision lists
 ---------- One shared card grid (calendar/_window_games) for all
   three of: the month page's month-precision rail, the quarter page's list
   and the year page's. Same thumb + name + hype-chip idiom as
   .calendar-grid__game, laid out as a card grid rather than day cells
   -- there is no day to file these under. 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: repeat(auto-fill, minmax(200px, 1fr));
  gap: 0.6rem;
}

.calendar-window__item {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
  padding: 0.5rem;
}

.calendar-window__cover {
  width: 32px;
  height: 32px;
  flex: 0 0 auto;
  border-radius: var(--radius-xs);
  object-fit: cover;
}

/* the row is the name alone now:
   every list is single-precision, so the page heading states the period and a
   per-row copy of it said nothing. The __body column and __date rules went
   with the label; the name takes over the grow and the min-width: 0 that lets
   the ellipsis work inside the flex row. */
.calendar-window__name {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--text-sm);
  font-weight: 600;
  color: var(--text);
}

/* Shrinks the reused .game-card__hype pill down to a compact number chip,
   same treatment as .calendar-grid__hype above. */
.calendar-window__hype {
  flex: 0 0 auto;
  margin: 0;
  padding: 0.05rem 0.32rem;
  font-size: var(--text-2xs);
}

/* P1-style narrow treatment, matching the grid's own breakpoint: one tile per
   row instead of the auto-fill grid. Moved with it, so the rail and the grid
   still change shape at the same width. */
@media (max-width: 640px) {
  .calendar-window__list {
    grid-template-columns: 1fr;
  }
}

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

.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 {
  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 row reads as a sub-level of the kind row above it. */
.chart-tabs--periods {
  margin-block: 0.35rem 0.25rem;
  font-size: 0.95em;
}

.chart-tabs--periods 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);
}

/* 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;
}

.chart-entry {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  padding: 0.6rem 0.9rem;
  border-top: 1px solid var(--border);
}

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

.chart-entry__rank {
  flex: 0 0 2.25rem;
  text-align: right;
  font-size: var(--text-3xl);
  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. */
.chart-entry:nth-child(-n + 3) .chart-entry__rank {
  color: var(--accent-strong);
}

.chart-entry__cover-link {
  flex: 0 0 auto;
  line-height: 0;
}

.chart-entry__cover {
  width: 38px;
  height: 51px;
  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);
}

.chart-entry__body {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
}

.chart-entry__name {
  font-weight: 600;
  overflow-wrap: anywhere;
}

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

/* 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 {
  flex: 0 0 auto;
  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;
}

/* 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);
}

@media (max-width: 480px) {
  .chart-entry {
    flex-wrap: wrap;
    gap: 0.5rem 0.75rem;
  }

  .chart-entry__rank {
    flex-basis: 1.75rem;
  }

  .chart-metric {
    margin-left: calc(2.5rem + 38px);
  }
}

/* ---------- 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);
}

/* ---------- 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;
}

/* ---------- Mobile ---------- */

@media (max-width: 480px) {
  .container {
    padding-inline: 1rem;
  }

  .site-header__inner {
    flex-wrap: wrap;
  }

  /* 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;
  }
}

/* ---------- 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;
}
