:root {
  /* NO GRADIENTS OUTSIDE THE WORDMARK (Luke, 22 Sep): "solid colours
     wherever there is gradient". These were the wordmark's sunset as
     tokens for borders and rules; the one that is still used is now a
     solid amber, written as a one-stop linear-gradient only because it
     is layered as a background image (the two-box border technique needs
     an image, not a colour). The wordmark keeps its gradient -- it is the
     logo. */
  --accent-edge: linear-gradient(var(--sun-amber), var(--sun-amber));
  --retro-face: "Righteous", var(--font-display), sans-serif;
  /* THE SUNSET'S COLOURS ONE AT A TIME. The full gradient is a statement
     and wears out when it is on everything (Luke: "it doesn't need to be
     the same exact gradient over and over"). These are its stops, used
     singly for jobs: violet for "you are pointing at this", pink for "you
     are in this", gold for the content that matters. */
  --sun-gold: #f9d976;
  --sun-amber: #eab54a;
  --sun-pink: #e0729a;
  --sun-violet: #9d6fd6;
  --sun-violet-rgb: 157, 111, 214;

  /* THE THEME TOKENS (26 Sep). Luke chose to ship nine themes as a choice
     in the settings menu, Sunset the default; the palettes are the
     html[data-theme] blocks in "THEMES" at the end of this file. Every
     colour that used to be written out as the sunset's -- the two ambers,
     the pink menu, the page tints, the wordmark -- reads one of these now,
     and each theme overrides the set. The values here ARE Sunset.
     tools/theme-tokens-check.mjs checks every theme sets every token. */
  /* The "act" colour: arcade buttons, focus rings, amber tints. */
  --act: #eab54a; --act-rgb: 234, 181, 74;
  --act-light: #f0c05a; --act-deep: #a8792a; --act-ink: #2a1a05;
  /* The tab and accent gold (--gold below) as channels, for tints. */
  --gold-rgb: 245, 192, 79;
  /* "You are in this": the side menu. */
  --menu: #e0729a; --menu-rgb: 224, 114, 154;
  --menu-light: #ea8fb0; --menu-deep: #a24a6c; --menu-ink: #2a0a18;
  /* The page and the docked bar, as channels, for scrims and glass. */
  --bg-rgb: 20, 14, 27; --bar-rgb: 21, 17, 27;
  /* The wordmark: top-to-bottom in the banner, left-to-right on login. */
  --wm-grad-v: linear-gradient(180deg, #f9d976 0%, #eab54a 35%, #e0729a 70%, #9d6fd6 100%);
  --wm-grad-h: linear-gradient(90deg, #f5c04f, #c98af0 55%, #f56bb0);
  /* The primary button's gradient end and its label. */
  --btn-primary-end: #f2c569; --btn-primary-ink: #1c1220;
  /* The banner's two soft glows (and two cards that echo them): a warm
     pink and a cool blue behind the amber. */
  --glow-warm-rgb: 236, 90, 160; --glow-cool-rgb: 109, 139, 255;
  /* DARKER AND LESS PURPLE, 22 Sep. These were #1c1424 / #2b1f38 /
     #362943 / #4a3a5c -- a saturated violet at every level, so the panels
     read as a colour of their own and competed with the neon accents
     (gold, magenta, the platform blues and greens) instead of sitting
     behind them. The hue is kept, faintly, so the page is still warm
     rather than a flat grey; what went is the saturation and about half
     the lightness. (A first pass at #0f0c13 was a tad too dark -- the
     panels stopped reading as surfaces at all.) The accents did not change -- they stand out more
     because the ground under them is quieter.

     FOUR COPIES OF THESE LIVE OUTSIDE THE TOKENS, as rgba() for alpha:
     .spotlight-block, the backdrop fade, the docked nav and the phone
     bottom bar. Plus the doughnut border in app.js. Change these and
     change those, or the fade leaves a seam against the page. */
  --bg: #15111b;
  --bg-panel: #201a28;
  --bg-panel-raised: #2a2233;
  --hairline: #3d3249;
  --text: #faf5ec;
  --text-muted: #c3b2d6;
  --text-faint: #9384a8;

  --gold: #f5c04f;
  --gold-soft: rgba(var(--gold-rgb), 0.2);

  /* The themeable accent.
     Read by surfaces INSIDE the profile panel. --profile-accent is set on
     #view-profile only, so everywhere else this resolves to --gold --
     which is the point: an accent picked for a profile colours the
     profile, not the whole app.
     Deliberately NOT used by: the rating histogram ramp, the chart
     palette, platform badges, or --ok. Colour carries MEANING in those --
     a red-to-green ramp tinted purple stops meaning anything, and a
     platform badge in someone's accent stops identifying the platform. */
  --accent: var(--profile-accent, var(--gold));
  --accent-line: var(--profile-accent-line, rgba(var(--act-rgb), 0.28));
  --accent-fill: var(--profile-accent-fill, rgba(var(--act-rgb), 0.05));

  /* "This is working." Used by the health dot and by a connected platform
     -- one colour, one meaning. Not a platform brand colour: the
     connected state used var(--steam), so PlayStation and Xbox both
     announced themselves in Steam's blue. */
  --ok: #6ec878;
  --magenta: #f56bb0;
  --magenta-soft: rgba(245, 107, 176, 0.18);

  /* Platform colors are brand-anchored, not arbitrary: Steam's own light
     blue, PlayStation's deep navy blue, Nintendo's signature red, Xbox
     green, and Epic in violet since it's the one platform without a
     universally recognized brand color to anchor to. */
  --steam: #66c0f4;
  --steam-soft: rgba(102, 192, 244, 0.18);
  --playstation: #2f5fdb;
  --playstation-soft: rgba(47, 95, 219, 0.2);
  --nintendo: #e4372c;
  --nintendo-soft: rgba(228, 55, 44, 0.18);
  --xbox: #4cb944;
  --xbox-soft: rgba(76, 185, 68, 0.18);
  --epic: #a56bf0;
  --epic-soft: rgba(165, 107, 240, 0.18);
  --blizzard: #00aeff;
  --blizzard-soft: rgba(0, 174, 255, 0.18);

  --font-display: "Big Shoulders Display", sans-serif;
  --font-body: "Inter", -apple-system, sans-serif;
  /* NOT monospace any more.
     IBM Plex Mono was used 81 times -- labels, chips, captions, every
     number -- and it gave the whole app a typewriter/terminal register
     nobody wanted. Swapped at the variable so all 81 sites change
     together rather than being hunted down.
     `tabular-nums` is the part that matters: mono was doing real work
     keeping columns of figures aligned, and Inter's tabular figures do
     the same without the look. */
  --font-mono: "Inter", -apple-system, sans-serif;

  --radius: 10px;

  /* THE MEDALS: Game Awards wins and nominations (29 Sep). Fixed, NOT
     theme tokens -- no theme block sets these, on purpose. A win is gold
     and a nomination silver in every theme; --gold is the theme's accent
     (blue on Glacier) and was what made a win match the genre and Owned
     pills. The gold happens to equal Sunset's --gold. */
  --medal-gold: #f5c04f; --medal-gold-rgb: 245, 192, 79;
  --medal-silver: #c8d0da; --medal-silver-rgb: 200, 208, 218;
}

* { box-sizing: border-box; }

[hidden] { display: none !important; }

html { scroll-behavior: smooth; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  /* Figures line up in columns without a monospace face -- the one job
     IBM Plex Mono was actually doing before it was swapped out. */
  font-variant-numeric: tabular-nums;
  font-family: var(--font-body);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

/* ---------- Hero ---------- */

.hero {
  background:
    radial-gradient(ellipse 900px 500px at 15% -10%, rgba(var(--act-rgb), 0.16), transparent 60%),
    radial-gradient(ellipse 700px 450px at 100% 0%, rgba(var(--glow-cool-rgb), 0.14), transparent 55%),
    radial-gradient(ellipse 600px 350px at 60% 110%, rgba(var(--glow-warm-rgb), 0.10), transparent 60%),
    var(--bg);
  border-bottom: 1px solid var(--hairline);
  /* Was 4.5rem/3rem, then 2rem/1.6rem -- and still took a fifth of the
     first screen before any content appeared. The wordmark is the only
     thing in here that has to be seen, and it is 5rem tall on its own. */
  /* Matches `main`'s 0.6rem so the wordmark starts where the Profile tab
     does. It was 1.5rem, sitting the header 14px inside the nav beneath
     it. main's 0.6rem reads as a phone rule but is not scoped to one.
     Matching pair at 640px below.

     Top down from 1.1rem to 0.7rem with the tagline gone. The banner used
     to be wordmark, tagline, nothing; it is wordmark and nav now, so the
     space that separated the brand from the bottom rule is doing a
     different job and needs less of a run-up. */
  padding: 0.7rem 0.6rem 0.9rem;
}

/* 1240px, matching `.view-tabs`. It was 980px, so the header sat 130px
   inside the nav directly beneath it at every width. The tabs are pinned
   to 1240 whichever view is open, so that is the number to match. */
/* THE HEADER DOES NOT MOVE (Luke, 29 Sep: "I don't want this moving
   anymore. We can lock the tabs and header down from now on.") For one
   day the wordmark and tabs followed the content's edge on Games and Play
   Next -- a `translate` by --hdr-shift, measured in app.js -- and it went
   wrong twice in that day: the strip, moved right, hung past the window
   and let the page scroll sideways; then on a 1962px window the measured
   value came out negative and put the wordmark at x = 0, clipped. So the
   header is the 1240 page, centred, on every tab, the same as the
   banner behind it and the gear on its right, and nothing measures it.
   A measured position can be measured wrong; a fixed one cannot. */

.hero-inner {
  max-width: 1240px;
  margin: 0 auto;
  /* The anchor for the settings gear, which is lifted out of flow below.
     On `.hero-inner` rather than `.hero` so the gear stays on the content's
     right edge at 1240 centred, not the window's. */
  position: relative;
}

.eyebrow {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent);
  margin: 0 0 0.75rem;
}

.hero-title {
  /* Righteous, not Monoton.
     Monoton is a striped neon face designed for CAPS -- it needs
     `text-transform: uppercase` to look right, which fought a lowercase
     wordmark. Righteous keeps the retro register (geometric, 70s) and is
     drawn for lowercase, so "nowplayed" reads as intended rather than as
     a shouted acronym.
     Swapping it is two lines: this family, and the Google Fonts href in
     index.html. Other retro options that would work: Audiowide
     (retro-futurist), Rubik Mono One (chunky poster), Bruno Ace (sleeker
     sci-fi). */
  /* LEXEND, 700 (Luke, 26 Sep), on every theme. Chosen from sixteen faces
     compared on the page; Righteous's spurs -- the points on the n -- were
     the complaint, and a rounded-geometric face reads cleaner at this size.
     The note above about Monoton still stands. */
  font-family: "Lexend", var(--font-display), sans-serif;
  font-weight: 700;
  /* Full size, deliberately. A slimming pass capped this at 3.4rem along
     with cutting the padding -- the padding was the ask. The space around
     it made the banner tall, not the type. */
  font-size: clamp(2.6rem, 9vw, 5rem);
  line-height: 1.1;
  /* Slight negative tracking, so the wordmark reads as one word rather
     than nine letters. */
  letter-spacing: -0.01em;
  /* THE TAIL OF THE Y, AND THE WHOLE GRADIENT (26 Sep).
     background-clip: text only paints inside the element's box, and at
     line-height 1.1 the box stopped above the descender -- the y's tail
     got no colour and vanished. The padding reaches below the baseline;
     the margin gives it back, so the gap under the wordmark (0.35rem) is
     unchanged. And the heading was as wide as the banner, so a sideways
     gradient spread across empty space and the word showed only its first
     colours; fit-content makes it span the word. */
  padding-bottom: 0.22em;
  margin: 0 0 calc(0.35rem - 0.22em);
  width: fit-content;
  /* No uppercase. The name is lowercase and stays lowercase. */
  background: var(--wm-grad-v);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  color: transparent;
  filter: drop-shadow(0 0 18px rgba(var(--act-rgb), 0.35)) drop-shadow(0 0 34px rgba(var(--sun-violet-rgb), 0.25));
}

.synced-at {
  margin: 2.5rem 0 0;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--text-faint);
}

/* ---------- Layout ---------- */

main {
  max-width: 980px;
  margin: 0 auto;
  /* Published as a variable because .game-detail-backdrop cancels it with a
     negative `top`, and it inherits this from here rather than repeating the
     number. It was repeated, the two drifted, and the artwork spilled up
     into the banner -- see the note on that rule. */
  --main-pad-top: 2.5rem;
  padding: var(--main-pad-top) 1.5rem 1rem;
}

.section-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem 1.5rem;

  /* A heading belongs to what's BELOW it, so it sits nearer that. This had
     margin-bottom only, and the old tab strip's 2rem margin collapsed
     through it, so the first heading in a panel landed flush against the
     tab divider -- 0px above, 20px below, reading as the tail of the
     section it had just left. The strip is gone; the asymmetry stays,
     because proximity is the whole job of a heading. */
  margin-top: 1.75rem;
  margin-bottom: 0.9rem;
}

/* Library stacks its heading, filters and actions in one left-aligned
   column. space-between works when a head is just a title and one
   control, but with four rows of things it scattered them across the
   width with no relationship to each other. */
.library-section .section-head {
  display: block;
  margin-bottom: 0.6rem;
}
/* The actions row sits above the filters now, so the filters are not the
   pane's first child and do not want the 1.75rem a leading section-head
   gets -- the actions' bottom margin is the gap. */
.library-section > .library-actions + .section-head { margin-top: 0; }
/* No top margin: it separated the filters from a visible "Library"
   heading above them, which is screen-reader only now. Left in, it sat
   the filters 11px lower than the action buttons sharing their line. */
.library-section .section-head .controls {
  margin-top: 0;
  justify-content: flex-start;
}



.section-head h2 {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.9rem;
  text-transform: uppercase;
  letter-spacing: 0.01em;
  margin: 0;
}

/* Notes read as a single quiet line. The old 32ch cap wrapped every note
   into a three-line block, which drew far more attention than a hint
   should and made the page look cluttered. */
.section-note {
  font-size: 0.78rem;
  color: var(--text-faint);
  /* Sits with its heading, then leaves room before the content.
     It had `margin: 0.15rem 0 0` -- almost touching the heading above and
     nothing below, so the note and the thing it describes drifted apart
     while the note crowded the title. Same proximity problem as
     .section-head, one level down. */
  margin: 0.1rem 0 0.85rem;
  line-height: 1.4;
}

.controls {
  display: flex;
  gap: 0.6rem;
  flex-wrap: wrap;
  align-items: center;
}

#search, #sort {
  font-family: var(--font-body);
  font-size: 0.8rem;
  color: var(--text);
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.55rem 0.8rem;
}

#search { min-width: 200px; }
#search:focus, #sort:focus {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* ---------- Library table ---------- */

.table-wrap {
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  /* overflow-x scrolls, overflow-y stays hidden so the rounded corners
     still clip the table.
     This was the `overflow: hidden` shorthand, which set BOTH axes -- so
     the mobile `overflow-x: auto` below was overridden and the Library
     table had no way to scroll. It squeezed instead, and game titles
     wrapped one letter per line. */
  overflow-x: auto;
  overflow-y: hidden;
  background: var(--bg-panel);
}

table {
  width: 100%;
  border-collapse: collapse;
}

thead th {
  text-align: left;
  font-size: 0.72rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
  font-weight: 600;
  padding: 0.85rem 1rem;
  border-bottom: 1px solid var(--hairline);
}

tbody tr {
  border-bottom: 1px solid var(--hairline);
}
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--bg-panel-raised); }

tbody td {
  padding: 0.85rem 1rem;
  font-size: 0.92rem;
  vertical-align: middle;
}

.game-name-cell { font-weight: 600; }

.platform-tag {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-family: var(--font-mono);
  font-size: 0.76rem;
}

.platform-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex: 0 0 auto;
}
.platform-dot.steam { background: var(--steam); }
.platform-dot.playstation { background: var(--playstation); }
.platform-dot.epic { background: var(--epic); }
.platform-dot.nintendo { background: var(--nintendo); }
.platform-dot.xbox { background: var(--xbox); }

.mono-cell { font-family: var(--font-mono); color: var(--text-muted); font-size: 0.86rem; }

.dash { color: var(--text-faint); }

.empty-state {
  padding: 2.5rem 1rem;
  text-align: center;
  color: var(--text-faint);
  font-size: 0.9rem;
}

/* An empty state that BELONGS to a section sits under its heading, not
   centred in the middle of the panel. Centred is right for a whole view
   with nothing in it; inside a section it reads as a notice that floated
   in from somewhere else -- which is exactly how this one looked when it
   rendered with no heading at all.
   Kept HERE, beside the rule it overrides, rather than next to
   .card-row-block further down -- that spot is inside an
   `@media (pointer: fine)` block, which is where the first attempt at
   this landed and silently did nothing. */
.card-row-block .empty-state {
  text-align: left;
  padding: 0.4rem 0 1rem;
}

/* ---------- Footer ---------- */

/* A REAL FOOTER (Luke, 29 Sep, picked on the real page from row / split /
   center). Four pieces: the name, what syncs, the credits (IGDB, Privacy,
   the year) and the disclaimer. On desktop the name and the sync line
   share the top row, the small print the bottom one; a phone stacks and
   centres them (phone block). */
.site-footer {
  max-width: 980px;
  margin: 3rem auto 0;
  padding: 1.5rem 1.5rem 3rem;
  border-top: 1px solid var(--hairline);
  color: var(--text-faint);
  font-size: 0.8rem;
  display: grid;
  /* One row since #133 (30 Sep): the credits and Privacy on the left,
     the disclaimer on the right. It was two rows -- the wordmark with a
     tagline over the credits, "Syncs with ..." over the disclaimer -- and
     the .footer-brand / .footer-wordmark / .footer-tagline / .footer-sync
     rules went with those lines (the wordmark's quieter gradient is in
     git history, 29 Sep, if the login page ever wants it). */
  grid-template-columns: 1fr auto;
  grid-template-areas: "meta fine";
  gap: 0.45rem 1.5rem;
  align-items: center;
}
.site-footer p { margin: 0; }
.footer-meta { grid-area: meta; font-size: 0.74rem; color: var(--text-faint); }
.footer-fine { grid-area: fine; font-size: 0.7rem; color: var(--text-faint); opacity: 0.8; text-align: right; }
.footer-dot { margin: 0 0.4rem; opacity: 0.6; }

/* ---------- Loading / login screens ---------- */

/* ---------- Login screen ---------- */

/* A WALL OF COVER ART, and the page on top of it.
   It was a form on a flat background: correct, and it told a first-time
   visitor nothing about what they were signing into. The covers do that in
   the time it takes to look, which is the one thing every competitor's
   landing page gets right.

   Full-bleed rather than a card, because the art IS the page. */
.login-screen {
  position: relative;
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  align-items: center;
  padding: 2rem clamp(1.5rem, 6vw, 5rem);
}

/* THE WALL DRIFTS, LEFT TO RIGHT. Rows of covers, each a strip that slides
   sideways for as long as the page is open.

   It was a static `auto-fill` grid. A still wall reads as a background
   image -- something the page is printed on. A moving one reads as a
   library being flicked through, which is what the page is selling.

   --cover-w IS THE SINGLE SOURCE OF TRUTH for the column width, because
   buildLoginWall() reads it back off this element with getComputedStyle to
   work out how many covers a row needs. Change it here, in one place; the
   phone rule below overrides it and the JS follows automatically. A second
   number hardcoded in app.js is how the two silently disagree. */
.login-wall {
  /* 160px, not 118. THIS NUMBER IS THE PAGE WEIGHT, and quadratically: the
     wall places one cover per slot and every slot is a distinct image, so
     the count is (W / cover-w) x (H / cover-h) and cover-h is cover-w x 1.5.
     Halving the width quadruples the downloads.

     Measured against six real covers off IGDB's CDN: t_cover_big averages
     21.5KB (14.6-26.0). At 118px a 1102x900 window placed 96 covers, about
     2.0MB. At 160px it places ~52, about 1.1MB, and the art is bigger and
     easier to recognise -- which is the only job the wall has.

     t_cover_small would be 4.1KB but it is 90px wide, so it is upscaled at
     any of these widths and worse on a 2x screen. WebP is not the answer
     either: IGDB's CDN returns it LARGER than JPEG about half the time
     (one cover measured 37.6KB webp against 26.0KB jpg). */
  --cover-w: 160px;
  --gap: 8px;
  position: absolute;
  inset: 0;
  /* The clip belongs HERE, on the thing that overflows, not on
     `.login-screen`. Each strip is deliberately wider than the window --
     that is what there is to loop through -- so the overflow is the normal
     case rather than an edge one. */
  overflow: hidden;
  display: flex;
  flex-direction: column;
  gap: var(--gap);
  padding: var(--gap);
  /* Slightly desaturated: this is a texture, and 60 covers at full
     saturation fight everything placed on them. */
  filter: saturate(0.85);
}

.login-wall-row {
  /* Clips its own strip. Without this the strip's left-hand overhang paints
     across the whole page while it is off-screen. */
  overflow: hidden;
  flex: none;
}

/* `max-content` so the strip is as wide as its covers rather than the
   window -- a strip constrained to 100% would squash them and there would
   be nothing to scroll through. */
.login-wall-strip {
  display: flex;
  gap: var(--gap);
  width: max-content;
  will-change: transform;
  animation: login-wall-drift var(--drift, 90s) linear infinite;
}

/* -50% to 0, not 0 to 50%. The strip holds its covers TWICE, so -50% lands
   exactly on the start of the second copy and 0 on the start of the first:
   the two frames are pixel-identical and the loop has no seam. Going the
   other way drifts right to left. */
@keyframes login-wall-drift {
  from { transform: translateX(-50%); }
  to   { transform: translateX(0); }
}

.login-wall img {
  width: var(--cover-w);
  aspect-ratio: 2 / 3;
  object-fit: cover;
  border-radius: 6px;
  display: block;
  flex: none;
}

/* Darkest where the words are, thinning towards the right so the art is
   still art. An even overlay would have to be dark enough for the text,
   which would kill the covers everywhere else.

   A FADE AND A FRAME, NOT A CIRCLE (Luke, 25 Sep). This was a radial
   ellipse, 900x560px at 28% 50%, stepping from 97% to 72% -- which on a
   wide screen read as a big dark hole in the wall with a visible edge,
   out past the form into the middle of the page. Four softer versions
   were compared on the real stylesheet (a softer ellipse, a plain left
   fade, this, and a frosted card); this one keeps the form as readable
   and shows the most art. A left-to-right fade darkens behind the words,
   and the top and bottom edges are darkened so the wall looks framed
   rather than cut. Two linear gradients, so nothing is sized in pixels
   and it holds at any width. The phone keeps its own flat scrim below. */
.login-scrim {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(180deg,
      rgba(var(--bg-rgb), 0.55) 0%, rgba(var(--bg-rgb), 0) 22%,
      rgba(var(--bg-rgb), 0) 78%, rgba(var(--bg-rgb), 0.65) 100%),
    linear-gradient(90deg,
      rgba(var(--bg-rgb), 0.93) 0%,
      rgba(var(--bg-rgb), 0.84) 28%,
      rgba(var(--bg-rgb), 0.55) 48%,
      rgba(var(--bg-rgb), 0.3) 70%,
      rgba(var(--bg-rgb), 0.24) 100%);
}

.login-card {
  position: relative;
  z-index: 1;
  max-width: 430px;
  width: 100%;
}

.login-wordmark {
  /* Lexend, and the same two fixes as the banner's -- the y's tail was
     cut off here worst of all, at line-height 1. See .hero-title. */
  font-family: "Lexend", var(--font-display), sans-serif;
  font-weight: 700;
  font-size: clamp(2.4rem, 7vw, 3.4rem);
  line-height: 1;
  letter-spacing: -0.01em;
  padding-bottom: 0.22em;
  margin: 0 0 -0.22em;
  width: fit-content;
  /* TOP TO BOTTOM, as the banner's wordmark is (1 Oct). This was the
     left-to-right gradient, --wm-grad-h, so the same word was gold-to-
     violet downwards in the app and gold-to-pink sideways on the page
     you sign in from. One wordmark, one gradient. (--wm-grad-h is still
     defined by every theme and nothing uses it now.) */
  background: var(--wm-grad-v);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

/* The claim, and nothing else. It is the differentiator -- every competitor
   is manual logging -- so it gets its own line and full weight.

   THE WHOLE SELL IS THIS ONE LINE NOW. It used to be the claim, then a
   sub-line saying how ("Connect your platform once..."), then three platform
   pills, then four explainer sections below the fold. All of it went: the
   covers say what this is before a word is read, and the rest was the page
   arguing with someone who has not asked a question yet.
   Both the `.login-sub` and `.login-plats` rules were removed with their
   markup -- do not leave one behind, which is exactly what happened the
   first time.

   The bottom margin is 1.6rem rather than the 0.4rem it carried while the
   sub-line and the pills sat underneath supplying their own. Deleting the
   markup without moving that number puts the claim flush against the email
   field. */
.login-claim {
  font-family: var(--font-body);
  font-size: 1.15rem;
  font-weight: 600;
  color: var(--text);
  line-height: 1.35;
  margin: 0.9rem 0 1.6rem;
}

#login-form { max-width: 340px; }

/* ---------- Login screen: below the fold ---------- */

/* The login screen stops being one viewport once there is anything under it.
   `min-height` rather than `height`, and the card keeps 100svh so the fold
   still lands where it should. */
/* dvh AFTER svh (#159, 1 Oct). On a phone the first screen is meant to be
   exactly one screen -- the wall and the form, with "Files games for you"
   starting below the fold. svh is the height with the browser's toolbars
   showing; whenever they are tucked away the visible screen is taller
   than that, and the wall ended short with the explainer's heading in
   view. dvh is the screen as it is now. (Not reproducible in a desktop
   pane, where the three units are equal: 812, 812 and 812 at 375x812.)
   svh stays first as the fallback for a browser without dvh. */
.login-screen { min-height: 100svh; min-height: 100dvh; height: auto; display: block; }
/* Belt and braces against a wide row: the page may scroll DOWN, never
   sideways. `clip` rather than `hidden` so it does not create a scroll
   container that would trap the sections inside it. */
.login-screen { overflow-x: clip; }
/* The wall and the scrim belong to the FIRST screen only. Without this they
   stretch the whole document and the explainer reads over drifting covers. */
.login-screen > .login-wall,
.login-screen > .login-scrim { height: 100svh; height: 100dvh; inset: 0 0 auto 0; }
.login-screen > .login-card {
  min-height: 100svh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.login-more[hidden] { display: none; }
/* With the explainer hidden the card is the whole page: it is a full
   screen tall already, and the screen's own top and bottom padding made
   64px of nothing to scroll into. */
.login-screen:has(> .login-more[hidden]) { padding-top: 0; padding-bottom: 0; }
.login-more {
  position: relative;
  z-index: 1;
  max-width: 1100px;
  margin: 0 auto;
  padding: 1rem 0 5rem;
}

/* Text and art, alternating. */
.lm-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: clamp(2rem, 6vw, 5rem);
  align-items: center;
  padding: clamp(3rem, 8vw, 6rem) 0;
}
.lm-flip .lm-text { order: 2; }

.lm-text h2 {
  font-family: var(--font-display);
  font-size: clamp(1.8rem, 3.4vw, 2.6rem);
  line-height: 1.05;
  color: var(--text);
  margin: 0 0 1rem;
}
.lm-text p {
  font-family: var(--font-body);
  font-size: 0.98rem;
  line-height: 1.6;
  color: var(--text-muted);
  margin: 0 0 0.9rem;
  max-width: 34rem;
}

/* The panel the diagram sits on. A surface, not a card with a life of its
   own -- the drawing is the content and a heavy border competes with it. */
.lm-art {
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: 14px;
  padding: 1.25rem;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* SVG scales to the panel; every size inside is in viewBox units so one
   number here controls the whole drawing. */
.lm-svg {
  width: 100%;
  height: auto;
  display: block;
  font-family: var(--font-body);
}

/* ---- shared diagram ink ----
   Text wears TEXT tokens, never the series colour. The coloured rect beside
   a label is what carries identity; a gold word on a gold box is just
   harder to read. */
.lm-svg text { fill: var(--text); font-size: 14px; font-weight: 600; }
.lm-svg .lm-title { font-size: 15px; font-weight: 700; }
.lm-svg .lm-label { fill: var(--text-muted); font-size: 12px; font-weight: 600; }
.lm-svg .lm-cap   { fill: var(--text-faint); font-size: 11px; font-weight: 500; }
.lm-svg .lm-foot  { fill: var(--text-faint); font-size: 11px; font-weight: 500; }
.lm-svg .lm-hrs   { fill: var(--text-muted); font-size: 12px; font-weight: 600;
                    font-variant-numeric: tabular-nums; }

/* ---- panel 1: the status pipeline ---- */
.lm-node rect { fill: var(--bg-panel-raised); stroke: var(--hairline); stroke-width: 1; }
.lm-node text { font-size: 13px; }
.lm-glyph-stroke { fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.lm-glyph-fill { stroke: none; }

/* Not-yet: dashed, and the only node drawn in faint ink. */
.lm-node-wait rect { stroke-dasharray: 4 4; }
.lm-node-wait text { fill: var(--text-faint); }
.lm-node-wait .lm-glyph-stroke { stroke: var(--text-faint); }

.lm-node-idle text { fill: var(--text-muted); }
.lm-node-idle .lm-glyph-stroke { stroke: var(--text-muted); }

/* Live and done are --accent and --gold's green counterpart. They fail a
   protanopia check against each other (dE 5.3), and they are NOT repainted:
   these two colours mean "active" and "this is working" everywhere in the
   app. The separation is carried by the glyph -- a play triangle against a
   tick -- and by being two steps apart in a labelled chain. */
.lm-node-live rect { stroke: var(--gold); fill: var(--gold-soft); }
.lm-node-live .lm-glyph-fill { fill: var(--gold); }

.lm-node-done rect { stroke: var(--ok); fill: rgba(110, 200, 120, 0.14); }
.lm-node-done .lm-glyph-stroke { stroke: var(--ok); }

/* Bounced off is dashed like `unreleased`, because both are states the game
   is resting in rather than moving through -- and it is deliberately NOT
   red. Drifting away from a game is an ending here, not a failure. */
.lm-node-off rect { stroke: var(--magenta); stroke-dasharray: 4 4; fill: var(--magenta-soft); }
.lm-node-off .lm-glyph-stroke { stroke: var(--magenta); }

/* The badge that says the arrows are not yours to draw. Gold, because it is
   the claim the panel is making, and the only filled thing in the drawing
   apart from the live state itself. */
.lm-auto rect { fill: var(--gold-soft); stroke: var(--gold); stroke-width: 1; }
.lm-auto circle { fill: var(--gold); }
.lm-auto text { fill: var(--gold); font-size: 13px; font-weight: 600; }

.lm-link { fill: none; stroke: var(--hairline); stroke-width: 2; }
.lm-link-off { stroke: var(--magenta); opacity: 0.7; stroke-dasharray: 5 4; }

/* ---- panel 2: the two runs ---- */
/* ONE HUE, two lightness steps. Same game, two instances -- a second hue
   would be a colour to decode for no information, and both runs are
   directly labelled anyway. */
.lm-run { fill: var(--gold); }
.lm-run-2 { fill: rgba(var(--gold-rgb), 0.55); }
/* The achievements themselves, punched out of the bar. */
.lm-ticks rect { fill: var(--bg-panel); opacity: 0.75; }
.lm-gap { fill: none; stroke: var(--hairline); stroke-width: 1.5; stroke-dasharray: 3 5; }
.lm-axis { stroke: var(--hairline); stroke-width: 1; }

/* ---- panel 3: three sources, one game ---- */
.lm-src rect { fill: var(--bg-panel-raised); stroke-width: 1; }
.lm-src text { font-size: 13px; }
.lm-src-steam rect { stroke: var(--steam); fill: var(--steam-soft); }
.lm-src-psn   rect { stroke: var(--playstation); fill: var(--playstation-soft); }
.lm-src-xbox  rect { stroke: var(--xbox); fill: var(--xbox-soft); }

.lm-merge { fill: none; stroke-width: 2; opacity: 0.85; }
.lm-merge-steam { stroke: var(--steam); }
.lm-merge-psn   { stroke: var(--playstation); }
.lm-merge-xbox  { stroke: var(--xbox); }

.lm-target rect { fill: var(--bg-panel-raised); stroke: var(--gold); stroke-width: 1.5; }

/* ---- panel 4: the absence ---- */
.lm-absent { width: 100%; font-family: var(--font-body); }
.lm-gone { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.7rem; }
.lm-gone li {
  /* LEGIBILITY FIRST, then the strike. This was --text-faint at 0.92rem
     under a full-strength magenta rule, so the two fought and the row read
     as damage rather than as a sentence being struck out. You have to be
     able to READ it for crossing it out to mean anything.
     Muted ink, a size up, and the rule dropped to 75% so it marks the line
     instead of competing with it. */
  font-size: 1rem;
  line-height: 1.45;
  color: var(--text-muted);
  text-decoration: line-through;
  text-decoration-color: rgba(245, 107, 176, 0.75);
  text-decoration-thickness: 2px;
}
.lm-instead {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-faint);
  margin: 1.1rem 0 0.9rem;
  display: flex;
  align-items: center;
  gap: 0.75rem;
}
.lm-instead::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--hairline);
}
.lm-kept { margin: 0; display: grid; gap: 0.9rem; }
.lm-kept dt {
  font-family: var(--font-display);
  font-size: 1.7rem;
  line-height: 1;
  color: var(--gold);
}
.lm-kept dd {
  margin: 0.2rem 0 0;
  font-size: 0.92rem;
  color: var(--text-muted);
}

.lm-close { text-align: center; margin: 0; padding-top: 1rem; }

@media (max-width: 760px) {
  .lm-row { grid-template-columns: 1fr; gap: 1.5rem; padding: 2.5rem 0; }
  /* The art follows its text on a phone, so `order` has to go -- otherwise
     the flipped rows read heading, picture, heading, picture out of step. */
  .lm-flip .lm-text { order: 0; }
  .login-more { padding: 0 1.25rem 3.5rem; }

  /* THE DIAGRAMS WERE UNREADABLE ON A PHONE AND IT DOES NOT SHOW IN A
     SCREENSHOT -- it just looks like small text until you measure it.
     Each drawing is 440 viewBox units wide. Inside `.login-screen`'s
     1.25rem padding, `.login-more`'s 1.25rem, and the panel's own 1rem,
     that renders 261 CSS px on a 375px screen -- a scale of 0.59, so a
     13-unit label lands at 7.7px and an 11-unit caption at 6.5px. Measured,
     not guessed.

     Two levers, and it needs both. The panel goes full-bleed to take back
     the two layers of page padding (-2.5rem clears both), and the type
     inside the viewBox goes up. 261px becomes 351 and the scale 0.80, so
     the same caption now lands near 12px.

     Sizes are bumped rather than doubled on purpose: the boxes in the
     viewBox are fixed, so type that grows faster than its container
     collides with it. These values were checked against every text node's
     getBBox at 375px, not eyeballed. */
  .lm-art {
    margin-inline: -2.5rem;
    padding: 1rem 0.75rem;
    border-inline: 0;
    border-radius: 0;
  }
  .lm-svg text { font-size: 16px; }
  .lm-svg .lm-title { font-size: 17px; }
  .lm-svg .lm-label { font-size: 14px; }
  .lm-svg .lm-cap,
  .lm-svg .lm-foot,
  .lm-svg .lm-hrs { font-size: 13px; }
  .lm-node text { font-size: 15px; }
}

.center-screen {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.5rem;
}

/* The 380px cap that was here is gone. It sat BELOW the login-screen block
   and beat its `max-width: 430px` at equal specificity -- the later-rule-wins
   trap, and the measurement caught it rather than the eye: the card rendered
   380 while the stylesheet above said 430. The width lives with the rest of
   the login rules now, in one place. */

.hero-title.small {
  font-size: clamp(2.5rem, 10vw, 3.5rem);
  margin-bottom: 1.75rem;
}

#login-form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

#login-form label {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  font-size: 0.85rem;
  color: var(--text-muted);
}

#login-form input {
  font-family: var(--font-body);
  font-size: 0.95rem;
  color: var(--text);
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.65rem 0.8rem;
}

#login-form input:focus {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
/* Under the username (#166): what it is for and what it may hold, in the
   label's colour a size down. */
.login-hint {
  font-size: 0.74rem;
  color: var(--text-faint);
}

.login-actions {
  display: flex;
  gap: 0.6rem;
  margin-top: 0.25rem;
}

.form-message {
  min-height: 1.2rem;
  font-size: 0.85rem;
  color: var(--gold);
  margin: 0.5rem 0 0;
}

/* ---------- Buttons ---------- */

.btn {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 0.88rem;
  border-radius: 6px;
  padding: 0.65rem 1.1rem;
  cursor: pointer;
  border: 1px solid transparent;
  transition: transform 0.15s ease, opacity 0.15s ease;
}

.btn:hover { transform: translateY(-1px); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.btn-primary {
  background: linear-gradient(135deg, var(--accent), var(--btn-primary-end));
  color: var(--btn-primary-ink);
  font-weight: 700;
}

.btn-primary:hover {
  box-shadow: 0 0 16px -2px rgba(var(--act-rgb), 0.55);
}

.btn-ghost {
  background: transparent;
  color: var(--text-muted);
  border-color: var(--hairline);
}

.btn-small {
  padding: 0.45rem 0.8rem;
  font-size: 0.8rem;
}
/* "+ Mark completed" on the game page (#139): the + in gold, as the page's
   other invitations have it. The phone block sizes it to fit the top bar. */
.mark-completed-btn { gap: 0.3rem; white-space: nowrap; }
.mark-completed-btn .mark-completed-plus { color: var(--gold); font-weight: 700; }

/* ---------- Hero top row (logout button) ---------- */

.hero-top-row {
  display: flex;
  align-items: flex-start;
  justify-content: flex-end;
  gap: 1rem;

  /* OUT OF FLOW, so the wordmark starts at the top of the banner.
     One right-aligned gear was taking a 35px row of its own and pushing the
     wordmark 40px down -- a full line of banner spent on a button that needs
     none of the width. Absolute, it sits in the same place and costs
     nothing.
     The settings popover is anchored to `.settings-menu-wrap`, which has its
     own `position: relative`, so it opens where it always did. */
  position: absolute;
  top: 0;
  right: 0;

  /* THE GEAR WAS UNCLICKABLE WITHOUT THIS, and it looked fine the whole
     time -- the button rendered in the right place, at full opacity, with
     `pointer-events: auto`. Every click landed on the wordmark instead.

     `.hero-title` carries two `drop-shadow()` filters for its glow. A
     non-`none` filter makes an element a stacking context, and a stacking
     context is painted as one unit at the level of a POSITIONED element
     with `z-index: 0` -- so the wordmark stopped being ordinary in-flow
     content that positioned siblings paint over, and joined them. Both it
     and this row then sat at z-index 0 inside `.hero-inner`, which puts
     DOM order in charge, and the `h1` comes second and wins.

     The `h1` is `display: block`, so its box is the full 1005px of the
     banner while "nowplayed" is only about 200px of that. The gear sits in
     the empty right-hand end of that box -- visible through a transparent
     background, and completely covered for hit-testing.

     None of this mattered until the row went `position: absolute` just
     above. In flow it had its own 35px row and never overlapped the
     wordmark, so the filter's stacking context cost nothing.

     30 rather than 1 because this z-index makes the row a stacking context
     and traps its popovers inside it. `.settings-menu` is the highest at
     30, so matching it keeps all three clearing the same page content they
     cleared when they competed globally. */
  z-index: 30;
}

.hero-top-actions {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.4rem;
}

.settings-menu-wrap {
  position: relative;
}

.settings-gear-btn {
  background: transparent;
  border: 1px solid var(--hairline);
  border-radius: 8px;
  color: var(--text-muted);
  padding: 0.4rem;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: color 0.15s ease, border-color 0.15s ease, transform 0.2s ease;
}
.settings-gear-btn:hover {
  color: var(--accent);
  border-color: var(--gold-soft);
  transform: rotate(30deg);
}

/* THE SETTINGS MENU IN THE SITE'S FORMAT (26 Sep). It was a near-black
   box of plain text items -- the one surface still in the old look. Now a
   raised panel with a hairline edge, like the rest: who is signed in (with
   their avatar), the theme, then the three places to go. Every item,
   Log Out included, is the same row: Luke flagged Log Out looking
   different from the others in the prototype, where it was muted. */
.settings-menu {
  position: absolute;
  top: calc(100% + 0.5rem);
  right: 0;
  z-index: 30;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: 14px;
  padding: 0.45rem;
  box-shadow: 0 18px 44px rgba(0, 0, 0, 0.5);
  min-width: 250px;
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
}

.settings-menu-item {
  background: transparent;
  border: none;
  border-radius: 9px;
  color: var(--text);
  font-family: inherit;
  font-size: 0.85rem;
  font-weight: 600;
  text-align: left;
  padding: 0.6rem 0.75rem;
  cursor: pointer;
  white-space: nowrap;
}
.settings-menu-item:hover,
.settings-menu-item:focus-visible {
  background: rgba(255, 255, 255, 0.05);
  color: var(--gold);
  outline: none;
}

/* Who is signed in: the avatar, then the name. */
.settings-who {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  padding: 0.55rem 0.75rem 0.65rem;
}
.settings-who-avatar {
  flex: 0 0 auto;
  width: 2.4rem;
  height: 2.4rem;
  border-radius: 50%;
  overflow: hidden;
  display: grid;
  place-items: center;
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
}
.settings-who-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.settings-who-avatar img[hidden],
.settings-who-initial[hidden] { display: none; }
.settings-who-initial { font-weight: 800; color: var(--gold); }
.settings-who-id { min-width: 0; }
.settings-who-id small { display: block; font-size: 0.72rem; color: var(--text-faint); }
.settings-who-id strong {
  display: block;
  margin-top: 0.1rem;
  font-size: 0.95rem;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.settings-rule { height: 1px; background: var(--hairline); margin: 0.3rem 0.35rem; }

/* The theme picker. */
.settings-theme {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.45rem 0.75rem;
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--text);
}
.settings-theme select {
  font: 600 0.82rem var(--font-body);
  color: var(--text);
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 9px;
  padding: 0.35rem 0.5rem;
  cursor: pointer;
}
.settings-theme select:hover,
.settings-theme select:focus-visible { border-color: var(--gold); outline: none; }

.connections-popover {
  position: absolute;
  top: calc(100% + 0.5rem);
  right: 0;
  z-index: 20;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 1rem;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
  width: max-content;
  max-width: min(90vw, 560px);
}

.connection-cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 0.85rem;
}

.connection-card {
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 1.1rem 1.2rem;
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
}

/* The sync card. One action, given the weight of a primary control,
   sitting above the per-platform cards rather than among them --
   otherwise it reads as a fourth platform. */
.sync-all-card {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.35rem;
}

.sync-all-btn {
  width: 100%;
  justify-content: center;
  font-size: 0.95rem;
  padding: 0.65rem 1rem;
}

/* Disabled while running. The label changes to "Syncing…" as well, but
   the state needs to be visible without reading. */
.sync-all-btn:disabled {
  opacity: 0.6;
  cursor: default;
}

.connection-card-head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.connection-name {
  font-weight: 600;
  font-size: 0.95rem;
}

.connection-status {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--text-faint);
}

/* Green, not Steam's brand blue. This used var(--steam), so PlayStation
   and Xbox both announced themselves in Steam's colour -- and blue
   appears nowhere else in the app, so it read as a link. */
.connection-status.is-connected {
  color: var(--ok);
}

/* ---------- View tabs: one nav, two shapes ---------- */

/* The underline-tab strip is gone. It was a second full-width bar directly
   under the banner -- two horizontal rules doing one job -- and that stacking
   is what read as a dated site rather than an app.

   The same element becomes two things, because what fits depends entirely
   on the window:

     default            a pill row inside the banner, beside the wordmark
     <= 640px           a fixed icon bar at the bottom

   The bottom bar is `position: fixed`, so it leaves this flow entirely and
   the markup only has to be right for the row. Nothing in app.js knows
   which shape is showing: same element, same `.view-tab` buttons, same
   `data-view`. */

/* TWO ROWS, NOT ONE. Wordmark on top, nav under it, both left-aligned to
   the same edge.

   Beside the wordmark, the nav was competing with it for one line: the
   banner's height comes from a 5rem wordmark, so the pills either floated
   in the middle of that space or sat on its baseline with a wide gap of
   nothing above them. On its own row they have their own line and the
   wordmark keeps its size.

   This is still ONE bar. The thing that read as dated was a second
   full-width rule below the banner; a second row inside it, with no
   divider, is not that. */
.hero-bar {
  display: block;
}
.hero-brand { flex: none; }

/* Left edge shared with the wordmark, so the two rows hang off one line.
   The pills have their own horizontal padding, so a flush `margin-left: 0`
   leaves the first label indented past the "n". This pulled back by that
   padding to line up the TEXT rather than the box.

   THE BOX, NOT THE TEXT (Luke, 26 Sep: the Profile tab sat "a little too
   far to the left"). The selected tab is a filled pill, and pulled back by
   its padding it started 13px left of the wordmark and of every panel
   below -- the eye follows the pill, not the label inside it. Flush now. */
.hero-bar .view-tabs {
  margin-top: 0.55rem;
  margin-left: 0;
}

/* ---------- Note and search, at the end of the tab row (#130) ---------- */

/* Two bare glyphs in the accent colour, a beat after the last tab, each in
   the tabs' 34px cell so they line up with the pills. Chosen (30 Sep) from
   boxed / pill / filled and five colours; bare and the accent was the pick,
   with the glass folded. */
.hdr-tools {
  display: flex;
  align-items: center;
  gap: 0.3rem;
  margin-left: 0.5rem;
  flex: 0 0 auto;
}
.hdr-glass,
.hdr-note {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.15rem;
  height: 2.15rem;
  padding: 0;
  border: 0;
  border-radius: 9px;
  background: none;
  color: var(--gold);
  cursor: pointer;
}
.hdr-glass svg,
.hdr-note svg,
.plus-btn svg,
.notes-back svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.hdr-glass svg,
.hdr-note svg { width: 20px; height: 20px; }
.hdr-glass:hover,
.hdr-note:hover { background: rgba(var(--gold-rgb), 0.14); }
/* THE FIELD UNFOLDS beside the glass and folds away again (see the
   handlers by openGameSearch). A pill, so it reads as a search and not as
   a fifth tab. */
.hdr-search-input {
  font-family: inherit;
  font-size: 0.8rem;
  color: var(--text);
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 999px;
  padding: 0.42rem 0.9rem;
  height: 2rem;
  width: 250px;
}
.hdr-search-input:focus { border-color: var(--gold); outline: none; }
.hdr-search-input[hidden] { display: none; }
/* The notes page's way back (#130). */
.notes-back {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  margin: 0 0 0.6rem;
  padding: 0.3rem 0.1rem;
  background: none;
  border: 0;
  font: 600 0.85rem var(--font-body);
  color: var(--text-muted);
  cursor: pointer;
}
.notes-back:hover { color: var(--text); }
.notes-back svg { width: 16px; height: 16px; }
/* The phone's note disc and the word under its search: phone-only, below. */
.plus-btn,
.hdr-glass-label { display: none; }

.view-tabs {
  display: flex;
  gap: 0.3rem;
  /* Scrolls rather than wraps between roughly 900px and 1100px, where the six
     pills are wider than what is left beside the wordmark. Wrapping would
     make the banner two rows tall, which is the thing being removed. */
  overflow-x: auto;
  scrollbar-width: none;
}
.view-tabs::-webkit-scrollbar { display: none; }

.view-tab {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 0.86rem;
  color: var(--text-faint);
  background: none;
  border: none;
  border-radius: 9px;
  padding: 0.48rem 0.8rem;
  cursor: pointer;
  white-space: nowrap;
}

/* A tinted pill, not a filled one. A solid gold block sits directly above
   cover art on the profile, and the page is meant to let the artwork carry
   it -- the tint marks the tab without competing. */
.view-tab.is-active {
  /* A literal rather than --accent-fill, which is 0.05 and invisible behind
     a pill, or --gold-soft, which is 0.2 and reads as a filled button. */
  background: rgba(var(--gold-rgb), 0.14);
  /* Resolves to --gold here: the nav sits outside #view-profile, and
     --profile-accent is scoped to that panel. That is deliberate -- an
     accent someone picked for their profile colours the profile, not the
     app's navigation. */
  color: var(--accent);
}
.view-tab:hover:not(.is-active) {
  color: var(--text-muted);
  background: rgba(255, 255, 255, 0.04);
}
.view-tab:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* No icons in the row. Six of them beside six labels is decoration, and the
   labels already say it. They are for the two shapes where space is the
   constraint. */
.view-tab-icon {
  display: none;
  width: 17px;
  height: 17px;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* A fade on the right edge, where the row is too narrow to hold all six.
   The nav scrolls there (overflow-x above), but a scrollable strip with no
   visible edge looks like a strip that ends -- and the item that falls off
   is Gameplay Analysis, the last one. The old tab strip had this at phone
   width for the same reason.

   1150px is measured, and it is an approximation on purpose. The nav is a
   fixed 677px, and the brand beside it is not: `.hero-title` is
   `clamp(2.6rem, 9vw, 5rem)`, so the wordmark grows with the window until it
   pins at 80px, around 396px wide. 396 + 28 gap + 677 = 1101 of a row that
   is the window less 19px, so the two collide at roughly 1120.

   Erring high: a fade with nothing behind it dims one pill slightly, while
   no fade with something behind it hides a destination. Recompute if the
   wordmark's clamp or the number of items changes. */
@media (min-width: 641px) and (max-width: 1150px) {
  .view-tabs {
    -webkit-mask-image: linear-gradient(to right, #000 88%, transparent);
    mask-image: linear-gradient(to right, #000 88%, transparent);
  }
}

/* The sidebar that was here is gone.

   It put the nav in the empty page gutter above 1660px -- free, in that the
   content never gave up a pixel for it, and measured as such. It was still
   wrong: a navigation that lives down the left edge on a big monitor and in
   the banner everywhere else is two different products depending on the
   window, and the version in the banner is the better one.

   Kept in the history rather than the file. If it is ever wanted back, the
   number is arithmetic and not taste: `main` is capped at 1240 and centred,
   so a 210px sidebar needs 1240 + 210 * 2 = 1660px of window before there
   is a gutter to put it in.

   The nav is two shapes now: a pill row in the banner, and a bar at the
   bottom of a phone. */

/* ---------- Top 100 ---------- */

.top100-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(10, minmax(0, 1fr));
  /* Tighter gutters give each card meaningfully more width at the same
     10-across density -- the covers were small enough that art was hard
     to recognise at a glance, which is most of the point of a grid. */
  gap: 0.5rem;
}

.top100-item {
  list-style: none;
}

.top100-item.sortable-ghost {
  opacity: 0.4;
}

.top100-rank {
  font-family: var(--font-mono);
  font-weight: 600;
  color: var(--accent);
  text-align: right;
  text-shadow: 0 0 10px rgba(var(--act-rgb), 0.35);
}

.top100-rank-input {
  font-family: var(--font-mono);
  font-weight: 600;
  color: var(--accent);
  text-align: right;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 4px;
  /* The GRID badge width is set by `input.top100-grid-rank-badge` further
     down -- more specific and later, so it wins there. This value only
     applies to the list view. */
  width: 3.1rem;
  padding: 0.1rem 0.2rem;
  -moz-appearance: textfield;
}
.top100-rank-input::-webkit-outer-spin-button,
.top100-rank-input::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}
.top100-rank-input:hover,
.top100-rank-input:focus {
  border-color: var(--hairline);
  background: var(--bg-panel-raised);
}

/* ---------- Overview chart ---------- */

.chart-wrap {
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 1.5rem;
}

/* ---------- Calendar ---------- */

.calendar-month { margin-bottom: 2rem; }

.calendar-game {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: 8px;
  padding: 0.6rem 1rem;
}

.calendar-game-status {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--text-faint);
  text-transform: uppercase;
}

/* ---------- Extra platform colors not covered by the wishlist page's set ---------- */
.platform-dot.pc { background: var(--text-muted); }
.platform-dot.unknown { background: var(--text-faint); }

/* ---------- Top 100 cover art + years ---------- */

.top100-cover, .top100-cover-placeholder {
  width: 44px;
  height: 62px;
  border-radius: 4px;
  object-fit: cover;
  background: var(--bg-panel-raised);
  flex: 0 0 auto;
}

/* ---------- Library: status picker + game links ---------- */

.game-link {
  color: var(--text);
  text-decoration: none;
}
.game-link:hover { color: var(--accent); }

.status-select {
  font-family: var(--font-body);
  font-size: 0.82rem;
  color: var(--text);
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.35rem 0.5rem;
}

/* ---------- Game detail page ---------- */

.game-detail-section {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

.game-detail-layout {
  display: grid;
  /* 240px was oversized for a column that now holds only the cover --
     the URL field and Steam search that justified the width are gone. */
  grid-template-columns: 180px 1fr;
  gap: 1.75rem;
}

.game-detail-cover {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

/* On the DETAIL page the cover keeps its own shape -- no crop, no
   letterbox.
   It used to be forced to 2/3 with `object-fit: cover`, on the reasoning
   that IGDB supplies portrait art for almost everything and the odd
   wrong-shaped one "still looks acceptable cropped". It doesn't: a
   PlayStation cover is near-square, so both sides get cut and the title
   goes with them -- The Last of Us Remastered read as "HE AST F US".
   Dropping the fixed ratio means each cover displays at whatever shape it
   actually is. Nothing is hidden, and nothing is padded out with bars.
   Grids keep `cover`, because there uniform tiles matter more than any
   single image.
   The proper fix is upstream: prefer IGDB's portrait art over the
   platform's when both exist. That needs the igdb_cover_url column from
   the parked game_facts migration -- see BACKLOG. */
.game-detail-cover img {
  width: 100%;
  height: auto;
  /* A very tall or very wide cover can't run away with the page. */
  max-height: 70vh;
  object-fit: contain;
  border-radius: 8px;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
}

/* The placeholder has no image to take a shape from, so it keeps the
   portrait ratio -- otherwise a missing cover collapses to nothing. */
.cover-placeholder {
  width: 100%;
  aspect-ratio: 2 / 3;
  border-radius: 8px;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
}

.cover-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-faint);
  font-size: 0.8rem;
  text-align: center;
  padding: 1rem;
}

.game-detail-name {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 2.2rem;
  text-transform: uppercase;
  margin: 0 0 0.4rem;
}

.game-detail-stat-row {
  display: flex;
  flex-wrap: wrap;
  gap: 1.5rem;
  margin: 0 0 1.5rem;
}

.game-detail-stat {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
}

/* Steam's wording UNDER the percentage, not beside it.
   The cell rendered "94% Positive - Overwhelmingly Positive" plus a
   Refresh button on one line -- forty-plus characters next to Time to
   Beat and Hours Played, which are three or four. It set the width of the
   whole row.
   The label is a restatement of the number, so it demotes cleanly to a
   caption. Nothing is lost; it just stops competing.
   The width limit is on the CELL, not the caption: the caption sits
   inside the value span, which sizes to its own content, so capping the
   caption alone changed nothing. */
.game-detail-stat:has(.stat-caption) {
  max-width: 8.5rem;
}

.stat-caption {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.6rem;
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-faint);
  margin-top: 0.15rem;
  line-height: 1.2;
  white-space: normal;
}

/* Any author-level `display` beats the browser's default
   [hidden] { display: none }, so setting el.hidden = true silently did
   nothing on these cells -- they declare display:flex. This restores the
   attribute's meaning everywhere rather than patching each component,
   since the same trap applies to any element given a display rule. */
[hidden] {
  display: none !important;
}

.game-detail-stat-label {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-faint);
}

.game-detail-stat-value {
  font-size: 0.92rem;
  color: var(--text);
  font-weight: 600;
}

.game-detail-fields {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 1rem;
  margin-bottom: 1.5rem;
}

.game-detail-fields label, .game-detail-review-label {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  font-size: 0.8rem;
  color: var(--text-muted);
}

.game-detail-fields select,
.game-detail-fields input,
.game-detail-review-label textarea {
  font-family: var(--font-body);
  font-size: 0.9rem;
  color: var(--text);
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.6rem 0.75rem;
}

.game-detail-review-label {
  margin-bottom: 1rem;
}

.game-detail-review-label textarea {
  resize: vertical;
  font-family: var(--font-body);
  line-height: 1.5;
}

/* ---------- Sortable library headers ---------- */

.sortable-th {
  cursor: pointer;
  user-select: none;
}
.sortable-th:hover { color: var(--text-muted); }

.sort-arrow {
  font-size: 0.65rem;
  color: var(--accent);
  margin-left: 0.2rem;
}

/* ---------- Platform badges (monogram icons) ---------- */
/* Deliberately not exact brand-logo reproductions -- clean monogram badges
   in each platform's accent color, which reads clearly without reproducing
   trademarked artwork. */

.platform-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  border-radius: 5px;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 0.68rem;
  flex: 0 0 auto;
  line-height: 1;
}

.platform-badge.steam { background: var(--steam-soft); color: var(--steam); }
.platform-badge.playstation { background: var(--playstation-soft); color: var(--playstation); }
.platform-badge.epic { background: var(--epic-soft); color: var(--epic); }
.platform-badge.nintendo { background: var(--nintendo-soft); color: var(--nintendo); }
.platform-badge.xbox { background: var(--xbox-soft); color: var(--xbox); }
.platform-badge.blizzard { background: var(--blizzard-soft); color: var(--blizzard); }
.platform-badge.pc, .platform-badge.unknown { background: rgba(179, 162, 196, 0.14); color: var(--text-muted); }
.platform-badge svg { display: block; }

/* ---------- Calendar grid ---------- */

.calendar-nav {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin-bottom: 1.25rem;
  flex-wrap: wrap;
}

/* Play Next's List View | Calendar View toggle. It had no margin, and on
   Calendar View the month pickers sat directly under it -- two rows of
   same-sized buttons touching, which read as one control rather than the
   tab's nav above the pane's own controls. The gap is what says they are
   different levels. */
.playnext-nav {
  margin-bottom: 1.75rem;
}

.calendar-nav select {
  font-family: var(--font-body);
  font-size: 0.85rem;
  color: var(--text);
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.5rem 0.7rem;
}

.calendar-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 4px;
  background: var(--hairline);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  overflow: hidden;
}

.calendar-weekday {
  background: var(--bg-panel-raised);
  color: var(--text-faint);
  font-family: var(--font-mono);
  font-size: 0.68rem;
  text-transform: uppercase;
  text-align: center;
  padding: 0.5rem 0;
}

.calendar-day {
  background: var(--bg-panel);
  min-height: 90px;
  padding: 0.4rem;
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
}

.calendar-day-empty { background: var(--bg); }

.calendar-day.has-games {
  cursor: pointer;
}
.calendar-day.has-games:hover { background: var(--bg-panel-raised); }

.calendar-day.is-today .calendar-day-number {
  color: var(--accent);
  font-weight: 700;
}

.calendar-day-number {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--text-muted);
}

.calendar-day-detail {
  margin-top: 1.5rem;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 1.25rem 1.5rem;
}

.calendar-day-detail h3 {
  font-family: var(--font-display);
  font-size: 1.2rem;
  text-transform: uppercase;
  margin: 0 0 0.75rem;
}

.calendar-day-detail ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.calendar-day-detail li {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-size: 0.9rem;
}

/* ---------- Top 100 view toggle ---------- */

.view-toggle {
  display: inline-flex;
  border: 1px solid var(--hairline);
  border-radius: 6px;
  overflow: hidden;
}

/* THE SAME TYPE AS THE SUB-NAV TRAY: 0.84rem, weight 500, a hair of
   tracking, muted until selected. Luke liked the tray's buttons and
   asked for the in-page toggles to match -- one voice for every control
   that picks between views, whatever shape its container is. */
.view-toggle-btn {
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 0.84rem;
  letter-spacing: 0.01em;
  color: var(--text-muted);
  background: var(--bg-panel);
  border: none;
  padding: 0.5rem 0.9rem;
  cursor: pointer;
  transition: color 0.2s ease, background 0.2s ease;
}
.view-toggle-btn:hover { color: var(--text); }

/* NEUTRAL, NOT GOLD. Every in-page toggle -- All games | Owned, Top 5 |
   Top 100, By rating | By year -- lit its selected segment in gold, on a
   page where the site tab, the sub-view underline and the primary button
   were gold too. With everything highlighted nothing was. Gold now means
   navigation and the one primary action; a selected segment is a lighter
   fill and full-strength text, the way a segmented control reads on any
   well-made site. */
/* GLASS, matching the tray's sliding pill: a translucent white with a lit
   top edge and white semibold text. The boxes stay rectangular; only the
   selected segment's fill and type follow the tray. */
.view-toggle-btn.is-active {
  background: rgba(255, 255, 255, 0.1);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16);
  color: var(--text);
  font-weight: 600;
}

/* A TAB'S SUB-NAV IS TABS, NOT A TOGGLE. #gamelib-nav and #playnext-nav
   used the same boxed segmented control as the in-page toggles below
   them -- Top 5 | Top 200, All games | Owned -- so a row that changes
   WHERE you are looked identical to a row that changes HOW this pane is
   shown, one above the other. Underlined text, the shape of navigation,
   keeps the boxes for the controls.

   Higher specificity than the phone layer's `.view-toggle-btn` sizing on
   purpose: that rule shrinks buttons to fit a filter row, and these are
   not in one. The phone layer has its own rule for these at the end. */
.gamelib-nav,
.playnext-nav {
  display: flex;
  gap: 1.75rem;
  border: none;
  border-bottom: 1px solid var(--hairline);
  border-radius: 0;
  /* Scrolls rather than wraps if a narrow screen cannot fit the row --
     a tab row on two lines reads as two rows of tabs. */
  overflow-x: auto;
  scrollbar-width: none;
}
.gamelib-nav .view-toggle-btn,
.playnext-nav .view-toggle-btn {
  background: none;
  padding: 0.55rem 0;
  font-size: 0.92rem;
  white-space: nowrap;
  color: var(--text-muted);
  /* The underline sits ON the row's hairline rather than below it, so the
     active tab reads as a piece of that line lit up. */
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
}
.gamelib-nav .view-toggle-btn:hover,
.playnext-nav .view-toggle-btn:hover {
  color: var(--text);
}
.gamelib-nav .view-toggle-btn.is-active,
.playnext-nav .view-toggle-btn.is-active {
  background: none;
  color: var(--accent);
  /* NO LIT TOP EDGE, AND A SOFTER UNDERLINE (Luke, 23 Sep: "I hate the
     top line ... slightly less glow"). The glass highlight on
     .view-toggle-btn.is-active -- an inset white line along the top -- is
     right for a boxed toggle and wrong on an underlined tab, where it drew
     a stray line over the word. The underline stays, at 80%, a notch
     quieter than full amber. */
  box-shadow: none;
  border-bottom-color: rgba(var(--act-rgb), 0.8);
}

/* ---------- ONE ACCENT PER VIEW (experiment, 22 Sep) ----------------

   Each view has its own colour, and within that view every outline,
   focus ring and highlight uses it -- Luke: the buttons went "from purple
   to yellow", which was jarring. --va is the view's accent, --va-rgb the
   same as a triplet for translucent washes, --va-edge the same as an
   image for the two-box outline technique. --va-edge is defined HERE on
   each element rather than once on :root, because a custom property that
   references var() resolves where it is declared -- on :root it would
   have frozen at the root's colour for every view.

     (Per-view colours were tried first and replaced with one per tab --
     see the block below.)

   The menu buttons carry the same variables, so an item and the view it
   opens are visibly one colour. */
/* ONE COLOUR EVERYWHERE: amber (Luke, 22 Sep: "they should all be
   yellow"). This went per view (seven colours), then per tab (Games amber,
   Play Next violet, Notes teal), and settled on one. The variables stay --
   --va, --va-rgb, --va-light/-deep/-ink for the arcade buttons -- so a view
   can take its own colour again with one rule, but today every pane and
   every side-menu item reads the same amber. */
#view-library, #view-top100, #view-overview,
#view-upnext, #view-calendar, #view-notes, #view-profile,
.side-subnav .view-toggle-btn {
  --va: var(--act); --va-rgb: var(--act-rgb);
  --va-light: var(--act-light); --va-deep: var(--act-deep); --va-ink: var(--act-ink);
}
#view-library, #view-top100, #view-overview, #view-upnext, #view-calendar, #view-notes, #view-profile {
  --va-edge: linear-gradient(var(--va), var(--va));
}

/* ---------- Analysis, in the new format (experiment, 22 Sep) ----------

   ONE COLOUR FOR THE VIEW -- its tab's, Games amber -- where each of the eight
   chart panels had its own accent: a tinted ground, a coloured top edge,
   a radial corner wash and a coloured dot. That was the per-section
   colour the other views have just moved away from, and the corner wash
   was a gradient. The panels now match the Library's table: a plain
   panel, 14px corners, a soft lift. The title's dot went too.

   THE CHARTS KEEP THEIR COLOURS. Bars, slices and lines need several
   colours to tell data apart; only the chrome around them changes.

   Scoped to #view-overview and written at higher specificity than the
   .overview-chart-block rules, so those stay intact for reverting. */
#view-overview .overview-chart-block {
  background: var(--bg-panel);
  border: 1px solid rgba(255, 255, 255, 0.06);
  border-radius: 14px;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.04),
    0 12px 32px rgba(0, 0, 0, 0.28);
}
#view-overview .overview-chart-block::before { content: none; }
/* Level with the side menu, whose first item sits 0.75rem down -- the
   first panel started 12px above it. */
#view-overview { padding-top: 0; }
/* Titles in Inter at a clean weight, not the condensed display face --
   the other views' headings went the same way. */
#view-overview .overview-chart-title {
  font-family: var(--font-body);
  font-size: 1.15rem;
  font-weight: 700;
  letter-spacing: 0;
}
/* No dot before the title (Luke). It named each panel's own colour when
   every panel had one; with one colour for the view it was decoration. */
#view-overview .overview-chart-title::before { content: none; }

/* Toggles: the outlined buttons from Your Top Games -- hairline at rest,
   the view's colour on hover edge, its outline when selected, no fill. */
#view-overview .view-toggle {
  display: inline-flex;
  gap: 0.4rem;
  border: none;
  border-radius: 0;
  overflow: visible;
}
#view-overview .view-toggle .view-toggle-btn {
  height: 2rem;
  border-radius: 10px;
  border: 1px solid transparent;
  background:
    linear-gradient(var(--bg-panel), var(--bg-panel)) padding-box,
    linear-gradient(var(--hairline), var(--hairline)) border-box;
  color: var(--text-muted);
  transition: color 0.15s ease;
}
#view-overview .view-toggle .view-toggle-btn:hover {
  color: var(--text);
  background:
    linear-gradient(var(--bg-panel), var(--bg-panel)) padding-box,
    linear-gradient(rgba(var(--va-rgb), 0.6), rgba(var(--va-rgb), 0.6)) border-box;
}
#view-overview .view-toggle .view-toggle-btn.is-active,
#view-overview .view-toggle .view-toggle-btn.is-active:hover {
  color: var(--text);
  font-weight: 600;
  background:
    linear-gradient(var(--bg-panel), var(--bg-panel)) padding-box,
    var(--va-edge) border-box;
  box-shadow: none;
}

/* Dropdowns: the Library's filters. */
#view-overview select {
  height: 2.1rem;
  padding: 0 0.7rem;
  border-radius: 10px;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  color: var(--text);
  font-size: 0.82rem;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
#view-overview select:hover { border-color: rgba(var(--va-rgb), 0.6); }
#view-overview select:focus-visible {
  outline: none;
  border-color: var(--va);
  box-shadow: 0 0 0 3px rgba(var(--va-rgb), 0.22);
}

/* HOURS / GAMES / AVG LENGTH / AVG DAYS: the outlined toggles, not the
   old solid-gold pills -- the last controls on the page still in the
   pre-redesign style. Scoped here: .length-chip is also Play Next's
   Refine chips. */
#overview-metrics .length-chip {
  height: 2rem;
  padding: 0 0.85rem;
  border-radius: 10px;
  border: 1px solid var(--hairline);
  background: var(--bg-panel);
  color: var(--text-muted);
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.03em;
  transition: color 0.15s ease, border-color 0.15s ease;
}
#overview-metrics .length-chip:hover {
  color: var(--text);
  border-color: rgba(var(--va-rgb), 0.6);
}
#overview-metrics .length-chip.is-active {
  color: var(--text);
  background: var(--bg-panel);
  border-color: var(--va);
}

/* Rows that light under the cursor: the view's colour, faintly. */
#view-overview .multiplayer-list-item:hover {
  background: rgba(var(--va-rgb), 0.08);
}

/* ---------- Notes, in the new format (22 Sep) -------------------------

   Amber, like every view. The heading goes screen-reader-only, like every other
   view's: "Your notes" sat under a lit "Notes" tab saying the same thing.
   + New note is the arcade button Library's + Add game is. Each game is a
   lifted card like the Library table, and the row you point at takes the
   amber wash and bar. Every row carries its own outlined "Add note", far
   right. Currently-playing games sort to the top (app.js) and wear a small
   "Playing" tag so the order explains itself. */
#view-notes .notes-title {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
}
#view-notes { padding-top: 0; }
#view-notes .notes-toolbar { margin-bottom: 1.1rem; }
/* Bigger (Luke): 2.75rem tall at 1rem type, the field filling the row. */
#view-notes .notes-search {
  height: 2.75rem;
  font-size: 1rem;
  border-radius: 10px;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
/* THE WRAPPER HAS TO GROW, NOT THE INPUT. At runtime every search input
   is wrapped in a span.search-wrap (for its clear button), and that span
   sized to its content -- so `.notes-toolbar .notes-search { flex: 1 }`
   applied to a child of a 231px box and "bigger" changed nothing.
   Measured: the field was 231px in an 1100px row. */
#view-notes .notes-toolbar > .search-wrap { flex: 1 1 auto; min-width: 0; }
#view-notes .notes-toolbar > .search-wrap .notes-search { width: 100%; }
#view-notes .notes-search:hover { border-color: rgba(var(--va-rgb), 0.6); }
#view-notes .notes-search:focus {
  border-color: var(--va);
  box-shadow: 0 0 0 3px rgba(var(--va-rgb), 0.22);
}
#view-notes .notes-new {
  display: inline-flex;
  align-items: center;
  height: 2.75rem;
  padding: 0 1.05rem;
  border: none;
  border-radius: 10px;
  font-size: 0.88rem;
  font-weight: 700;
  color: var(--va-ink);
  background: var(--va);
  box-shadow: 0 3px 0 var(--va-deep), inset 0 1px 0 rgba(255, 255, 255, 0.35);
  transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
#view-notes .notes-new:hover {
  transform: translateY(1px);
  background: var(--va-light);
  box-shadow: 0 2px 0 var(--va-deep), inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
#view-notes .notes-new:active {
  transform: translateY(3px);
  box-shadow: 0 0 0 var(--va-deep), inset 0 1px 0 rgba(255, 255, 255, 0.3);
}

#view-notes .notes-game {
  border: 1px solid rgba(255, 255, 255, 0.06);
  border-radius: 14px;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.04),
    0 10px 28px rgba(0, 0, 0, 0.25);
}
#view-notes .notes-game[open] { border-color: rgba(var(--va-rgb), 0.35); }
#view-notes .notes-game-head { transition: background 0.15s ease; }
#view-notes .notes-game-head:hover {
  background: rgba(var(--va-rgb), 0.07);
  box-shadow: inset 3px 0 0 var(--va);
}
#view-notes .notes-game[open] .notes-game-head {
  border-bottom-color: rgba(255, 255, 255, 0.06);
}
#view-notes .notes-game-cover { border-radius: 6px; }
/* The name takes only its own width, so the Playing tag sits right after
   it; the count's auto margin is what pushes the right-hand group over. */
#view-notes .notes-game-name { flex: 0 1 auto; }
#view-notes .notes-game-playing {
  flex: none;
  padding: 0.15rem 0.5rem;
  border-radius: 999px;
  font-size: 0.7rem;
  font-weight: 600;
  letter-spacing: 0.03em;
  color: var(--va);
  background: rgba(var(--va-rgb), 0.14);
}
#view-notes .notes-game-count {
  margin-left: auto;
  font-family: var(--font-body);
}
#view-notes .notes-add-btn {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  height: 2rem;
  padding: 0 0.8rem;
  border-radius: 10px;
  border: 1px solid var(--hairline);
  background: var(--bg-panel);
  color: var(--text-muted);
  font-family: var(--font-body);
  font-size: 0.8rem;
  font-weight: 600;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease;
}
#view-notes .notes-add-btn:hover {
  color: var(--text);
  border-color: var(--va);
}
#view-notes .notes-add-icon {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: var(--va);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
#view-notes .notes-entry {
  border-bottom-color: rgba(255, 255, 255, 0.05);
}

/* ---------- Play Next, in the new format (22 Sep) ---------------------

   Suggestions and Gaming Calendar take the same treatment as Games:
   the view's colour (--va) for every accent, outlined controls, an arcade
   button for the page's one big action, and headings in the clean face.
   Scoped to the two panes. */

#view-upnext { padding-top: 0; }

/* "Pick one for me" leads, left -- like + Add game -- rather than floating
   alone at the far right edge of an otherwise empty line. */
/* THE REFINE PANEL OPENS BELOW THE BUTTONS, NOT BESIDE THEM. It is a
   child of the headline row, so on desktop it sat to the right of "Pick
   one for me" and took the width -- squeezing both buttons onto two
   lines ("Pick one for / me") while the chips sprawled across 1100px
   (Luke, 23 Sep: "blows up way too much"). Wrapping puts it on its own
   line; the max-width keeps a row of chips to a readable span. */
/* A COLUMN, not a wrapping row: with a max-width the panel still fitted
   beside the buttons, so wrap never fired. Stacked, it always opens under
   them, at up to 44rem. */
#view-upnext .upnext-headline {
  flex-direction: column;
  align-items: flex-start;
  gap: 0.75rem;
  margin-bottom: 1.4rem;
}
#view-upnext .upnext-controls .btn,
#view-upnext .upnext-refine-toggle { white-space: nowrap; }
#view-upnext .upnext-refine {
  width: 100%;
  max-width: 44rem;
  margin-top: 0;
}
#view-upnext .upnext-headline > div:empty { display: none; }
#view-upnext #upnext-random-btn {
  display: inline-flex;
  align-items: center;
  height: 2.4rem;
  padding: 0 1.1rem;
  border: none;
  border-radius: 10px;
  font-size: 0.9rem;
  font-weight: 700;
  color: var(--va-ink);
  background: var(--va);
  box-shadow: 0 3px 0 var(--va-deep), inset 0 1px 0 rgba(255, 255, 255, 0.35);
  transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
#view-upnext #upnext-random-btn:hover {
  transform: translateY(1px);
  background: var(--va-light);
  box-shadow: 0 2px 0 var(--va-deep), inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
#view-upnext #upnext-random-btn:active {
  transform: translateY(3px);
  box-shadow: 0 0 0 var(--va-deep), inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
/* Refine: an outlined button, not an underlined caps link. */
#view-upnext .upnext-refine-toggle {
  height: 2.4rem;
  padding: 0 1rem;
  border: 1px solid var(--hairline);
  border-radius: 10px;
  background: var(--bg-panel);
  font-family: var(--font-body);
  font-size: 0.88rem;
  font-weight: 600;
  text-transform: none;
  letter-spacing: 0;
  text-decoration: none;
  color: var(--text-muted);
  transition: color 0.15s ease, border-color 0.15s ease;
}
#view-upnext .upnext-refine-toggle:hover,
#view-upnext .upnext-refine-toggle[aria-expanded="true"] {
  color: var(--text);
  border-color: var(--va);
}
#view-upnext .upnext-refine {
  border-color: rgba(255, 255, 255, 0.06);
  border-radius: 14px;
  background: var(--bg-panel);
}
/* The refine chips: outlined toggles, the view's colour when chosen --
   the same as Analysis's metric pills, not solid-gold capsules. */
#view-upnext .length-chip {
  height: 2rem;
  padding: 0 0.8rem;
  border-radius: 10px;
  font-family: var(--font-body);
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: none;
  letter-spacing: 0;
  background: var(--bg-panel);
}
#view-upnext .length-chip:hover { border-color: rgba(var(--va-rgb), 0.6); }
#view-upnext .length-chip.is-active {
  color: var(--text);
  background: var(--bg-panel);
  border-color: var(--va);
}

/* Row headings in the clean face, sentence case -- "Releasing soon", not
   "RELEASING SOON" in the condensed display type. */
#view-upnext .card-row-title {
  font-family: var(--font-body);
  font-size: 1.15rem;
  font-weight: 700;
  text-transform: none;
  letter-spacing: 0;
  margin-bottom: 0.2rem;
}
#view-upnext .card-row-block { margin-bottom: 1.6rem; }
/* A cover you point at lifts and takes a ring of the view's colour. */
#view-upnext .activity-card:hover .activity-card-cover-wrap {
  box-shadow: 0 0 0 2px var(--va), 0 10px 24px -8px rgba(0, 0, 0, 0.6);
}
#view-upnext .upnext-pick {
  border-color: rgba(var(--va-rgb), 0.45);
  border-radius: 14px;
  background: var(--bg-panel);
}

/* Gaming Calendar */
#view-calendar { padding-top: 0; }
#view-calendar .calendar-nav .btn,
#view-calendar .calendar-nav select {
  height: 2.25rem;
  border-radius: 10px;
  border: 1px solid var(--hairline);
  background: var(--bg-panel);
  color: var(--text);
  transition: border-color 0.15s ease;
}
#view-calendar .calendar-nav .btn:hover,
#view-calendar .calendar-nav select:hover { border-color: rgba(var(--va-rgb), 0.6); }
#view-calendar .calendar-nav select:focus-visible {
  outline: none;
  border-color: var(--va);
  box-shadow: 0 0 0 3px rgba(var(--va-rgb), 0.22);
}
#view-calendar .calendar-grid {
  border-radius: 14px;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
}
#view-calendar .calendar-day.is-today .calendar-day-number { color: var(--va); }
/* TODAY IS OUTLINED (Luke, 29 Sep) in the menu's pink (--menu, "you are
   in this" -- today is the day you are in), with the number keeping the
   calendar's own accent (--va) so the two read as ring and label rather
   than one block of colour. Not the gold, which marks the tab you are on.
   An inset ring, so the cell keeps its size and the grid's lines do not
   move. */
#view-calendar .calendar-day.is-today { box-shadow: inset 0 0 0 1.5px var(--menu); }
#view-calendar .calendar-day.has-games:hover { background: rgba(var(--va-rgb), 0.08); }
#view-calendar .calendar-day-detail {
  border-color: rgba(255, 255, 255, 0.06);
  border-radius: 14px;
}

/* The menu's icons are a desktop thing: the phone keeps its text row
   (Luke, 25 Sep: "phone feels fine right now"). Shown in the block below. */
.side-subnav .subnav-ico { display: none; }
/* A FILLED CUP (Luke, 29 Sep: the trophy "is missing in the middle"). The
   icons are stroked outlines, which left the cup hollow; the one path that
   should read as solid says so. A class rather than a fill attribute,
   because the rules that style these icons set fill: none on the svg and
   a stylesheet beats a presentation attribute. */
.subnav-ico .ico-fill { fill: currentColor; }

/* ---------- A TAB'S VIEWS AS A SIDE MENU (experiment, 22 Sep) ---------

   The side menu came back for a second look, in the newer formatting.
   A column down the left of the tab: grey text at rest, brighter on
   hover, and the selected view as a flat glass fill with a solid amber
   bar down its left edge -- amber as the one accent, no gradient.

   `:not([hidden])` IS LOAD-BEARING. display: grid on the bare id would
   beat the UA's [hidden] rule and show the tab on every page.

   Below 641px the phone layer's scrolling row applies: a phone has no
   width to give a column. */
@media (min-width: 641px) {
  #view-gamelibrary:not([hidden]),
  #view-playnext:not([hidden]) {
    display: grid;
    /* 9rem + 1.25rem = 10.25rem, which a 1600px window's margin can hold
       whole -- at 10rem + 1.75rem the content sat 24px right of every
       other tab's at that width. "Your Top Games" still fits. */
    /* 11rem / 1.6rem since the cartridges (25 Sep): each carries an
       icon, and the chosen one moves 10px toward the content -- "Your Top
       Games" needs ~155px with its icon, and 9rem clipped it. */
    grid-template-columns: 11rem minmax(0, 1fr);
    /* 2.4rem, was 1.6rem (Luke, 25 Sep): the chosen cartridge pulls 10px
       toward the content, so at 1.6rem it sat ~16px from the Tiers covers
       and read as crowding them. */
    column-gap: 2.4rem;
    align-items: start;
    /* THE MENU IS ANCHORED TO THE SCREEN'S LEFT EDGE (Luke, 25 Sep).

       --gutter is the distance from the window's edge to the page's
       content box (main: 1240px centred, 0.6rem inside). The tab is pulled
       left by the menu column plus its gap where the margin allows
       (--pull), so the CONTENT lines up with every other tab's again --
       the one-deploy centring put it ~100px right of them -- and the menu
       itself is pulled the rest of the way to x = 0 (below). On a wide
       window that opens a gap between menu and content, which is the
       point: the menu is a rack on the edge of the screen, not a column
       floating in the page. */
    /* --vw, not 100vw (29 Sep): 100vw counts the scrollbar, so the rack
       sat half a scrollbar -- 7.5px on Windows -- past the window's edge
       at every width, clipping the "Library" card. --vw is the page's
       real width, set by setViewportWidth in app.js. */
    --gutter: max(0.6rem, calc((var(--vw, 100vw) - var(--page-w, 1240px)) / 2 + 0.6rem));
    --pull: min(13.4rem, calc(var(--gutter) - 0.6rem));
    margin-left: calc(-1 * var(--pull));
  }
  #view-gamelibrary > .side-subnav,
  #view-playnext > .side-subnav {
    grid-column: 1;
    grid-row: 1;
    display: flex;
    flex-direction: column;
    gap: 0.5rem; /* cartridges sit apart in the rack (25 Sep); was 0.2rem */
    /* To the window's edge: whatever of the gutter the pull did not use.
       A fixed width, so a wide window widens the gap, not the cards. */
    /* No top margin (28 Sep): it matched the 0.75rem each pane used to
       start with, and those went with the tighter page top -- the rack
       and the pane's first line still start level. */
    margin: 0 0 0 calc(-1 * (var(--gutter) - var(--pull)));
    width: 11.5rem;
    justify-self: start;
    padding: 0;
    border: none;
    overflow: visible;
    /* Stays in view while the pane scrolls, just under the docked nav. */
    position: sticky;
    top: 4.5rem;
  }
  #view-gamelibrary > .gamelib-pane,
  #view-playnext > .playnext-pane {
    grid-column: 2;
    grid-row: 1;
    min-width: 0;
  }
  /* A TAB'S VIEWS AS CARTRIDGES (Luke, 25 Sep).

     The menu read as page furniture -- grey text and a glow -- and Luke
     wanted it to stand out. Five designs were built and compared on the
     real page (a sliding shelf, these cartridges, and three rows under the
     site tabs); the top rows stacked three bands of navigation in two
     colours and read as a clash, and these won.

     Each view is a card with a grip strip down its left edge and an icon.
     At rest they sit tucked into the rack; the chosen one pulls OUT, 10px
     toward the content, lit in the menu's colour, and the content slides
     out of it (initCartridgeSlide in app.js).

     PINK, NOT THE AMBER EVERYTHING ELSE WEARS. The sunset's stops each
     have a job (see :root): pink is "you are in this", which is exactly
     what a selected view is. Amber stays the colour of the tabs and of
     things you act on, so the menu is distinct without being a new
     colour to the site. Declared here, at desktop widths, so the phone's
     row keeps the amber it had. */
  .side-subnav,
  .side-subnav .view-toggle-btn {
    --va: var(--menu); --va-rgb: var(--menu-rgb);
    --va-light: var(--menu-light); --va-deep: var(--menu-deep); --va-ink: var(--menu-ink);
  }
  .side-subnav .view-toggle-btn {
    position: relative;
    display: flex;
    align-items: center;
    gap: 0.65rem;
    height: 3.1rem;
    margin: 0;
    /* 1.4rem, from 1.05 (Luke, 28 Sep: "a little too close to the left
       edge"). The card sits 6px into the screen edge (translateX below)
       and the grip strip takes the first 0.35rem, so 1.05 left the icon
       about 11px from the edge of the screen. */
    padding: 0 0.8rem 0 1.4rem;
    text-align: left;
    /* Square and borderless on the left: the card comes OUT OF the screen
       edge (anchored there, above), so there is no left side to draw. */
    border-radius: 0 12px 12px 0;
    font-size: 0.88rem;
    font-weight: 600;
    color: var(--text-muted);
    border: 1px solid transparent;
    border-left-width: 0;
    background:
      linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0)) padding-box,
      linear-gradient(var(--bg-panel), var(--bg-panel)) padding-box,
      linear-gradient(var(--hairline), var(--hairline)) border-box;
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
    transform: translateX(-6px);
    transition: transform 0.4s cubic-bezier(0.34, 1.4, 0.64, 1),
      box-shadow 0.3s ease, color 0.2s ease, background 0.3s ease;
  }
  /* The grip strip, grey at rest. */
  .side-subnav .view-toggle-btn::before {
    content: "";
    position: absolute;
    left: 0.35rem;
    top: 0.55rem;
    bottom: 0.55rem;
    width: 3px;
    border-radius: 3px;
    background: rgba(255, 255, 255, 0.14);
    transition: background 0.3s ease, box-shadow 0.3s ease;
  }
  .side-subnav .subnav-ico {
    display: block;
    width: 17px;
    height: 17px;
    flex: 0 0 auto;
    fill: none;
    stroke: var(--text-muted);
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
    transition: stroke 0.2s ease;
  }
  /* Hover: half out of the rack, icon in the menu's colour. */
  .side-subnav .view-toggle-btn:hover {
    color: var(--text);
    transform: translateX(-2px);
  }
  .side-subnav .view-toggle-btn:hover .subnav-ico { stroke: var(--va); }
  /* SELECTED: pulled out, lit edge, lit strip. */
  .side-subnav .view-toggle-btn.is-active,
  .side-subnav .view-toggle-btn.is-active:hover {
    color: var(--text);
    font-weight: 700;
    /* TRANSPARENT, all four sides. The edge is the border-box background
       below showing through; the generic ".gamelib-nav .view-toggle-btn
       .is-active" gives the phone row its amber underline, and without
       this it drew an amber line under the chosen cartridge (Luke). The
       old side menu had "border-bottom: none" here for the same reason. */
    border: 1px solid transparent;
    border-left-width: 0;
    transform: translateX(10px);
    background:
      linear-gradient(90deg, rgba(var(--va-rgb), 0.14), rgba(var(--va-rgb), 0.02) 70%) padding-box,
      linear-gradient(var(--bg-panel-raised), var(--bg-panel-raised)) padding-box,
      linear-gradient(90deg, var(--va), rgba(var(--va-rgb), 0.35)) border-box;
    box-shadow: 0 10px 26px rgba(0, 0, 0, 0.4), -6px 0 18px -8px rgba(var(--va-rgb), 0.55);
  }
  .side-subnav .view-toggle-btn.is-active::before {
    background: var(--va);
    box-shadow: 0 0 10px rgba(var(--va-rgb), 0.7);
  }
  .side-subnav .view-toggle-btn.is-active .subnav-ico { stroke: var(--va); }
  /* Reduced motion: the site-wide block (* transition-duration) covers the
     pull-out; initCartridgeSlide skips the content slide itself. */

  /* THE NAV PINS TO THE TOP WHEN YOU SCROLL, at every desktop width. It
     used to do this only below 1368px, becoming a floating icon rail in
     the left margin above that -- see "The nav docks when you scroll".
     The phone has its own bottom bar. `body.nav-docked` is set by
     dockNavOnScroll() once the banner's nav has scrolled past. */
  .nav-docked .view-tabs {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 26;
    margin: 0;
    /* --vw for the scrollbar reason above. */
    padding: 0.4rem max(1rem, calc((var(--vw, 100vw) - 1240px) / 2));
    background: rgba(var(--bar-rgb), 0.92);
    backdrop-filter: blur(8px);
    border-bottom: 1px solid var(--hairline);
  }
  /* Content would jump up by the bar's height the moment it goes fixed, so
     the space it left is held open. */
  .nav-docked main { padding-top: calc(var(--main-pad-top) + 3rem); }

  /* Profile's glass -- desktop only since 29 Sep; the phone gets a solid
     fill (the max-width: 640px block). Moved out of the base rules so
     neither width has to out-rank the other. */
  #view-profile .profile-tile,
  #view-profile #profile-side {
    background: color-mix(in srgb, var(--bg-panel) 40%, transparent);
    -webkit-backdrop-filter: blur(12px);
    backdrop-filter: blur(12px);
  }
}

/* THE SUB-NAV IN THE BANNER, under the site tabs. The second level of
   the site's navigation rather than a row inside the page -- it was a
   row above the content, then a side menu beside it, and in both places
   it read as page furniture you only found by opening the tab.

   Smaller and quieter than the tabs above it, so the two rows read as
   parent and child; the active item keeps the gold underline, which is
   what marks a tab. No hairline under the row: the banner's own bottom
   border is the line.

   Scoped under .banner-subnav-row so these beat the plain .gamelib-nav /
   .playnext-nav rules above on specificity rather than on source order.
   switchToView sets margin-left to hang the row under its parent tab. */
.banner-subnav-row {
  margin-top: 0.35rem;
}
.banner-subnav-row[hidden],
.banner-subnav-row .banner-subnav[hidden] { display: none; }

/* THE SUB-NAV IS A TRAY THAT HANGS FROM ITS TAB.

        [ Game Library ]
              /\
   ( Library  Your Top Games  Tiers  Gaming Tiles )

   A dark rounded capsule whose top edge glows gold under the parent tab
   (the seam, below) -- "these are inside that tab". It replaced a
   hairline with a gold chevron, then a tooltip notch; Luke found the
   first lame and the second odd, and neither showed well on the banner.

   switchToView centres it on the tab (margin-left) and sets --caret-x to
   where that tab's centre falls inside it, so the glow is under the tab
   even when the tray had to be pushed in from the window's edge. */
.banner-subnav-row .banner-subnav {
  --caret-x: 50%;
  position: relative;
  display: flex;
  gap: 0.15rem;
  width: max-content;
  max-width: 100%;
  /* Room for the seam's glow to read as light between tab and tray. At
     0.55rem the tray crowded the tab's pill. */
  margin: 0.85rem 0 0;
  padding: 0.25rem;
  border: 1px solid var(--hairline);
  border-radius: 999px;
  background: var(--bg-panel);
  box-shadow:
    0 10px 30px rgba(0, 0, 0, 0.35),
    inset 0 1px 0 rgba(255, 255, 255, 0.04);
  /* VISIBLE, not scrolling: the seam's glow sits above the tray's top
     edge and overflow of any kind would clip it. The phone layer restores
     scrolling, where there is no seam. */
  overflow: visible;
}

/* THE SEAM: a solid amber segment on the tray's top edge, under the
   parent tab -- where the tray came from. (It was a gradient fading out
   to either side, with a glow; now solid, like everything but the
   wordmark.)

   It replaced a tooltip notch -- a 10px rotated square -- which at that
   size read as a stray diamond butting into the tab's pill, and could
   barely be seen. A line the full width of the tray, brightest at
   --caret-x, is impossible to miss and has no corners to look odd. */
.banner-subnav-row .banner-subnav::before {
  content: "";
  position: absolute;
  top: -1px;
  /* A SOLID amber segment, 90px, centred under the parent tab. It was a
     radial gradient fading out along the whole top edge, with a blurred
     glow beneath -- no gradients outside the wordmark now (Luke). */
  left: calc(var(--caret-x) - 45px);
  width: 90px;
  height: 2px;
  border-radius: 2px;
  background: var(--sun-amber);
  pointer-events: none;
}
.banner-subnav-row .banner-subnav::after { content: none; }

/* THE SLIDING PILL behind the active view.
   One element that moves, positioned by placeSubnavIndicator, so changing
   view is a slide from one item to the next. The slight overshoot in the
   curve is the flair; everything else here holds still. */
.banner-subnav-row .subnav-indicator {
  position: absolute;
  top: 0.25rem;
  bottom: 0.25rem;
  left: 0;
  width: var(--ind-w, 0);
  transform: translateX(var(--ind-x, 0));
  border-radius: 999px;
  /* GLASS, NOT GOLD-TO-PINK. The gradient went muddy brown over the
     banner's purple -- two warm tints over a cool ground. A translucent
     white with a lit top edge stays clean on any background, and leaves
     gold to the parent tab, which is the one thing it should mark here. */
  background: rgba(255, 255, 255, 0.1);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.16),
    inset 0 0 0 1px rgba(255, 255, 255, 0.08),
    0 2px 10px rgba(0, 0, 0, 0.25);
  opacity: 0;
  transition:
    transform 0.32s cubic-bezier(0.34, 1.4, 0.5, 1),
    width 0.32s cubic-bezier(0.34, 1.4, 0.5, 1),
    opacity 0.15s ease;
  pointer-events: none;
}
.banner-subnav-row .subnav-indicator.no-slide { transition: none; }

.banner-subnav-row .banner-subnav .view-toggle-btn {
  position: relative;
  z-index: 2;
  font-size: 0.84rem;
  font-weight: 500;
  letter-spacing: 0.01em;
  padding: 0.42rem 1rem;
  margin: 0;
  border: none;
  border-radius: 999px;
  background: none;
  color: var(--text-muted);
  transition: color 0.2s ease;
}
.banner-subnav-row .banner-subnav .view-toggle-btn:hover { color: var(--text); }
/* White, not gold: the pill behind it carries the colour, and gold text on
   a gold-tinted pill would lose contrast exactly where it matters. */
.banner-subnav-row .banner-subnav .view-toggle-btn.is-active {
  background: none;
  color: var(--text);
  font-weight: 600;
}

/* A short drop-in when the row appears on switching tabs, so it reads as
   coming FROM the tab above. Class set by showBannerSubnav only when the
   row was hidden, not on every click inside it. */
@keyframes banner-subnav-in {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: none; }
}
/* Reduced motion needs no rule here: the site-wide
   prefers-reduced-motion block already cuts every animation to 0.01ms. */
.banner-subnav-row .banner-subnav.is-entering {
  animation: banner-subnav-in 0.18s ease-out;
}



/* ---------- Tier view ---------- */

.tier-group {
  margin-bottom: 2rem;
}

.tier-label {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.8rem;
  /* Plain text, not glowing gold -- the same discipline as the toggles:
     a group heading is structure, not a highlight. */
  color: var(--text);
  margin: 0 0 0.85rem;
  /* Flex so the reset can sit beside the year without the count moving.
     The heading was three inline spans and stayed that way for the two
     groupings that have no reset. */
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
}

/* ---------- Games Played Over Time (Gantt) ---------- */

/* No horizontal scroll.
   A chart you have to drag sideways to read isn't showing you the shape
   of anything -- which is the only reason to draw it. It fits the panel
   or it gets fewer years. */
.gantt-wrap {
  /* Both axes set explicitly, on purpose.
     This was `overflow-x: hidden` with overflow-y left at `visible` --
     and CSS does not allow that pair, so the browser silently promotes
     the other axis to `auto`. That produced a vertical scrollbar on the
     timeline at desktop widths, which is why it appeared to have a
     scroll nobody asked for.
     `hidden` on x also meant the timeline was CUT OFF rather than
     scrollable, despite .gantt-inner being deliberately sized past the
     viewport for exactly that reason. */
  overflow-x: auto;

  /* `auto`, not `hidden`. Nothing overflowed vertically when the note
     above was written; All years does now, and this is what lets the
     panel scroll. The scrollbar only appears when renderGanttChart sets a
     max-height. */
  overflow-y: auto;
}

.gantt-inner {
  width: 100%;
  min-width: 0;
}

/* ---------- Overview grid (four charts, Tableau-style layout) ---------- */

.overview-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
  gap: 1.5rem;
}

.overview-chart-block {
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 1.25rem 1.5rem 1.5rem;
}

/* The look of these headings is set in the block further down this file --
   see "stop every block being the same box". Only the spacing lives here,
   because it is what every other rule in this area is positioned against. */
.overview-chart-title {
  margin: 0 0 0.9rem;
}

/* NO RULE UNDER THE FIGURES. It was drawing a hairline the full width of the
   block, under three short stats that already read as a group -- a line
   separating them from a chart they are the headline for.
   The `padding-bottom` went with it: it was the gap the rule needed, not
   spacing the row wanted, and `margin-bottom` below already separates the
   row from what follows. */
.overview-summary-row {
  display: flex;
  gap: 1rem;
  margin-bottom: 1rem;
  overflow-x: auto;
}

.summary-cell {
  display: flex;
  flex-direction: column;
  align-items: center;
  min-width: 52px;
}

.summary-year {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  color: var(--text-faint);
}

.summary-value {
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: 0.95rem;
  color: var(--accent);
}

.overview-chart-block .chart-wrap {
  background: none;
  border: none;
  padding: 0;
}

/* ---------- Missing status highlighting ---------- */

.row-missing-status {
  background: rgba(255, 111, 82, 0.06);
}
.row-missing-status:hover { background: rgba(255, 111, 82, 0.1); }

.status-select-missing {
  border-color: var(--epic);
  box-shadow: 0 0 0 1px rgba(255, 111, 82, 0.25);
}

#missing-status-toggle.is-active {
  background: var(--epic-soft);
  color: var(--epic);
  border-color: var(--epic);
}

/* ---------- Up Next ---------- */

.upnext-pick {
  display: flex;
  align-items: center;
  gap: 1.25rem;
  background: var(--bg-panel-raised);
  border: 1px solid var(--gold-soft);
  border-radius: var(--radius);
  padding: 1.25rem 1.5rem;
  margin-bottom: 1.5rem;
}

.upnext-pick-cover-wrap {
  position: relative;
  width: 70px;
  height: 98px;
  flex: 0 0 auto;
}
.upnext-pick-cover-wrap .top100-cover,
.upnext-pick-cover-wrap .top100-cover-placeholder {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

/* Sentence case in the body face since #141, with the card on the same
   hairline surface as the sheets and its cover a size down. */
.upnext-pick-label {
  font-family: var(--font-body);
  font-size: 0.74rem;
  color: var(--text-muted);
  margin: 0 0 0.3rem;
}
.upnext-pick { border-color: var(--hairline); border-radius: 14px; padding: 0.9rem 1rem; gap: 0.9rem; }
.upnext-pick-cover-wrap { width: 56px; height: 78px; }

.upnext-pick-name {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.5rem;
  color: var(--text);
  text-decoration: none;
}
.upnext-pick-name:hover { color: var(--accent); }

/* ---------- Combined platform badges (multi-platform games) ---------- */

.platform-tag .platform-badge { margin-right: 0.15rem; }

/* ---------- Playthroughs list editing ---------- */

.playthroughs-block {
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 1.1rem 1.3rem;
  margin-bottom: 1.5rem;
}

.secondary-platform-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.1rem;
  text-transform: uppercase;
  margin: 0;
}

/* The playthrough headline: "Finished on your third attempt, over 3
   weeks".
   Given real weight on purpose. This is the sentence the app exists to
   be able to write -- it comes from achievement timestamps nobody else
   collects, and it's the line most worth screenshotting. Treating it as
   another muted meta row would waste it.
   The detail page is text-heavy and can take stronger treatment; the
   profile is mostly cover art and can't. Different problems. */
.playthrough-summary {
  font-family: var(--font-display);
  font-size: 1.05rem;
  font-weight: 700;
  color: var(--accent);
  margin: 0 0 0.85rem;
  line-height: 1.3;
}

.playthrough-row {
  border-top: 1px solid var(--hairline);
  padding-top: 1rem;
  margin-top: 1rem;
}
.playthrough-row:first-of-type {
  border-top: none;
  padding-top: 0;
  margin-top: 0.75rem;
}

.playthrough-row .btn {
  margin-top: 0.5rem;
}

/* ---------- Field hints ---------- */

.field-hint {
  font-size: 0.7rem;
  color: var(--text-faint);
  font-weight: 400;
}

th[title] {
  cursor: help;
}

/* ---------- Scrollbars on the rows that scroll ---------- */

/* THE BAR IS THE OPERATING SYSTEM'S, AND IT LOOKS LIKE IT. A macOS
   scrollbar laid over a row of cover art is a light slab with a grey thumb
   sitting in a dark purple page: it reads as window furniture that has
   landed on the content rather than as part of it. On a machine set to
   "Show scroll bars: Always" -- or any machine with a mouse plugged in --
   it is there the whole time, under every row at once.

   So the bar keeps its space and stays draggable, and is TRANSPARENT until
   the pointer is over the row it belongs to. Nothing moves when it
   appears, because nothing is being added: the track is already in the
   layout and only its colour changes.

   `scrollbar-color` / `scrollbar-width` rather than `::-webkit-scrollbar`.
   Setting either standard property makes Chrome ignore the pseudo-element
   rules entirely, so writing both is writing one of them for nothing --
   and the standard pair is the half Firefox reads. Supported everywhere
   this runs since Safari 18.2.

   `:focus-within` as well as `:hover`, because a row tabbed into scrolls
   too, and a bar that answers only the mouse is not an affordance.

   `@media (hover: hover)` because a phone has no pointer to reveal it
   with. There the bar would be transparent forever, which takes away the
   one moving thing that says a row scrolls -- and on touch you drag the
   content itself, so the bar was never the handle. Phones keep the native
   behaviour.

   NOT `.gantt-wrap`. Its scrollbar is the VERTICAL one, and it is the only
   thing on screen saying the timeline continues past the bottom of the
   panel. That one is a cue, not furniture. `.view-tabs` and `.gantt-axis`
   hide theirs outright and still should: the nav fades at its edge
   instead, and the axis is scrolled by the chart above it rather than by
   hand. */
@media (hover: hover) {
  /* The queue's x and swap, on the cover you are pointing at (#150; the
     rule they come out of, by .queue-card-btn, says why). */
  .queue-card .queue-card-btn { opacity: 0; }
  .queue-card:hover .queue-card-btn,
  .queue-card .queue-card-btn:focus-visible,
  .queue-card.is-target .queue-card-btn { opacity: 1; }

  /* A quiet rating cell (#138) shows its box when the row is pointed at. */
  #library-table tbody tr:hover .rating-cell.is-quiet .rating-input { opacity: 1; }

  /* The Top 5's x, on the cover you are pointing at (#144; the rule it
     came out of, by .top5-tile-remove, says why it is in here). */
  .top5-tile:hover .top5-tile-remove {
    opacity: 1;
    visibility: visible;
  }

  .card-row,
  .profile-tiles,
  .table-wrap,
  .overview-summary-row {
    scrollbar-width: thin;
    /* Thumb first, then track. Both transparent at rest. */
    scrollbar-color: transparent transparent;
    transition: scrollbar-color 0.18s ease;
  }

  /* Translucent white rather than a named colour, for the same reason the
     surfaces inside the Gameplay Analysis tiles are: these rows sit on
     several different grounds, and one rule that borrows the ground
     underneath works on all of them. A palette colour would have to be
     re-picked per surface, and the KPI strip is what happens when somebody
     forgets one. */
  /* The tile lift lives here rather than beside the tile rules, because
     there is ONE `(hover: hover)` block and this is it. It arrived in a
     different branch than the scrollbar rules above and git merged both
     cleanly -- no textual conflict, two blocks, and the invariant broken
     until tools/media-dead-decl-check.mjs said so on the next run. Nothing
     between here and its old position sets `transform` on this selector,
     so moving it up cannot change which rule wins. */
  .overview-chart-block:hover { transform: translateY(-2px); }

  .card-row:hover,
  .card-row:focus-within,
  .profile-tiles:hover,
  .profile-tiles:focus-within,
  .table-wrap:hover,
  .table-wrap:focus-within,
  .overview-summary-row:hover,
  .overview-summary-row:focus-within {
    scrollbar-color: rgba(255, 255, 255, 0.28) transparent;
  }
}

/* ---------- Horizontal-scroll activity card rows ---------- */

.card-row-block {
  margin-bottom: 2.5rem;
}

/* The note belongs to the title, not halfway to the covers. Three
   margins stacked to ~28px -- the h3's 0.75rem, `.section-head`'s
   0.9rem, the note's 0.1rem -- and none collapse, since `.section-head`
   is a flex container. Scoped to card rows: elsewhere the head is
   followed by content and needs its room. */
.card-row-block > .section-head {
  margin-bottom: 0;
}
.card-row-block > .section-head .card-row-title {
  margin-bottom: 0;
}

/* And the leading, which was what remained at zero margins: 3px between
   the boxes, 9px between the ink. The title inherits 1.5 line-height --
   22.4px of glyph in a 33.6px box. 1.1 suits one line of uppercase
   display type. The note keeps its own; it is body text. */
.card-row-block > .section-head .card-row-title {
  line-height: 1.1;
}

/* The note sits straight under it rather than a nudge below. */
.card-row-block > .section-head + .section-note {
  margin-top: 0;
}

.card-row-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.4rem;
  text-transform: uppercase;
  /* Same principle as .section-head: nearer to its own content than to
     whatever came before. The markup sets `margin-top: 1.75rem` inline on
     these, so the gap below only has to be the smaller of the two. */
  margin: 0 0 0.75rem;
}

/* ROOM FOR THE HOVER, CANCELLED BY MARGIN. `overflow-x: auto` makes the
   row a clip box on BOTH axes -- CSS will not let one axis scroll while
   the other stays visible. A hovered cover lifts 3px and (on Play Next)
   takes a 2px ring, so its top edge and the first card's left edge were
   cut off the moment you pointed at them. The padding gives the lift and
   ring space inside the clip box; the equal negative margin puts the
   covers back exactly where they were, so nothing else moves.
   scroll-padding keeps snap-to-start landing on the cover, not the pad. */
.card-row {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  padding: 6px 4px 0.5rem;
  margin: -6px -4px 0;
  scroll-padding-inline: 4px;
  scroll-snap-type: x proximity;
}

.activity-card {
  flex: 0 0 auto;
  width: 150px;
  text-decoration: none;
  scroll-snap-align: start;
  display: flex;
  flex-direction: column;
  /* 0.35, not 0.5: the gap applies between every child and the name
     reserves a second line it rarely uses, so stats sat ~40px under a
     one-line title and 26px under a two-line one. The reservation stays
     -- it is what levels the stat lines across a row. */
  gap: 0.35rem;
}

.activity-card-cover-wrap {
  position: relative;
  width: 150px;
  height: 210px;
  border-radius: 10px;
  overflow: hidden;
  background: var(--bg-panel-raised);
  box-shadow: 0 6px 20px -8px rgba(0,0,0,0.5);
  transition: transform 0.15s ease;
}
.activity-card:hover .activity-card-cover-wrap { transform: translateY(-3px); }

.activity-card-cover-wrap .activity-card-cover,
.activity-card-cover-wrap .activity-card-cover-placeholder {
  position: absolute;
  top: 0; left: 0;
  width: 100%; height: 100%;
  object-fit: cover;
}
.activity-card-cover-wrap .activity-card-cover-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-faint);
  font-size: 0.72rem;
  text-align: center;
  padding: 1rem;
}

.activity-card-info {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
}

.activity-card-name {
  color: var(--text);
  font-weight: 600;
  font-size: 0.85rem;
  line-height: 1.3;
  margin: 0;

  /* Two lines reserved whether or not both are used.
     "Grand Theft Auto VI" wraps and "GRIS" doesn't, so in a row of cards
     the stat lines underneath sat at different heights and the row read
     as ragged. Same fix as the Tiled captions: clamp at two, reserve the
     height, keep everything below aligned. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  min-height: 2.6em;
}
.activity-card:hover .activity-card-name { color: var(--accent); }

/* The two stat lines belong together.
   `.activity-card` uses `gap: 0.5rem` between every child, which is right
   between the cover and the title but far too much between "2 wins" and
   "+2 nominated" -- they're one fact in two lines and were drifting apart
   as if unrelated.
   A negative top margin on the second pulls it back under the first
   without loosening the gap for everything else. */
.activity-card-stat {
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: 0.72rem;
  margin: 0;
  line-height: 1.3;
}

/* HIGHEST RATED: THE STAR SITS UNDER THE NAME, not a line below it.
   The reserved second line (above) exists to level a row of stat lines.
   Here the stat is a single star rating, and on the many one-line names
   ("Cairn", "Returnal") the reservation left an empty line between the
   game and its score -- Luke: the stars are too far down. A star a line
   higher under a two-line name is a smaller cost than every short name
   floating away from its score. */
#top-rated-year-row .activity-card-name { min-height: 0; }
/* The score, a size up (Luke, 23 Sep). This row is ABOUT the rating, and
   at the shared stat size (0.62rem) "★ 9" read as a footnote under the
   name. The ID selector outranks the phone block's 0.65rem as well. */
#top-rated-year-row .activity-card-stat {
  font-size: 0.8rem;
  line-height: 1.3;
  margin-top: 0.1rem;
}

/* And the first stat sits closer still to the name it describes. The
   reserved second line leaves visible air under a short title; this
   takes some of it back without touching the reservation itself. */
.activity-card-name + .activity-card-stat {
  margin-top: -0.25rem;
}

.activity-card-stat-secondary {
  color: var(--text-faint);
  font-family: var(--font-mono);
  font-size: 0.66rem;
  margin: -0.35rem 0 0;
  line-height: 1.3;
}

/* ---------- Multiplayer donut chart ---------- */

/* These tiles had no bottom margin, so "You and the critics" and "Things
   almost nobody does" were separated by their two borders and nothing
   else. Direct children only: `.overview-chart-block` is also used
   inside `.overview-grid`, which already spaces with `gap`. */
.overview-section > .overview-chart-block {
  margin-bottom: 1.5rem;
}

.donut-block { margin-bottom: 1.5rem; }

/* A `.section-head` opening a tile needs no top margin. The 1.75rem
   above is for headings in the page flow; inside a panel it stacks with
   the padding -- 20px plus 28px put the multiplayer heading 48px down a
   tile it should top. */
.overview-chart-block > .section-head:first-child {
  margin-top: 0;
}

.donut-wrap {
  max-width: 480px;
  margin: 0 auto;
}

/* ---------- Multiplayer donut + list layout ---------- */

.donut-and-list {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 2rem;
  align-items: center;
}

.multiplayer-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  max-height: 320px;
  overflow-y: auto;

  /* Capped, so a figure sits near its name. `flex: 1` on the name is
     right -- it keeps the figures in a column -- but the list fills half a
     1240px panel, so name and figure ended up at opposite ends of a 900px
     row. Capping keeps the alignment and closes the gap. */
  max-width: 30rem;
}

.multiplayer-list-item {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-size: 0.85rem;
  /* Long names truncate rather than pushing the row wider than the
     panel -- a name you can read most of beats a list you have to drag
     sideways. min-width:0 is what lets a flex child shrink at all. */
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.multiplayer-list-item > * { min-width: 0; }
.multiplayer-list {
  overflow-x: hidden;
  /* Reserves room for the vertical scrollbar in the layout rather than
     letting it overlap. Without this the rows were sized to the full
     width and the last ~13px of each sat under the scrollbar -- clipped
     rather than scrollable, so nothing looked wrong until you measured
     it. */
  scrollbar-gutter: stable;
}

.multiplayer-list-swatch {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  flex: 0 0 auto;
}

.multiplayer-list-name {
  flex: 1;
  color: var(--text);
  font-weight: 600;
}

.multiplayer-list-stat {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--gold);
}

/* ---------- Multiplayer list as legend ---------- */

.multiplayer-list-item {
  cursor: pointer;
  border-radius: 6px;
  padding: 0.25rem 0.4rem;
  margin: 0 -0.4rem;
  transition: background 0.12s ease;
}
.multiplayer-list-item:hover { background: var(--bg-panel-raised); }
.multiplayer-list-item.is-highlighted {
  background: var(--gold-soft);
}
.multiplayer-list-item:focus-visible {
  outline: 2px solid var(--gold);
  outline-offset: 1px;
}

/* ---------- PlayStation connect form ---------- */

.psn-connect-form {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  margin-top: 0.75rem;
}

.psn-connect-form .field-hint {
  line-height: 1.5;
}
.psn-connect-form .field-hint a {
  color: var(--gold);
}

.psn-connect-form input {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text);
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.55rem 0.7rem;
}

/* Sign in with PlayStation (#116). The QR is the primary object in the
   panel; the by-hand field is a testing fallback and is folded away so
   it does not compete with it. */
.psn-link-panel {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  margin-top: 0.5rem;
}
.psn-link-qr {
  width: 176px;
  padding: 8px;
  background: #fff;             /* a QR needs real contrast, not the theme's panel */
  border-radius: 8px;
  line-height: 0;
}
.psn-link-qr svg { width: 100%; height: auto; display: block; }
.psn-link-expiry { color: var(--text-muted); }
.psn-link-expiry .btn { margin-left: 0.35rem; }
.psn-link-manual summary { cursor: pointer; color: var(--text-muted); }
.psn-link-manual-row {
  display: flex;
  gap: 0.5rem;
  margin-top: 0.5rem;
}
.psn-link-manual-row input {
  flex: 1;
  min-width: 0;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text);
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.55rem 0.7rem;
}
.psn-signin-note {
  margin-top: 0.6rem;
  line-height: 1.6;
}
.psn-signin-note .btn { margin: 0 0.25rem; vertical-align: baseline; }
.psn-signin-note.is-expired { color: var(--text); }
.psn-or { margin-top: 0.4rem; }

/* ---------- Profile tab ---------- */

.profile-avatar-wrap {
  width: 56px;
  height: 56px;
  border-radius: 50%;
  overflow: hidden;
  background: var(--bg-panel-raised);
  border: 2px solid var(--gold-soft);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}
.profile-avatar {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.profile-avatar-placeholder {
  font-family: var(--font-display);
  font-size: 1.3rem;
  color: var(--text-faint);
}

.favorites-grid {
  display: grid;
  /* Five per row, filling the section rather than capped at 90px.
     The cap was set when this lived in a fixed-width column; now that the
     section can be medium, wide or full, a hard 90px meant the covers
     stayed the same size while the box around them grew -- so a wider
     Favourites was just more empty space.
     Capped at 140px so they do not become posters at full width. */
  grid-template-columns: repeat(5, minmax(0, 140px));
  gap: 0.6rem;
  margin-bottom: 0.75rem;
}

.favorite-card {
  text-decoration: none;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  position: relative;
}

.favorite-rank {
  position: absolute;
  top: 6px;
  left: 6px;
  background: rgba(10, 6, 15, 0.75);
  color: var(--gold);
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 0.72rem;
  padding: 0.1rem 0.4rem;
  border-radius: 4px;
  z-index: 1;
}

.favorite-cover-wrap {
  position: relative;
  width: 100%;
  aspect-ratio: 2/3;
  border-radius: 8px;
  overflow: hidden;
  background: var(--bg-panel-raised);
}
.favorite-cover, .favorite-cover-placeholder {
  position: absolute;
  top: 0; left: 0;
  width: 100%; height: 100%;
  object-fit: cover;
}
.favorite-cover-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-faint);
  font-size: 0.65rem;
  text-align: center;
  padding: 0.5rem;
}

.favorite-name {
  color: var(--text);
  font-weight: 600;
  font-size: 0.8rem;
  line-height: 1.3;
}

/* Glass, not a panel. The fill was 0.4 opaque over the cover-art
   backdrop, which made it read as a card sitting ON the page rather than
   part of it -- and the backdrop is the most personal thing on the
   profile, so covering it is the wrong trade.
   The blur does the legibility work; the fill only has to lift the text
   off whatever art happens to be behind it. */
.spotlight-block {
  background: rgba(32, 26, 40, 0.22);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  border: 1px solid rgba(255, 255, 255, 0.06);
  border-radius: var(--radius);
  padding: 0.65rem 0.85rem;
  margin-top: 1.5rem;
}

/* SIDEBAR HEADINGS ARE CAPTIONS, not headlines.
   `.card-row-title` is 1.4rem of the display face in near-white, which
   is right above a row of cover art and far too loud above a Spotify
   embed in a 340px column -- the heading was outweighing the thing it
   introduced. Same family so the page still reads as one product, two
   thirds the size.
   THE PROFILE'S OWN ACCENT rather than a muted grey. It is sampled from
   the backdrop these panels sit on, so the headings belong to the page
   instead of being applied to it -- and they change with it as the
   theme game changes. Size is doing the quietening now, which is what
   should have been doing it: near-white at 1.4rem was loud because of
   the 1.4rem.
   Same fallback as every other accent surface, for a profile with no
   sampled colour yet. */
/* WHITE NOW, like every other heading (Luke, 23 Sep). The sampled accent
   made these the one set of headings on the page in a colour -- teal on
   his current backdrop -- so the sidebar read as a different page. Size
   still does the quietening on desktop; on a phone, where the sidebar is
   just more of the same stack, they match the main headings (phone
   block). */
.profile-side .card-row-title {
  font-size: 0.95rem;
  color: var(--text);
  letter-spacing: 0.03em;
  margin-bottom: 0.5rem;
}

.spotlight-game {
  display: flex;
  gap: 1rem;
  text-decoration: none;
  align-items: flex-start;
}

.spotlight-cover-wrap {
  position: relative;
  width: 64px;
  height: 90px;
  border-radius: 8px;
  overflow: hidden;
  background: var(--bg-panel-raised);
  flex-shrink: 0;
}
.spotlight-cover, .spotlight-cover-placeholder {
  position: absolute;
  top: 0; left: 0;
  width: 100%; height: 100%;
  object-fit: cover;
}
.spotlight-cover-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-faint);
  font-size: 0.65rem;
  text-align: center;
  padding: 0.5rem;
}

.spotlight-name {
  color: var(--text);
  font-weight: 700;
  margin: 0 0 0.4rem;
}
.spotlight-note {
  color: var(--text-muted);
  font-size: 0.88rem;
  line-height: 1.5;
  margin: 0;
}

.spotlight-edit-form {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}
.spotlight-edit-form input,
.spotlight-edit-form textarea {
  font-family: inherit;
  font-size: 0.88rem;
  color: var(--text);
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.55rem 0.7rem;
}

.spotlight-game-results {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}
.spotlight-result-item {
  text-align: left;
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.4rem 0.6rem;
  color: var(--text);
  cursor: pointer;
  font-size: 0.85rem;
}
.spotlight-result-item:hover {
  border-color: var(--gold);
}

.spotlight-edit-actions {
  display: flex;
  gap: 0.6rem;
}

/* ---------- Music player ---------- */

.music-player-wrap {
  overflow: hidden;
}
.music-player-wrap iframe {
  border-radius: 10px;
  display: block;
  width: 100%;
}

.music-result-item {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.music-result-type {
  font-family: var(--font-mono);
  font-size: 0.65rem;
  text-transform: uppercase;
  color: var(--text-faint);
  border: 1px solid var(--hairline);
  border-radius: 4px;
  padding: 0.05rem 0.3rem;
  flex-shrink: 0;
}

/* ---------- Profile header layout (name + compact music player) ---------- */

.profile-header {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  margin-bottom: 1.4rem;
  flex-wrap: wrap;
}

.profile-header-name {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}
.profile-header-name h2 {
  margin: 0;
}

.music-edit-form-block {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 1rem 1.2rem;
  margin-bottom: 1.5rem;
}
.music-edit-form-block input {
  font-family: inherit;
  font-size: 0.88rem;
  color: var(--text);
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.55rem 0.7rem;
}

/* ---------- Favorites + Spotlight side by side ---------- */

/* One flat grid for every profile section.
   Replaces `.profile-two-col`, whose nesting made reordering impossible:
   CSS `order` moves siblings, and favourites lived in a different parent
   from music and spotlight.
   Four columns because the section widths -- 1, 2 and 4 -- all divide
   into it, so any combination tiles. Rows are packed in JS for the same
   reason the stats are: CSS cannot fill a short final row. */
/* TWO COLUMNS, like a profile page rather than a dashboard: the games
   down the left, and a narrow sidebar on the right for the two things
   that are about YOU rather than about what you played.
   340px is a fixed sidebar, not a fraction -- a music player and a
   single cover do not want to grow with the window, and a fraction makes
   them wider on a big screen for no reason. The main column takes
   whatever is left. */
.profile-body {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 340px;
  gap: 1.25rem 1.5rem;
  align-items: start;
}

/* Nothing on in the sidebar: the games take the whole width rather than
   leaving a third of the page open beside them. */
.profile-body.no-side { grid-template-columns: minmax(0, 1fr); }
.profile-body.no-side .profile-side { display: none; }

/* Each column is its own stack. This is the whole reason the row packer
   and the two-row block are gone: a stack cannot leave a gap in a row,
   and two stacks cannot force each other's heights. */
/* THE STACK'S GAP IS THE ONLY SPACE BETWEEN SECTIONS. Currently Playing,
   Highest Rated and Achievement showcase also had an inline
   `margin-top: 1.75rem` on their headings, left from before this was a
   stack -- so those three sat 52px below the section above while Stats
   and Favorites sat 24px, and the page read as loose and uneven. The
   inline margins are gone; spacing lives here and nowhere else. */
.profile-main,
.profile-side {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

/* The per-section grid spans are gone. A section is the width of the
   column it sits in, and which column is declared in PROFILE_SECTIONS --
   the sections are not grid items any more, they are children of one of
   the two stacks. */

@media (max-width: 900px) {
  /* One column. The sidebar stops being a sidebar and its sections
     continue the stack, in the order the definitions give them. */
  .profile-body {
    grid-template-columns: 1fr;
    /* Tighter. 1.25rem between full-width sections is a screenful of
       nothing every few sections -- that gap was tuned for a grid where
       it also had to separate columns.
       1rem, not 0.5: 0.5 only worked because three section headings also
       carried an inline `margin-top: 1.75rem` from before the profile was
       a stack. That margin came out (23 Sep, Luke: "too much spacing
       between sections"), so this gap is now the whole of the space
       between sections, and every section gets the same amount. */
    gap: 1rem;
  }

  /* The two stacks become one. `display: contents` drops the main
     column's wrapper out of the layout so its sections become children of
     the body grid directly, followed by the sidebar.
     The SIDEBAR KEEPS ITS BOX (28 Sep): it is one panel now, and
     `display: contents` would throw the panel away with the wrapper, so
     Music, Spotlight and Stats stay together in it after the games. */
  .profile-main { display: contents; }

  /* ---- Profile makeover on a phone (28 Sep; the desktop rules are in
     "PROFILE MAKEOVER" at the end of the file, and win against anything
     here at equal specificity -- hence `html body`). ---- */
  html body #view-profile #profile-side { margin-top: 0; }

  html body #view-profile .profile-content.has-banner .profile-header {
    min-height: 150px;
    padding: 0 0.9rem 0.9rem;
    margin-bottom: 1.25rem;
    column-gap: 0.7rem;
  }
  html body #view-profile .profile-content.has-banner .profile-avatar-wrap,
  html body #view-profile .profile-content.has-banner .profile-avatar-placeholder {
    width: 64px;
    height: 64px;
  }
  html body #view-profile .profile-content.has-banner .profile-header-name h2 { font-size: 1.6rem; }
  html body .profile-quote { font-size: 0.85rem; }

  /* A tile and a peek of the next: the peek is what says "swipe". */
  html body #view-profile .profile-tile { flex-basis: 82%; }

  html body .profile-bleed { height: 80vh; }
}

/* ---------- Compact connection cards, moved into the hero banner ---------- */

.connection-cards-compact {
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 0.6rem;
  margin: 1.1rem 0 0.4rem;
  max-width: 640px;
}

.connection-cards-compact .connection-card {
  padding: 0.6rem 0.8rem;
  gap: 0.4rem;
  background: rgba(10, 6, 15, 0.35);
}

.connection-cards-compact .connection-card-head {
  flex-wrap: wrap;
  row-gap: 0.15rem;
}

.connection-cards-compact .connection-name {
  font-size: 0.85rem;
}

.connection-cards-compact .connection-card-actions {
  gap: 0.4rem;
}
.connection-cards-compact .connection-card-actions .btn {
  padding: 0.25rem 0.55rem;
  font-size: 0.75rem;
}

/* THE CONNECTIONS PANEL: A COLUMN OF ROWS (#157, 1 Oct).
   It was Sync all across the top and the three platforms side by side
   under it (26 Sep), in a panel 700px wide. On a phone those three
   columns were 97px each: "Synced 10/1/2026, 2:06:15 PM" wrapped to three
   lines and Disconnect ran out of its card.
   Chosen on the real panel, at both widths, from that, a row with
   Disconnect as a link under Sync, and this: one row per platform --
   badge and name, when it synced under them, Sync at the right, and a
   "..." that opens a second line holding Disconnect. ONE layout for every
   width, so there is no phone block to keep in step with it; and the
   panel is 400px on a desktop, because a row needs no more and across
   700px a name and its button were a screen apart ("way too wide").
   Still raised panels with a hairline edge, the Library's quiet buttons,
   and Sync all as the arcade button in the theme's colour. Scoped to the
   gear's panel -- the onboarding screen's connect cards are a different
   element and keep their own look. */
#connections-popover {
  border-radius: 16px;
  box-shadow: 0 18px 44px rgba(0, 0, 0, 0.5);
  width: min(92vw, 400px);
  max-width: min(92vw, 400px);
}
#connection-cards {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  margin: 0 0 0.4rem;
  max-width: none;
}
#connection-cards .connection-card {
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 12px;
  padding: 0.6rem 0.8rem;
}
#connection-cards .connection-name { font-size: 0.95rem; font-weight: 700; }
#connection-cards .connection-synced-at { font-family: var(--font-body); font-size: 0.72rem; }
/* The message line under the cards holds its height while it is empty:
   27px of nothing between the last platform and the health line. */
#connect-message:empty { display: none; }
/* A FAILURE on that line is not the line's colour (#163). The line is the
   accent, which is also what a link looks like here, and "Sync failed" in
   it read as something to press. The same red as a card's "Last sync
   failed", so the two say one thing in one colour. */
.connect-error { color: #c9737b; font-weight: 600; }
/* "Tidying up…", while the pass after a sync runs: quieter than the
   result it follows, which is already true. */
.connect-working { color: var(--text-faint); }

/* A PLATFORM'S ROW. Three columns: the head, Sync, "...". The actions box
   is dissolved (display: contents) so its buttons sit on the row's own
   grid -- Sync and "..." on the first line, Disconnect on a second line
   that exists only while the row is open. */
#connection-cards .connection-card:not(.sync-all-card) {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  column-gap: 0.45rem;
  row-gap: 0.35rem;
  align-items: center;
}
/* The head: badge and name on one line, and the line under them starting
   at the row's left edge -- under the badge, not indented past it. (The
   compact cards' head already wraps, and the synced line is width: 100%,
   which is what puts it on a line of its own.) */
#connection-cards .connection-card-head { grid-column: 1; grid-row: 1; column-gap: 0.5rem; }
/* "Connected" is not said: a row with a Sync button is connected. "Not
   connected" is, on the line where the synced time would be. */
#connection-cards .connection-status.is-connected { display: none; }
#connection-cards .connection-status { margin-left: 0; width: 100%; }
#connection-cards .connection-card-actions { display: contents; }
#connection-cards .connection-card-actions > .btn:first-child { grid-column: 2; grid-row: 1; position: relative; }
/* A card that is not connected has one button here (Connect Steam) and no
   "...": it takes both columns, or it would stop a gap short of the edge. */
#connection-cards .connection-card-actions > .btn:only-child { grid-column: 2 / span 2; }
/* Whatever else a card holds -- a connect form, PlayStation's sign-in
   note -- takes the full width under the row. */
#connection-cards .connection-card:not(.sync-all-card) > :not(.connection-card-head):not(.connection-card-actions) { grid-column: 1 / -1; }

/* THE TOP CARD: its buttons side by side, each at least as wide as its
   words -- "Fill in missing data" in an equal half of a phone's panel
   wrapped to two lines and stood taller than Sync all. With only Sync all
   (everyone but the admin accounts, who also get Fill in missing data) it
   takes the full width. Each button used to sit over a note saying what
   it did; the notes are gone (see buildSyncAllCard). */
#connection-cards .sync-all-card {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(max-content, 1fr);
  gap: 0.6rem;
  align-items: center;
  padding: 0.7rem 0.8rem;
}
#connection-cards .sync-all-card .btn {
  width: 100%;
  justify-content: center;
  font-size: 0.85rem;
  padding: 0.6rem 0.5rem;
  border-radius: 10px;
  white-space: nowrap;
}
#connection-cards .sync-all-btn {
  border: none;
  font-weight: 800;
  color: var(--act-ink);
  background: var(--act);
  box-shadow: 0 3px 0 var(--act-deep), inset 0 1px 0 rgba(255, 255, 255, 0.35);
  transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
#connection-cards .sync-all-btn:hover {
  transform: translateY(1px);
  background: var(--act-light);
  box-shadow: 0 2px 0 var(--act-deep), inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
#connection-cards .sync-all-btn:active {
  transform: translateY(3px);
  box-shadow: 0 0 0 var(--act-deep);
}

/* Every other button: the Library's quiet button. */
#connection-cards .btn-ghost {
  border: 1px solid transparent;
  background:
    linear-gradient(var(--bg-panel), var(--bg-panel)) padding-box,
    linear-gradient(var(--hairline), var(--hairline)) border-box;
  color: var(--text);
  border-radius: 9px;
  font-weight: 700;
  font-size: 0.78rem;
  padding: 0.38rem 0.7rem;
  transition: transform 0.15s ease, background 0.15s ease;
}
#connection-cards .btn-ghost:hover {
  transform: translateY(-1px);
  background:
    linear-gradient(var(--bg-panel-raised), var(--bg-panel-raised)) padding-box,
    linear-gradient(var(--act), var(--act)) border-box;
}
#connection-cards .sync-all-card .btn-ghost { font-size: 0.85rem; padding: 0.6rem 0.5rem; }

/* "..." -- only in the gear's panel. The markup carries it wherever the
   cards render (connectionCardActions); on the onboarding screen it is
   not shown and Disconnect is, as it always was. */
.connection-more { display: none; }
#connection-cards .connection-more {
  grid-column: 3;
  grid-row: 1;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 1.85rem;
  padding: 0;
  border: 1px solid var(--hairline);
  border-radius: 9px;
  background: none;
  color: var(--text-muted);
  font-size: 1.05rem;
  line-height: 1;
  cursor: pointer;
}
#connection-cards .connection-more:hover,
#connection-cards .is-open .connection-more { color: var(--text); background: var(--bg-panel); }
/* Sync and "..." are 29px tall in a 62px row, 7px apart: a finger gets
   the rest of the row's height, and no more of the gap than half each. */
#connection-cards .connection-card-actions > .btn:first-child::after,
#connection-cards .connection-more::after { content: ""; position: absolute; inset: -10px -3px; }

/* Disconnect: a quiet link at the right of the row's second line, there
   only while the row is open. After the quiet-button rules above, which
   it otherwise ties with. */
#connection-cards .connection-disconnect {
  grid-column: 1 / -1;
  grid-row: 2;
  justify-self: end;
  position: relative;
  border: 0;
  background: none;
  padding: 0.25rem 0.1rem;
  font-size: 0.72rem;
  font-weight: 500;
  color: var(--text-muted);
  text-decoration: underline;
  text-underline-offset: 2px;
}
#connection-cards .connection-disconnect:hover { transform: none; background: none; color: var(--text); }
#connection-cards .connection-disconnect::after { content: ""; position: absolute; inset: -8px -6px; }
#connection-cards .connection-card:not(.is-open) .connection-disconnect { display: none; }

.connection-synced-at {
  width: 100%;
  font-family: var(--font-mono);
  font-size: 0.68rem;
  color: var(--text-faint);
}

/* A sync that hasn't run in three days. Amber, not red: the connection may
   be perfectly fine and the platform may simply be down, so this is a
   thing worth noticing rather than an error.
   Card only -- no badge, no banner, nothing that follows you around the
   app. You see it when you go and look. */
/* A RECORDED failure, distinct from stale.
   Stale is amber and says "this might be fine, try syncing" -- the
   platform may simply be down. An error is a sync that ran and reported
   why it failed, which is a different message and deserves a different
   colour. */
.connection-synced-at.is-error {
  color: #c9737b;
  font-weight: 600;
}

.connection-reconnect-hint {
  display: block;
  width: 100%;
  margin-top: 0.2rem;
  font-size: 0.72rem;
  color: var(--text-faint);
}

.connection-synced-at.is-stale {
  color: var(--gold);
}

/* Connected, first sync not run yet. Reads as neutral -- it's the normal
   state for a connection made thirty seconds ago, not a problem. */
.connection-synced-at.is-never {
  color: var(--text-muted);
  font-style: italic;
}

/* ---------- Profile panel ---------- */

.profile-section {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius);
  padding: 0;
}

/* .profile-background and its ::before/::after lived here: the spotlight
   game's cover blurred behind the whole profile. Replaced by the banner
   and .profile-bleed (28 Sep) -- see "Profile makeover" at the end of the
   file. */

.profile-content {
  position: relative;
  z-index: 1;
  padding: 1.3rem 1.5rem 2rem;
}

/* THE GAP ABOVE THE CRITICS. "You and the critics" moved below the chart
   grid (23 Sep), and outside the grid nothing spaced it: it sat flush
   against the Steam Deck chart, 0px where every other block has 24px
   (Luke: "way too close"). The grid's own row gap, matched. */
#view-overview .overview-grid + #critic-block,
#view-overview .overview-grid + .overview-chart-block { margin-top: 1.5rem; }

/* ---------- Recently finished (profile) ----------
   renderRecentlyFinished. Five small tiles: a cover, then the name, the
   finish date and rating, an achievement bar and a "Read review" link.
   ONE ROW THAT SCROLLS SIDEWAYS (Luke, 23 Sep: "not all have to fit"),
   like every other row on the profile -- it wrapped into a 3 + 2 grid,
   which read as a block of cards rather than a row. Fixed-width tiles,
   the padding-and-negative-margin trick from .card-row so a hovered
   edge is not clipped, and snap so a swipe lands on a tile. */
.profile-tiles {
  display: flex;
  gap: 0.6rem;
  overflow-x: auto;
  padding: 4px 4px 0.5rem;
  margin: -4px -4px 0;
  scroll-padding-inline: 4px;
  scroll-snap-type: x proximity;
  -webkit-overflow-scrolling: touch;
}
.profile-tile {
  /* 17rem, from 15: with hours added the date line ("Finished Sep 11 ·
     12.0h · ★ 8") broke mid-date at 15rem. The row scrolls, so a wider
     tile costs nothing but one fewer visible at once. */
  flex: 0 0 17rem;
  scroll-snap-align: start;
  display: grid;
  grid-template-columns: 52px minmax(0, 1fr);
  gap: 0.75rem;
  align-items: start;
  padding: 0.65rem;
  border: 1px solid rgba(255, 255, 255, 0.06);
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.03);
  transition: border-color 0.15s ease;
}
.profile-tile:hover { border-color: rgba(var(--act-rgb), 0.4); }
.profile-tile-cover {
  position: relative;
  display: block;
  width: 52px;
  aspect-ratio: 2 / 3;
  border-radius: 6px;
  overflow: hidden;
  background: var(--bg-panel-raised);
}
.profile-tile-img, .profile-tile-img-placeholder {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.profile-tile-info { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.profile-tile-name {
  font-size: 0.86rem;
  font-weight: 600;
  color: var(--text);
  text-decoration: none;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.profile-tile-name:hover { color: var(--sun-gold); }
.profile-tile-platforms { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.profile-tile-platforms .platform-pill { font-size: 0.64rem; padding: 0.05rem 0.4rem; }
.profile-tile-meta {
  display: flex;
  gap: 0.55rem;
  white-space: nowrap;
  align-items: baseline;
  font-size: 0.72rem;
  color: var(--text-muted);
}
.profile-tile-rating { font-weight: 700; }
.profile-tile-progress { display: flex; align-items: center; gap: 0.45rem; }
.profile-tile-bar {
  flex: 1 1 auto;
  height: 4px;
  border-radius: 2px;
  background: rgba(255, 255, 255, 0.08);
  overflow: hidden;
}
.profile-tile-bar > span { display: block; height: 100%; border-radius: 2px; background: var(--sun-amber); }
.profile-tile-pct { font-family: var(--font-mono); font-size: 0.66rem; color: var(--text-muted); }
/* 100% IS PLATINUM (Luke, 23 Sep): a full bar takes a pale silver-blue
   instead of amber, and its figure goes with it -- every achievement,
   the trophy people mean by "platinum", not just a longer amber bar. */
.profile-tile-progress.is-complete .profile-tile-bar > span { background: #d7e0ea; }
.profile-tile-progress.is-complete .profile-tile-pct { color: #d7e0ea; font-weight: 700; }
.profile-tile-review {
  align-self: flex-start;
  font-size: 0.72rem;
  color: var(--text-muted);
  text-decoration: none;
}
.profile-tile-review:hover { color: var(--sun-gold); }

/* ---------- Music + Spotlight as a matched pair of cards ---------- */

/* `.profile-two-col-cards` is gone -- the column it styled no longer
   exists. Its margin-top existed to align the cards with the favourites
   GRID rather than the "Favorite Games" title above it; the flat grid
   uses `align-items: start` and per-section spacing instead. */
.profile-body .spotlight-block { margin: 0; }
/* THE SIDEBAR CARDS' PADDING, RESTORED. `.spotlight-block` sets it, but
   these sections are also `.profile-section`, whose rule comes later in
   the file with `padding: 0` and wins at equal specificity -- so the
   cards drew their border with nothing inside it: headings on the top
   edge, the Spotify player and the stat rows against the left border,
   the numbers into the right (Luke, 23 Sep: "a little wonky"). Both
   classes together outrank either alone. */
.profile-section.spotlight-block { padding: 0.8rem 0.95rem; }

/* ---------- Top 100 grid cards ---------- */

.top100-grid-card-inner {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}

/* THE STAR ON A RANKED CARD.
   Top right, because the rank badge holds the top left. Hover-only for
   the same reason the top-five x is: a hundred covers each wearing a
   permanent badge is a grid about buttons rather than about games. */
.top100-rate-btn {
  position: absolute;
  top: 0.3rem;
  right: 0.3rem;
  z-index: 3;

  /* Small. It marks a card rather than decorating it, and it only
     appears on games with no score -- so on a grid part-way through
     being rated it is a scatter of little marks, which is the right
     amount of presence for "these are the ones left". */
  width: 1.25rem;
  height: 1.25rem;
  padding: 0;
  border: 0;
  border-radius: 50%;
  /* Opaque dark rather than a tint: it sits on cover art, which can be
     any colour, and a translucent chip over a bright cover is
     unreadable. */
  background: rgba(10, 7, 14, 0.82);
  color: var(--gold);
  font-size: 0.72rem;
  line-height: 1;
  cursor: pointer;

  opacity: 0;
  /* visibility, not opacity alone -- otherwise an invisible button sits
     over the corner of every cover swallowing clicks meant for the link
     underneath. */
  visibility: hidden;
  transition: opacity 0.12s ease, visibility 0.12s, background 0.12s ease;
}

.top100-grid-item:hover .top100-rate-btn,
.top100-rate-btn:focus-visible {
  opacity: 1;
  visibility: visible;
}

.top100-rate-btn:hover {
  background: rgba(10, 7, 14, 0.95);
}

/* The legend for that star, above the grid. Off unless the ranked grid
   is showing AND carries at least one star -- :has lets the page's own
   contents decide, rather than every view switch in app.js having to
   remember a legend exists. The mark is drawn like the star itself: the
   same gold on the same dark chip, so the two read as one thing. */
.top100-star-legend {
  display: none;
  align-items: center;
  gap: 0.4rem;
  margin: 0;
  /* The note's own size and colour, so the row reads as one line of help
     rather than two things that happen to be near each other. */
  font-size: 0.8rem;
  line-height: 1.5;
  color: var(--text-faint);
}

/* The rank note and the star legend, on one row (25 Sep). The row owns the
   spacing the note used to carry, so the gap below is the same whether the
   legend is showing or not; it wraps to two lines on a narrow screen. */
.top100-notes {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  column-gap: 1.25rem;
  row-gap: 0.25rem;
  margin: 0.1rem 0 0.85rem;
}

.top100-notes .section-note {
  margin: 0;
}
/* Every grid that carries the star: the ranked list, Tiers, and the Top
   25 (#topx-list -- a tier-list of its own, which the first version of
   this missed, so Top 25 showed stars with no legend). */
#view-top100:has(#top100-list:not([hidden]) .top100-rate-btn) .top100-star-legend,
#view-top100:has(#tier-list:not([hidden]) .top100-rate-btn) .top100-star-legend,
#view-top100:has(#topx-list:not([hidden]) .top100-rate-btn) .top100-star-legend {
  display: flex;
}
/* The legend's button (#145) exists for a phone; see the phone block. */
.legend-btn { display: none; }
.top100-star-legend-mark {
  display: inline-grid;
  place-items: center;
  width: 1.25rem;
  height: 1.25rem;
  border-radius: 50%;
  background: rgba(10, 7, 14, 0.82);
  color: var(--gold);
  font-size: 0.72rem;
  line-height: 1;
}

/* ---------- Rate or review panel ---------- */

/* 680px, from 420 -- at 420 the textarea was ~50 characters across, a
   comment box you write one line in.
   BY ID, not the `.rate-modal` class the element also carries: that
   class sits above `.cleanup-modal`, same specificity, so a width on it
   did nothing. Applies wherever the editor opens. */
#rate-overlay .cleanup-modal {
  width: min(94vw, 680px);
}

.rate-field {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

/* Sentence case in the body face since #141; it was mono uppercase. */
.rate-field-label {
  font-family: var(--font-body);
  font-size: 0.8rem;
  color: var(--text-muted);
}

.rate-field input,
.rate-field textarea {
  width: 100%;
  /* Keeps a long review from running edge to edge of a 680px panel --
     past about 70 characters a line gets hard to track back from. The
     score box is short by nature and unaffected. */
  max-width: 100%;
  padding: 0.5rem 0.6rem;
  border: 1px solid var(--hairline);
  border-radius: 8px;
  background: var(--bg-panel-raised);
  color: var(--text);
  font: inherit;
}

.rate-field textarea {
  resize: vertical;
  /* Tall as well as wide, for the same reason -- a box two lines deep
     asks for two lines. Still short enough that the panel fits a laptop
     screen with the buttons visible, and it can be dragged taller. */
  min-height: 9rem;
  line-height: 1.6;
}

.rate-field input:focus-visible,
.rate-field textarea:focus-visible {
  outline: 2px solid var(--gold);
  outline-offset: 1px;
}

/* The score is two characters. On a 680px panel a full-width box for it
   looks like it is expecting an essay, and the essay is the field below. */
#rate-score {
  width: 6rem;
}

/* Save on the right, where the primary action sits in the other panels
   on this page. */
.rate-actions {
  justify-content: flex-end;
  margin-top: 0.4rem;
}
/* Buttons at their own size, not two half-width slabs (Luke, 29 Sep:
   "update the buttons ... here"). By id: .cleanup-actions .btn (flex: 1)
   is the same weight and comes later in the file, and won. */
#rate-overlay .rate-actions .btn { flex: none; min-width: 6.5rem; }
/* Not in the head row (#141): there the two sit beside the game's name
   at their own size, and 6.5rem each left the name three letters. */
#rate-overlay .cleanup-head .rate-actions .btn { min-width: 0; }

/* THE COMPLETION LINE (#174, 4 Oct): "Marked completed  Undo" on the
   left, and for a game with no play record the year on the right. Quiet
   on purpose -- it reports something already done; the score and the
   review are still what the sheet is for. Gold is "what matters" in the
   palette and the one thing here that does is the way back, so only Undo
   takes it. */
.rate-completion {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 0.4rem 1rem;
  font-family: var(--font-body);
  font-size: 0.8rem;
  color: var(--text-muted);
}
.rate-completion[hidden] { display: none; }
.rate-undo {
  margin-left: 0.5rem;
  padding: 0;
  border: 0;
  background: none;
  color: var(--gold);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 2px;
}
.rate-undo:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; border-radius: 4px; }
.rate-year {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.rate-year[hidden] { display: none; }
/* Wide enough for its placeholder, "Don't know", which is the answer an
   empty box gives. */
.rate-year input {
  width: 7.5rem;
  padding: 0.35rem 0.55rem;
  border: 1px solid var(--hairline);
  border-radius: 8px;
  background: var(--bg-panel-raised);
  color: var(--text);
  font: inherit;
}
.rate-year input:focus-visible { outline: 2px solid var(--gold); outline-offset: 1px; }
/* The toast is the queue's bar (.queue-add-bar) with nothing changed but
   the stacking: it follows a sheet closing, and must not sit under one
   opening. An answer with no Undo ("Put back as it was.") centres.
   (Two classes: .queue-add-bar's own z-index comes later in the file.) */
.queue-add-bar.undo-toast { z-index: 70; }
.undo-toast #undo-toast-btn[hidden] { display: none; }
.undo-toast:has(#undo-toast-btn[hidden]) { justify-content: center; padding-right: 0.9rem; }

.top100-grid-cover-wrap {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 2/3;
  border-radius: 8px;
  overflow: hidden;
  background: var(--bg-panel-raised);
}
.top100-grid-cover,
.top100-grid-cover-placeholder {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.top100-grid-cover-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-faint);
  font-size: 0.6rem;
  text-align: center;
  padding: 0.4rem;
}

.top100-grid-stat-bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  /* Taller, and the scrim ramps sooner.
     It used to run transparent -> 0.9 across the bar's own height, which
     is fine over a dark cover and useless over a bright one -- Paper
     Mario and Mass Effect pushed straight through it and the gold text
     became unreadable. Starting the ramp higher and reaching near-opaque
     earlier gives the text a consistent background whatever the art is
     doing underneath. */
  padding: 0.9rem 0.4rem 0.3rem;
  background: linear-gradient(
    180deg,
    transparent 0%,
    rgba(10, 6, 15, 0.55) 45%,
    rgba(10, 6, 15, 0.95) 100%
  );
  color: var(--gold);
  font-family: var(--font-mono);
  font-size: 0.76rem;
  font-weight: 600;
  /* Belt and braces for the brightest covers. */
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
  /* THE FIGURE IN THE MIDDLE (1 Oct). The bar read "★ 10 · 66.5h" from
     the cover's left edge. The ranked card shows the rating alone now
     (top100CardHtml), and one short figure reads as the cover's caption
     centred, and as a stray corner label at the left. */
  text-align: center;
}

.top100-grid-rank-badge {
  position: absolute;
  top: 6px;
  left: 6px;
  z-index: 2;
  background: rgba(10, 6, 15, 0.8);
  border-radius: 4px;
  padding: 0.1rem 0.4rem;
  font-size: 0.82rem;
}
input.top100-grid-rank-badge {
  border: 1px solid transparent;
}
input.top100-grid-rank-badge:hover,
input.top100-grid-rank-badge:focus {
  border-color: var(--gold);
  background: rgba(10, 6, 15, 0.95);
}

.top100-grid-name {
  color: var(--text);
  font-weight: 600;
  font-size: 0.88rem;
  line-height: 1.3;
  text-decoration: none;
}

/* Unranked section reuses the same card look via top100-grid-item, just
   without a Sortable instance or rank badge (no meaningful position to
   edit there). */
.top100-unranked-list {
  margin-top: 0.5rem;
}

/* ---------- Background upload controls ---------- */

.profile-background-controls {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
}
.profile-background-upload-label {
  cursor: pointer;
  margin: 0;
}
#background-upload-message:not(:empty) {
  font-size: 0.75rem;
}

/* ---------- Profile edit mode ---------- */

.profile-edit-control {
  display: none;
}
/* Shown in edit mode, but still respects each element's own `hidden`
   attribute (e.g. the trigger button that hides itself while its own
   edit form is open) rather than forcing it visible regardless. */
.profile-content.is-editing .profile-edit-control:not([hidden]) {
  display: inline-flex;
}

/* AND SHOWN WHEN THE SECTION IS EMPTY, edit mode or not. #87.
   "Add a spotlight" and "Pick a track" already existed, but only inside
   edit mode -- so a new user saw "No game spotlighted yet" with no way to
   act on it unless they first found Edit Profile. The sentence explained
   the blank instead of offering to fill it, which is the same fault as
   "rank some games in Top 100 to see your favorites here".
   The class is added by whichever loader found nothing, and removed the
   moment there is something, so this is only ever a way IN. */
.profile-edit-control.is-empty-cta:not([hidden]) {
  display: inline-flex;
}

/* ---------- Profile buttons, in the Library's format (25 Sep) ----------
   Edit Profile, Settings, "Pick a track", "Add a spotlight" and the empty
   favourites' "Pick your top 5" were the old ghost button -- a hairline box
   and muted small text -- while the Library had moved on to its own two
   (".library-actions .lib-action*" above): a quiet panel button whose edge
   lights with the view colour on hover, and the gold arcade button that
   presses down. Luke: the profile's "feels old". Same two here, not a
   third style.

   Restraint still applies on this page (the artwork carries it), so the
   quiet one is the default and gold is kept for the one real action in an
   empty section.

   NO `display` HERE. These sit on `.profile-edit-control`, whose whole
   show/hide is `display: none` / `inline-flex` in the edit-mode rules
   above; setting display on the button would make every hidden control
   visible. Layout comes from those rules or from `.btn`. */
.np-action {
  align-items: center;
  gap: 0.45rem;
  height: 2.25rem;
  padding: 0 0.95rem;
  border-radius: 10px;
  font-size: 0.85rem;
  font-weight: 700;
  letter-spacing: 0.01em;
  color: var(--text);
  border: 1px solid transparent;
  background:
    linear-gradient(var(--bg-panel), var(--bg-panel)) padding-box,
    linear-gradient(var(--hairline), var(--hairline)) border-box;
  transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
/* CENTRED under the empty-state line, which is centred (Luke, 25 Sep): at
   the left edge the button read as belonging to the next section. */
#favorites-goto-rankings.np-action:not([hidden]) {
  display: flex;
  width: fit-content;
  margin-left: auto;
  margin-right: auto;
}
/* ABOVE THE LINE, AND CLOSE TO IT (Luke, 25 Sep). The button sat under the
   empty-state line with the line's full 2.5rem padding between -- a long
   drop from the heading to the thing to do. It leads now, and the line is
   a short caption beneath it. */
#favorites-goto-rankings.np-action-lead { margin-top: 1.5rem; }
#favorites-empty { padding: 0.7rem 1rem 1.5rem; }
.np-action:hover {
  transform: translateY(-1px);
  background:
    linear-gradient(var(--bg-panel-raised), var(--bg-panel-raised)) padding-box,
    var(--va-edge) border-box;
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.3);
}
.np-action-icon {
  width: 15px;
  height: 15px;
  flex: 0 0 auto;
  fill: none;
  stroke: var(--va, var(--accent));
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* The arcade button -- same face, base and press as + Add game. */
.np-action-lead {
  color: var(--va-ink);
  background: var(--va);
  border: none;
  box-shadow:
    0 3px 0 var(--va-deep),
    inset 0 1px 0 rgba(255, 255, 255, 0.35);
  margin-top: 0.75rem;
}
.np-action-lead .np-action-icon { stroke: var(--va-ink); }
.np-action-lead:hover {
  transform: translateY(1px);
  background: var(--va-light);
  box-shadow:
    0 2px 0 var(--va-deep),
    inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
.np-action-lead:active {
  transform: translateY(3px);
  box-shadow: 0 0 0 var(--va-deep), inset 0 1px 0 rgba(255, 255, 255, 0.3);
}

/* ---------- Hero tagline (replaces the old eyebrow text) ---------- */

.hero-tagline {
  font-size: 0.95rem;
  color: var(--text-muted);
  margin: 0;
}

/* ---------- Steam store link on detail page ---------- */

.game-detail-name-row {
  display: flex;
  align-items: baseline;
  gap: 0.9rem;
  flex-wrap: wrap;
}
.game-detail-name-row .game-detail-name {
  margin: 0 0 0.4rem;
}

.steam-store-link {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--steam, var(--gold));
  text-decoration: none;
  white-space: nowrap;
}
.steam-store-link:hover {
  text-decoration: underline;
}

/* ---------- Avatar picker popover ---------- */

.profile-avatar-controls {
  position: relative;
  align-self: flex-start;
  margin-top: 0.4rem;
}

.avatar-picker-popover {
  position: absolute;
  top: calc(100% + 0.5rem);
  left: 0;
  z-index: 20;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 1rem;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
  width: max-content;
  max-width: min(90vw, 340px);
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
}

.avatar-picker-label {
  font-size: 0.8rem;
  color: var(--text-muted);
  margin: 0;
}

.avatar-picker-options {
  display: flex;
  gap: 0.6rem;
  flex-wrap: wrap;
}

.avatar-picker-option {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.3rem;
  background: transparent;
  border: 1px solid var(--hairline);
  border-radius: 8px;
  padding: 0.5rem;
  cursor: pointer;
  color: var(--text-muted);
  font-size: 0.7rem;
}
.avatar-picker-option:hover {
  border-color: var(--gold-soft);
}
.avatar-picker-option img {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  object-fit: cover;
}


/* Named cover placeholder -- unreleased games often have no published
   art anywhere yet, so instead of a blank purple box the cover space
   shows the title on a subtle gradient, reading as intentional. */
.cover-placeholder-named {
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 0.5rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.85rem;
  line-height: 1.25;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.55);
  background: linear-gradient(160deg, var(--bg-panel-raised) 0%, var(--bg-panel) 70%);
  overflow: hidden;
}

/* ---------- Compilation context note ---------- */

.compilation-note {
  font-size: 0.85rem;
  color: var(--text-muted);
  margin: 0 0 0.6rem;
}
.compilation-note a {
  color: var(--gold);
  text-decoration: none;
}
.compilation-note a:hover {
  text-decoration: underline;
}

/* ---------- IGDB match picker ---------- */

.igdb-match-row {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.7rem;
  margin: 0 0 0.8rem;
  flex-wrap: wrap;
}

.igdb-match-status {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--text-muted);
}
.igdb-match-status.is-matched { color: var(--text-muted); }
.igdb-match-status.needs-attention { color: var(--gold); }

.igdb-match-popover {
  position: absolute;
  top: calc(100% + 0.4rem);
  left: 0;
  z-index: 25;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 0.9rem;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
  width: min(92vw, 420px);
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}
.igdb-match-popover input {
  font-family: inherit;
  font-size: 0.85rem;
  color: var(--text);
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.5rem 0.65rem;
}

.igdb-match-results {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  max-height: 320px;
  overflow-y: auto;
}

.igdb-match-result {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  background: transparent;
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.4rem;
  cursor: pointer;
  text-align: left;
  color: var(--text);
}
.igdb-match-result:hover { border-color: var(--gold-soft); }
.igdb-match-result img,
.igdb-match-nocover {
  width: 34px;
  height: 46px;
  object-fit: cover;
  border-radius: 3px;
  flex-shrink: 0;
  background: var(--bg-panel-raised);
}

.igdb-match-info { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.igdb-match-name { font-size: 0.82rem; }
.igdb-match-meta { font-size: 0.7rem; color: var(--text-muted); }

.igdb-tag {
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--gold);
  border: 1px solid var(--gold-soft);
  border-radius: 3px;
  padding: 0 0.25rem;
  margin-left: 0.2rem;
}

/* Completion-time badge on Up Next cards -- the one place where "how long
   is this" actually changes the decision. */
.ttb-badge {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  color: var(--text-muted);
  white-space: nowrap;
}

/* Critic score: the review count is part of the number's meaning, so it
   sits with it rather than being hidden in a tooltip. */
.critic-suffix {
  font-size: 0.7em;
  opacity: 0.7;
}
.critic-count {
  font-family: var(--font-mono);
  font-size: 0.6rem;
  color: var(--text-muted);
  white-space: nowrap;
}

.connection-card-connect {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.connection-card-connect input {
  font-family: inherit;
  font-size: 0.8rem;
  color: var(--text);
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.45rem 0.6rem;
}

/* ---------- Add game by hand ---------- */

.add-game-wrap {
  position: relative;
  /* No margin-left:auto -- it forced the button onto its own line
     whenever the filter row wrapped, which looked like a mistake rather
     than a right-aligned action. */
}

.add-game-popover {
  position: absolute;
  top: calc(100% + 0.5rem);
  right: 0;
  z-index: 30;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 0.9rem;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
  width: min(92vw, 420px);
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}
/* The x is the phone sheet's way out; a desktop popover closes by clicking
   away and never shows it. Drawn in the phone block. */
.add-game-close { display: none; }
.add-game-popover input {
  font-family: inherit;
  font-size: 0.85rem;
  color: var(--text);
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.5rem 0.65rem;
}

.add-game-results {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  max-height: 320px;
  overflow-y: auto;
}

/* Already-owned entries stay visible -- telling someone a game is already
   there beats silently omitting it and leaving them wondering why their
   search found nothing.

   THEY USED TO BE UNCLICKABLE: opacity 0.45, cursor default, a disabled
   button that did nothing when tapped (#181, 5 Oct, "Elden ring" on a
   phone). Now an owned result is a LINK to the game's page, so it is
   drawn at full strength like any other row -- dimmed, it read as
   switched off, which is exactly the wrong message for a row that now
   goes somewhere. What tells it apart is its words ("In your library" and
   a trailing "Open") rather than a faded look. The <a> needs the
   underline off; it takes the row's look from .igdb-match-result. */
a.igdb-match-result { text-decoration: none; }

/* The trailing word that says what a tap does (#181). Pushed to the row's
   end, in the muted ink the meta line uses. "+ Add" takes the gold the
   DLC/edition tags and the "+ Mark completed" plus already use for an
   invitation, so the one row that changes your library is the one that
   looks like it will. No new colours. */
.igdb-match-action {
  margin-left: auto;
  flex-shrink: 0;
  padding: 0.15rem 0.5rem;
  font-size: 0.72rem;
  font-weight: 600;
  white-space: nowrap;
  color: var(--text-muted);
  border: 1px solid var(--hairline);
  border-radius: 4px;
}
.igdb-match-action.is-add {
  color: var(--gold);
  border-color: var(--gold-soft);
}
.igdb-match-result:hover .igdb-match-action { border-color: var(--gold-soft); }

/* ---------- Delete control ---------- */

.detail-actions {
  display: flex;
  align-items: center;
  gap: 0.8rem;
  flex-wrap: wrap;
}

.btn-danger {
  background: transparent;
  border: 1px solid rgba(220, 90, 90, 0.5);
  color: rgba(230, 120, 120, 0.95);
}
.btn-danger:hover {
  background: rgba(220, 90, 90, 0.12);
  border-color: rgba(220, 90, 90, 0.8);
}

/* ---------- Native control rendering ---------- */

/* The date fields' calendar button was invisible.
   `<input type="date">` draws its own picker icon, and the browser picks
   the colour from the element's color-scheme -- which defaults to light,
   so it painted a near-black glyph onto a near-black field. Same for the
   calendar popup itself and for select dropdowns.
   Declaring the scheme is the fix rather than `filter: invert(1)`: the
   filter only repaints the icon, leaves the popup light, and inverts
   again the moment anything here gains a light theme. There is no light
   theme today, so this is scoped to the controls rather than :root --
   it keeps scrollbars and the rest of the chrome exactly as they are. */
input,
select,
textarea {
  color-scheme: dark;
}

/* ---------- Number inputs ---------- */

/* The spinner arrows are a hazard on a rating field: they're small, they
   sit exactly where a cursor lands, and a stray scroll over a focused
   input silently changes the value. Typing is the only sane way to enter
   a score. */
input[type="number"] {
  -moz-appearance: textfield;
  appearance: textfield;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

/* ---------- Filter controls, unified ---------- */

/* Selects and search inputs sat at different heights and weights from the
   toggle buttons beside them, which made the filter row look assembled
   rather than designed. */
.controls select,
.controls input[type="search"] {
  font-family: inherit;
  font-size: 0.8rem;
  color: var(--text);
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 8px;
  padding: 0.42rem 0.7rem;
  height: 2rem;
  line-height: 1;
}
.controls select:hover,
.controls input[type="search"]:hover {
  border-color: var(--gold-soft);
}
.controls input[type="search"] {
  min-width: 210px;
}
.controls .view-toggle-btn {
  height: 2rem;
}

/* ---------- Inline editable cells ---------- */

/* Rating and Owned are edited in place because they're the two fields a
   person changes constantly while going through a library, and opening a
   detail page for each is friction that stops it happening at all. */
.rating-input {
  width: 3.4rem;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--gold);
  background: transparent;
  border: 1px solid transparent;
  border-radius: 5px;
  padding: 0.15rem 0.3rem;
  text-align: center;
}
.rating-input::placeholder { color: var(--text-faint); }
.rating-input:hover { border-color: var(--hairline); }
/* A QUIET RATING CELL (#138): no rating and the game not finished, so
   nothing shows -- the box is there, invisible, and a pointer finds it
   by hovering the row or tabbing to it. Under a finger there is no hover
   and the phone block keeps it out of the row's tap. */
.rating-cell.is-quiet .rating-input { opacity: 0; }
.rating-cell.is-quiet .rating-input:focus { opacity: 1; }
/* (The hover that reveals it is in the one @media (hover: hover) block,
   with the scrollbars -- one block per condition, media-dead-decl-check.) */
.rating-input:focus {
  border-color: var(--gold);
  background: var(--bg-panel-raised);
  outline: none;
}

/* ---------- Calendar pills, coloured by platform ---------- */

/* Each platform gets its own colour so a month's shape is readable at a
   glance -- which releases are PlayStation, which are Steam -- without
   having to read every title. Colours match the platform badges used
   elsewhere so the association is learned once. */
/* Search inputs outside the .controls wrapper still need the shared look. */
.filter-input {
  font-family: inherit;
  font-size: 0.8rem;
  color: var(--text);
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 8px;
  padding: 0.42rem 0.7rem;
  height: 2rem;
  line-height: 1;
  min-width: 210px;
}
.filter-input:hover { border-color: var(--gold-soft); }
.filter-input:focus { border-color: var(--gold); outline: none; }

/* Add game sits under the filter row, right-aligned -- it's an action
   rather than a filter, so grouping it with them read as a fourth
   dropdown. */
/* Sits with the filters rather than floating right on its own line.
   Right-aligned it read as detached from the controls it belongs to. */
.add-game-below {
  display: flex;
  justify-content: flex-start;
}
.add-game-below .add-game-popover {
  left: 0;
  right: auto;
}
/* THE HEADER'S HOME FOR THE SAME POPOVER (#165, 2 Oct): a point, not a
   box. app.js puts it under the header's glass (positionAddGameHost) and
   the popover hangs from it exactly as it hangs from the Add game button
   in the Library. Over the site header (z-index 30) and the phone's
   bottom bar (40); on a phone the popover inside is a fixed sheet and
   takes nothing from where this is. */
.add-game-host {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  z-index: 70;
}

/* ---------- Top 100 list mode, tightened ---------- */

/* The stat bar, rank field and drag handle were each sized for a card
   twice this size, so on a dense grid they covered most of the art. */
.top100-grid-stat-bar {
  padding: 0.2rem 0.3rem;
  font-size: 0.66rem;
}
.top100-grid-rank-badge {
  top: 4px;
  left: 4px;
  padding: 0.05rem 0.3rem;
  font-size: 0.72rem;
}
input.top100-grid-rank-badge {
  /* Wide enough for three digits.
     1.9rem (30px) fitted two, and a number input reserves room for
     spinner arrows on top of its text -- so rank 100 needed 35px and got
     clipped. Every Top 100 has a rank 100.
     An earlier attempt raised `.top100-rank-input` instead, which never
     applied: this selector is more specific AND later in the file. Check
     which rule actually wins before declaring a width fixed. */
  width: 2.5rem;
  text-align: center;
}
.top100-grid-name {
  font-size: 0.8rem;
}

/* ---------- DLC on the base game's page ---------- */

.dlc-block {
  margin: 1.5rem 0;
  padding-top: 1.5rem;
  border-top: 1px solid var(--hairline);
}

.dlc-list {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

.dlc-row {
  display: flex;
  /* Wraps, so the attribution line drops beneath rather than becoming a
     third column. It's a sentence, not a stat -- squeezed into the row it
     overflowed and read as truncated data. */
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: baseline;
  gap: 0.15rem 1rem;
  padding: 0.45rem 0.6rem;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  text-decoration: none;
  color: var(--text);
  font-size: 0.85rem;
}
.dlc-row:hover { border-color: var(--gold-soft); }

.dlc-row-meta {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--text-muted);
  white-space: nowrap;
}

/* Shown as a sum rather than folded into the game's own hours: every
   chart totals hours across rows, so adding DLC time to the parent while
   the DLC row still exists would count it twice. */
.dlc-total {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--gold);
  margin: 0.4rem 0 0;
}

/* ---------- Search clear button ---------- */

/* The native clear button is HIDDEN, not restyled.
   `attachSearchClearButtons` already adds a JS one -- it exists because
   Firefox renders none at all -- and this rule was styling WebKit's
   native button rather than suppressing it. So Safari drew both: two X
   icons, both working, side by side.
   One source of clear buttons. The JS one, because it is the only one
   present in every browser. */
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
  -webkit-appearance: none;
  appearance: none;
  display: none;
}

.search-wrap {
  position: relative;
  display: inline-flex;
  align-items: center;
}
.search-clear {
  position: absolute;
  right: 0.5rem;
  background: none;
  border: none;
  color: var(--text-muted);
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
  padding: 0 0.15rem;
}
.search-clear:hover { color: var(--gold); }
/* NOT WITHOUT ITS FIELD (6 Oct). The button shows whenever its field holds
   text, and the header's search field folds away with its text still in
   it -- so the x stayed on screen, alone, on top of the magnifying glass.
   A clear button belongs to a visible field. */
.search-wrap:has(> input[hidden]) > .search-clear { display: none; }

/* Dismiss for the "play this next" suggestion -- it used to be stuck on
   screen until you navigated away. */
.upnext-pick {
  position: relative;
}
.upnext-pick-close {
  position: absolute;
  top: 0.4rem;
  right: 0.5rem;
  background: none;
  border: none;
  color: var(--text-muted);
  font-size: 1.2rem;
  line-height: 1;
  cursor: pointer;
  padding: 0.1rem 0.3rem;
}
.upnext-pick-close:hover { color: var(--gold); }

/* ---------- Platform pills ---------- */

/* Each platform named in a filled pill, coloured to match its badge. The
   previous icon-plus-text treatment rendered "Steam/PlayStation" as a
   slash-joined string, which was slow to scan and stretched the column
   awkwardly for multi-platform games. */
.platform-pill {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 0.68rem;
  line-height: 1;
  padding: 0.25rem 0.45rem;
  border-radius: 999px;
  margin: 0.1rem 0.25rem 0.1rem 0;
  white-space: nowrap;
  border: 1px solid transparent;
}

.platform-pill.steam {
  background: rgba(102, 192, 244, 0.14);
  border-color: rgba(102, 192, 244, 0.4);
  color: #9ad4f5;
}
.platform-pill.playstation {
  background: rgba(122, 140, 255, 0.14);
  border-color: rgba(122, 140, 255, 0.4);
  color: #a8b4ff;
}
/* THROUGH A SUBSCRIPTION (0175): the platform's colour, because it is still
   a PlayStation game, with a dashed edge, because it is yours only while the
   membership lasts. The pill's text says which subscription. */
.platform-pill.is-subscription { border-style: dashed; }
.platform-pill.xbox {
  background: rgba(110, 200, 120, 0.14);
  border-color: rgba(110, 200, 120, 0.4);
  color: #96d8a0;
}
.platform-pill.nintendo {
  background: rgba(230, 100, 100, 0.14);
  border-color: rgba(230, 100, 100, 0.4);
  color: #f09a9a;
}
.platform-pill.epic,
.platform-pill.blizzard,
.platform-pill.pc,
.platform-pill.unknown {
  background: rgba(255, 255, 255, 0.06);
  border-color: var(--hairline);
  color: var(--text-muted);
}

.overview-years-controls {
  margin-bottom: 0.9rem;
  align-items: center;
}
.overview-years-label {
  font-size: 0.78rem;
  color: var(--text-muted);
}

/* ---------- Chart summary rows ---------- */

/* THE STICKY AND THE BACKGROUND WENT TOGETHER, because one only existed for
   the other.
   This row used to stick to the top of its block while you scrolled through
   it, and an opaque background was what stopped the bars sliding under the
   numbers. The blocks do not scroll internally any more -- they carry
   `overflow: hidden` and size to their content -- so the sticky has had
   nothing to do for a while: measured on both blocks that have this row,
   scrollHeight equals clientHeight.
   Keeping the background without the sticky is a slab for no reason; keeping
   the sticky without the background is numbers printed over a chart. Both
   out. The figures sit straight on the tile. */
.overview-summary-row {
  padding: 0.35rem 0;
  margin-bottom: 0.3rem;
}

.summary-stat {
  font-family: var(--font-mono);
  /* 0.9, not 0.72. These three are the headline of the panel -- the share
     of your Steam play that happened on a Deck -- and they were set
     smaller than the chart's own axis ticks, which made the summary read
     as a footnote to the thing it summarises. */
  font-size: 0.9rem;
  color: var(--text-muted);
  margin-right: 0.9rem;
  white-space: nowrap;
}
/* The figure itself carries the line, so it takes the display face at
   size -- the same treatment the figures above the bars get, and for the
   same reason: the number is what you are here for and the words around
   it are the caption. */
.summary-stat strong {
  font-family: var(--font-display);
  font-size: 1.35em;
  color: var(--gold);
  font-weight: 700;
  /* The display face sits high, so its baseline needs nudging back down
     to sit with the words beside it. */
  vertical-align: -0.04em;
  margin-right: 0.12em;
}

/* Device pills -- where a game was actually played, distinct from where
   it was bought. Deliberately quieter than the platform pills so the two
   columns don't compete. */
.device-pill {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 0.64rem;
  line-height: 1;
  padding: 0.22rem 0.4rem;
  border-radius: 999px;
  margin: 0.1rem 0.2rem 0.1rem 0;
  white-space: nowrap;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--hairline);
  color: var(--text-muted);
}
.device-pill.deck {
  background: rgba(102, 192, 244, 0.10);
  border-color: rgba(102, 192, 244, 0.3);
  color: #9ad4f5;
}

/* ---------- Backlog clean-up ---------- */

.cleanup-overlay {
  position: fixed;
  inset: 0;
  background: rgba(10, 6, 15, 0.8);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
  padding: 1rem;
}

/* THE IN-PAGE QUESTION (#119 box 5): the cleanup sheet's own panel, over
   everything -- it can be asked from inside the game page's edit panel or
   the gear menu, both of which sit above an ordinary overlay. The title
   wraps: it carries a game's name. */
.confirm-overlay { z-index: 600; }
.confirm-overlay[hidden] { display: none; }
.confirm-modal { width: min(92vw, 26rem); }
.confirm-modal .card-row-title { white-space: normal; overflow: visible; line-height: 1.3; }
.confirm-body { color: var(--text-muted); font-size: 0.9rem; line-height: 1.5; }
.confirm-body p { margin: 0 0 0.6rem; }
.confirm-body p:last-child { margin-bottom: 0; }
.confirm-body:empty { display: none; }
.confirm-actions { justify-content: flex-end; }
.confirm-actions .btn { flex: none; min-width: 6.5rem; }

/* "Or connect by online ID" (#119 box 3): a link under the sign-in
   button, not a second form. Quiet -- it is the other way in. */
.psn-online-id-toggle {
  align-self: flex-start;
  padding: 0;
  border: 0;
  background: none;
  color: var(--text-muted);
  font: inherit;
  font-size: 0.8rem;
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
}
.psn-online-id-toggle:hover, .psn-online-id-toggle:focus-visible { color: var(--text); }
.psn-online-id-toggle[hidden], .psn-online-id[hidden] { display: none; }
.psn-online-id { display: flex; flex-direction: column; gap: 0.5rem; }

/* IN THE SITE'S SETTINGS FORMAT (#141, 1 Oct; chosen on the real page
   from the card as it was, this, a version with the choices under the
   fields, and a tighter one): the gear menu's surface -- a raised panel
   with a hairline edge and 14px corners -- a body-face sentence-case
   heading, sentence-case labels, the site's own fields and quiet
   buttons, the choices in the head row, and no helper paragraph under
   anything. It was a display-face heading over a centred card with a
   poster-sized cover and small print below the buttons, which matched
   nothing else on the site. Centred on a phone too, not docked. */
.cleanup-modal {
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: 14px;
  padding: 1rem 1.1rem 1.1rem;
  width: min(94vw, 420px);
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  box-shadow: 0 18px 44px rgba(0, 0, 0, 0.5);
}
.cleanup-head .card-row-title {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  font-family: var(--font-body);
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: 0;
  text-transform: none;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The choices in the head row: at their own size, right of the title. */
.cleanup-head { gap: 0.6rem; }
.cleanup-head .cleanup-actions { flex: 0 0 auto; gap: 0.35rem; margin-top: 0; }
.cleanup-head .cleanup-actions .btn { flex: 0 0 auto; padding: 0.35rem 0.7rem; font-size: 0.78rem; }
/* The queue's head: the choices at the left, the x at the right. */
.cleanup-head .cleanup-actions:first-child { margin-right: auto; }
/* The one line that stays under a head, the count, small and faint. */
.cleanup-modal > .section-note[id] { font-size: 0.74rem; color: var(--text-faint); margin: 0; }
/* The queue card is a row about a game, not a poster. */
.cleanup-modal .cleanup-cover-wrap { width: 56px; }
.cleanup-modal .cleanup-card { min-height: 0; }
/* The choices under a card (Clean up): right-aligned, at their own size. */
.cleanup-modal > .cleanup-actions { justify-content: flex-end; gap: 0.45rem; }
.cleanup-modal > .cleanup-actions .btn { flex: 0 0 auto; padding: 0.45rem 0.85rem; font-size: 0.82rem; }
.cleanup-modal .review-toolbar .btn { font-size: 0.72rem; }

/* The rate-or-review panel is the same overlay as the backlog cleanup,
   wider because it is a LIST rather than one card -- 420px is right for a
   single cover with a decision under it and cramped for thirty rows. */
#review-overlay .cleanup-modal {
  width: min(94vw, 560px);
}

.review-list {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  /* Scrolls inside the panel rather than growing it past the viewport.
     37 finished games on one library, and the header and count have to
     stay visible while you look down the list. */
  max-height: min(60vh, 460px);
  overflow-y: auto;
  scrollbar-gutter: stable;
}

.review-row {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  padding: 0.35rem 0.45rem;
  margin: 0 -0.45rem;
  border-radius: 8px;
  text-decoration: none;
  color: inherit;
  transition: background 0.12s ease;
}

.review-row:hover,
.review-row:focus-visible {
  background: var(--bg-panel-raised);
}

.review-cover-wrap {
  position: relative;
  width: 34px;
  aspect-ratio: 2 / 3;
  border-radius: 4px;
  overflow: hidden;
  flex-shrink: 0;
  background: var(--bg-panel-raised);
}

.review-cover {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.review-row-main {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  /* Lets a long name shrink and ellipsise instead of pushing the hours
     off the row. */
  min-width: 0;
  flex: 1;
}

.review-row-name {
  color: var(--text);
  font-size: 0.88rem;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* What is missing, stated plainly and quietly. Not a warning colour:
   this is a list of things you could say something about, not a list of
   mistakes. */
.review-row-missing {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.04em;
  color: var(--text-faint);
}

.review-row-hours {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--text-muted);
  flex-shrink: 0;
}

.cleanup-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.cleanup-close {
  background: none;
  border: none;
  color: var(--text-muted);
  font-size: 1.4rem;
  line-height: 1;
  cursor: pointer;
}
.cleanup-close:hover { color: var(--gold); }

.cleanup-card {
  display: flex;
  gap: 0.9rem;
  align-items: center;
  min-height: 130px;
  touch-action: pan-y;
}

.cleanup-cover-wrap {
  position: relative;
  width: 88px;
  aspect-ratio: 2 / 3;
  border-radius: 8px;
  overflow: hidden;
  flex-shrink: 0;
  background: var(--bg-panel-raised);
}
.cleanup-cover, .cleanup-cover-placeholder {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.cleanup-name { font-weight: 600; margin: 0 0 0.3rem; }
.cleanup-meta {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--text-muted);
  margin: 0;
}

.cleanup-actions {
  display: flex;
  gap: 0.6rem;
}
.cleanup-actions .btn { flex: 1; }

.platinum-badge {
  flex: 0 0 auto;
  white-space: nowrap;
  font-family: var(--font-mono);
  font-size: 0.58rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: #cfd6e6;
  background: rgba(190, 205, 230, 0.14);
  border: 1px solid rgba(190, 205, 230, 0.35);
  border-radius: 3px;
  padding: 0.1rem 0.28rem;
}

/* ---------- Achievements ---------- */

/* A grid of the game's own achievement art rather than a bare
   percentage. The summary line still carries the numbers, but the icons
   are what make it read as a record of what you did rather than a
   statistic about it. */
.achievements-block {
  margin: 1.5rem 0;
  padding-top: 1.5rem;
  border-top: 1px solid var(--hairline);
}

.achievements-head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 0.7rem;
  flex-wrap: wrap;
}
.achievements-head .card-row-title { margin: 0; }

.achievements-trophy {
  display: inline-flex;
  color: var(--gold);
}

.achievements-summary {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--text-muted);
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 0.4rem;
}

/* A grid rather than a wrapping flex row -- gridTemplateColumns is
   readable from JS, which is how the two-row trim knows how many tiles
   fit at the current width. */
.achievements-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
  gap: 0.4rem;
}

.achievement-icon {
  width: 100%;
  aspect-ratio: 1;
  border-radius: 8px;
  overflow: hidden;
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: default;
  transition: transform 0.12s ease, border-color 0.12s ease;
}
.achievement-icon:hover {
  transform: translateY(-2px);
  border-color: var(--gold-soft);
}
.achievement-icon img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* No icon supplied -- shows the first letters rather than an empty box. */
.achievement-icon-blank {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--text-muted);
  text-transform: uppercase;
}

/* PSN trophy tiers, as a thin ring rather than a fill -- the icon art is
   the subject, the tier is a footnote. */
.achievement-icon.rarity-platinum { border-color: rgba(190, 205, 230, 0.7); }
.achievement-icon.rarity-gold     { border-color: rgba(228, 190, 90, 0.7); }
.achievement-icon.rarity-silver   { border-color: rgba(190, 195, 205, 0.6); }
.achievement-icon.rarity-bronze   { border-color: rgba(196, 130, 80, 0.6); }

/* Progress bar for games with no per-achievement detail -- Xbox never
   supplies it, and Steam/PSN games show this until their next backfill.
   Better than an empty block: it still says how far through you got. */
.achievement-bar-wrap {
  /* See the note below: a child of .achievements-grid needs an explicit
     span or it lands in one 44px icon column. */
  grid-column: 1 / -1;
  width: 100%;
  height: 6px;
  background: var(--bg-panel-raised);
  border-radius: 999px;
  overflow: hidden;
}
.achievement-bar {
  height: 100%;
  background: linear-gradient(90deg, var(--gold-soft), var(--gold));
  border-radius: 999px;
}
/* A child of .achievements-grid, which lays out 44px icon columns.
   Without an explicit span this becomes a grid ITEM in one of them, so
   "Individual achievements will appear after the next sync" wrapped to
   one word per line. `width: 100%` cannot fix it -- 100% of a 44px
   column is 44px. */
.achievement-bar-note {
  grid-column: 1 / -1;
  font-size: 0.7rem;
  color: var(--text-faint);
  margin: 0.4rem 0 0;
}
.achievements-summary strong { color: var(--gold); }

/* ---------- Game detail header ---------- */

/* Platform, developer, publisher and release date on one line under the
   title. They identify the game rather than measuring it, so giving them
   full stat cells overweighted them next to hours played. */
/* Quieter than the pill it introduces -- it is a label, not a fact. */
/* One line again: the byline is developer, publisher and release date.
   Ownership moved to .detail-ownership, in the cover column above the
   store links, so the two-line wrappers this used to need are gone. */
.game-detail-byline { flex-wrap: wrap; }

/* A SECTION LABEL, NOT A SENTENCE.
   This read "Owned on [pill]" on one line, in the byline's size and
   colour -- which made it a stray phrase sitting above a heading that
   looked nothing like it. It introduces the pills exactly as "Look it
   up" introduces the store list, so it is built the same way: the words
   become a label on their own line, the pills go underneath.
   The label itself is styled by .byline-where below, which now matches
   .look-it-up-label. */
.detail-ownership {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.4rem;
  margin: 0.9rem 0 0;
}
.detail-ownership[hidden] { display: none; }
/* Played on + Subscription service. A wrapper only a phone lays out (two
   columns, in the phone block); here it is as if it were not there. */
.detail-where { display: contents; }

.detail-status-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin: 0.55rem 0 0;
}
.detail-status-chips[hidden] { display: none; }

/* The control sits above the readout, so "+ Add rating" appears where the
   score will, rather than off to one side of it. */
.review-rating-col {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
}

/* Matches .look-it-up-label exactly -- the two labels sit one above the
   other in the same column and were speaking differently. */
.byline-where {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

/* The pills sit as one row under the label, rather than each taking a
   line of its own -- a game owned on two platforms is one fact. */
.ownership-pills {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
}

/* SUBSCRIPTION SERVICE pills (renderDetailSubscriptions). Each service in
   its own brand-ish colour, fixed rather than themed, so PS Plus never
   reads as a PlayStation store pill or as the accent. */
.subscription-pill {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  font-size: 0.72rem;
  padding: 0.2rem 0.6rem;
  border-radius: 999px;
  border: 1px solid currentColor;
  background: rgba(255, 255, 255, 0.04);
  white-space: nowrap;
}
.subscription-pill.ps_plus   { color: #f7d154; }
.subscription-pill.game_pass { color: #8ee06a; }
/* Ended: the service's colour, faded, with the word on the pill. */
.subscription-pill.is-lapsed {
  opacity: 0.55;
  border-style: dashed;
}
.subscription-lapsed-tag {
  font-family: var(--font-mono);
  font-size: 0.58rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-muted);
}

.game-detail-byline {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.4rem;
  font-size: 0.82rem;
  color: var(--text-muted);
  margin: 0.2rem 0 0.6rem;
}
.byline-sep {
  color: var(--text-faint);
  margin: 0 0.1rem;
}

/* Genres and franchise from IGDB -- populated since the identity work
   and never shown until now. */
.game-detail-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  margin-bottom: 0.9rem;
}
/* NEUTRAL, NOT THE ACCENT (Luke, 29 Sep). Genres were drawn in --gold,
   which every theme redefines as its own accent -- the same colour as the
   Owned pill beneath them, so "Puzzle" and "Owned" read as one kind of
   thing. Genres describe the game; they get the quiet surface, and the
   accent stays for what is about you. */
.game-tag {
  font-size: 0.68rem;
  padding: 0.2rem 0.5rem;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid rgba(255, 255, 255, 0.12);
  color: var(--text-muted);
  white-space: nowrap;
}

/* The stat row carries only measurements now, so it can afford to be
   louder -- these are the numbers you came to read. */
.game-detail-stat-row {
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 0.9rem 1.1rem;
  gap: 2rem;
}
.game-detail-stat-value {
  font-size: 1.15rem;
  font-weight: 600;
  color: var(--text);
}
/* A tile that has words where its number would be ("Too new to tell",
   #153): the label's quiet weight at a readable size, not a sentence set
   as a figure. By id: the phone's stat tiles size their value with a
   longer selector further down, and a class alone lost to it (the words
   came out at the figure's 20.8px and wrapped to two lines). */
#detail-meta-ttb.is-words {
  font-size: 0.8rem;
  font-weight: 500;
  color: var(--text-muted);
}
.game-detail-stat-label {
  color: var(--text-faint);
}

/* Plain text. The gradient made long titles fade toward gold at the
   right, which read as a rendering artefact rather than a deliberate
   effect -- and it fought the cover-art backdrop behind it. */
.game-detail-name {
  color: var(--text);
}

/* Hints sit quietly under their field rather than competing with it. */
.game-detail-fields .field-hint {
  font-size: 0.68rem;
  color: var(--text-faint);
}

/* Back button was stretching to the section width -- it's a small action,
   not a banner. */
#game-detail-back {
  align-self: flex-start;
  width: auto;
  display: inline-flex;
  margin-bottom: 1rem;
}
.game-detail-section {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
}
.game-detail-section > .game-detail-layout { width: 100%; }

/* ---------- Recent achievements ---------- */

/* Ten across, two rows. Icons only -- the name, game and date live in
   the tooltip, the same way the game detail page handles them. */
/* Columns follow the available width, not a fixed ten.
   Ten columns with a 560px cap meant a section at quarter width squeezed
   its icons to one per row -- a tall thin column, which is exactly the
   shape resizing was supposed to avoid.
   auto-fill at a 44px minimum keeps icons a sane size and fits as many
   as the width allows: two or three in a small section, ten in a full
   one. The COUNT scales too (ACHIEVEMENTS_BY_SIZE), so a small section
   is small rather than narrow and tall. */
.recent-achievements-row {
  display: grid;
  /* auto-fill with a 64px floor.
     At 44px the icons simply shrank to fit whatever count was given --
     twenty in a row at full width, each the size of a stamp. A floor
     makes them WRAP instead, which is the behaviour that lets a small
     section be two or three across and a full one ten. */
  grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
  gap: 0.4rem;
  margin-top: 0.5rem;
  /* No max-width: the section's own span decides how wide it is now. */
}

.recent-achievement-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 1;
  border-radius: 8px;
  overflow: hidden;
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  transition: border-color 0.12s ease, transform 0.12s ease;
}
.recent-achievement-icon:hover {
  border-color: var(--gold-soft);
  transform: translateY(-2px);
}
.recent-achievement-icon img { width: 100%; height: 100%; object-fit: cover; }
.recent-achievement-icon.rarity-platinum { border-color: rgba(190, 205, 230, 0.7); }
.recent-achievement-icon.rarity-gold     { border-color: rgba(228, 190, 90, 0.7); }
.recent-achievement-icon.rarity-silver   { border-color: rgba(190, 195, 205, 0.6); }
.recent-achievement-icon.rarity-bronze   { border-color: rgba(196, 130, 80, 0.6); }

.recent-achievement-blank {
  font-family: var(--font-mono);
  font-size: 0.65rem;
  color: var(--text-muted);
  text-transform: uppercase;
}

/* ---------- Game awards ---------- */

/* Five per row, fixed. auto-fill gave a different count at every window
   width, so a set built to read as two rows of five became three rows of
   three on a narrower screen -- which matters when the grid is the thing
   being shared. */
/* Sized for sharing, not for filling a desktop. A screenshot of this is
   the point -- it has to stay legible when someone opens it on a phone
   or in a Discord thumbnail, which means a bounded width rather than
   however wide the window happens to be. */
.awards-grid {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 0.4rem;
  margin-top: 0.5rem;
  margin-bottom: 1.4rem;
  max-width: 620px;
}

.award-card {
  display: flex;
  flex-direction: column;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: 10px;
  overflow: hidden;
}
.award-card.is-editing {
  cursor: pointer;
}
.award-card.is-editing:hover {
  border-color: var(--gold-soft);
}

.award-cover-wrap {
  position: relative;
  width: 100%;
  aspect-ratio: 2 / 3;
  display: block;
  background: var(--bg-panel-raised);
}
.award-cover, .award-cover-placeholder {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.award-empty {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.6rem;
  color: var(--text-faint);
}

/* The category is the point of the card, so it reads louder than the
   game -- the screenshot this is modelled on puts the label front and
   centre and it's right to. */
.award-label {
  padding: 0.35rem 0.4rem 0.1rem;
  font-size: 0.66rem;
  font-weight: 600;
  text-align: center;
  color: var(--text);
}
.award-game {
  padding: 0 0.4rem 0.4rem;
  font-size: 0.58rem;
  text-align: center;
  color: var(--text-muted);

  /* Two lines, not one truncated one.
     `white-space: nowrap` meant "The Witcher 3: Wild Hunt" showed as "The
     Witcher 3: Wild..." -- on the ONE view built to be screenshotted and
     shared, where the game's name is half the point of the tile.
     Clamped rather than left to wrap freely: a hard two-line ceiling
     keeps every tile the same height, so a long title can't shunt the
     grid around. Titles that still overflow get an ellipsis at the end of
     the second line, which is a far rarer case. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  line-height: 1.25;
  /* Reserve both lines whether or not the second is used, so single-line
     and two-line tiles keep their captions aligned.
     The bottom padding is in the calc because box-sizing is border-box
     globally -- without it the reserved height swallows the padding and
     one-line captions come out shorter than two-line ones. */
  min-height: calc(2.5em + 0.4rem);
}

.award-picker-results {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  max-height: 45vh;
  overflow-y: auto;
}
.award-picker-option {
  display: flex;
  justify-content: space-between;
  gap: 0.8rem;
  padding: 0.4rem 0.55rem;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 6px;
  color: var(--text);
  font-size: 0.82rem;
  cursor: pointer;
  text-align: left;
}
.award-picker-option:hover {
  border-color: var(--gold-soft);
  background: var(--bg-panel-raised);
}
.award-picker-platform {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  color: var(--text-muted);
}

/* ---------- Year in games ---------- */

.wrapped-block {
  margin: 1.5rem 0;
  padding: 1.2rem;
  border-radius: var(--radius);
  background:
    radial-gradient(ellipse 500px 300px at 10% 0%, rgba(var(--act-rgb), 0.14), transparent 60%),
    radial-gradient(ellipse 400px 250px at 90% 100%, rgba(var(--glow-warm-rgb), 0.12), transparent 60%),
    var(--bg-panel);
  border: 1px solid var(--gold-soft);
}

.wrapped-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 0.9rem;
}
.wrapped-title {
  font-family: var(--font-display);
  font-size: 1.4rem;
  margin: 0;
  background: linear-gradient(92deg, var(--gold), var(--pink, rgb(var(--glow-warm-rgb))));
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}
.wrapped-close {
  background: none;
  border: none;
  color: var(--text-muted);
  font-size: 1.3rem;
  line-height: 1;
  cursor: pointer;
}
.wrapped-close:hover { color: var(--gold); }

.wrapped-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 0.7rem;
}

.wrapped-card {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  padding: 0.7rem 0.8rem;
  background: rgba(0, 0, 0, 0.22);
  border: 1px solid var(--hairline);
  border-radius: 10px;
}
.wrapped-card-label {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-faint);
}
.wrapped-card-value {
  font-size: 1.05rem;
  font-weight: 600;
  color: var(--text);
  line-height: 1.2;
}
.wrapped-card-sub {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  color: var(--gold);
}

/* Wrapped button in the banner. Appears only once there's a year worth
   summarising -- in December this would surface on its own. */
.hero-wrapped-btn {
  margin-bottom: 0.6rem;
}

/* Wrapped panel sits inside the overlay now rather than on the profile. */
#wrapped-overlay .wrapped-block {
  width: min(94vw, 720px);
  margin: 0;
}

.detail-save-state {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  color: var(--text-faint);
}

/* Three grouped panels rather than one long form. Related fields sit
   together, and the grouping itself says what each field is for -- which
   is most of what the removed hint text was doing. */
/* One full-width row per group rather than a grid of boxes. Game Type
   has three short fields and was leaving most of its column empty, while
   the fields inside each group read better side by side than stacked. */
.game-detail-fields {
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
}

.field-group {
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 0.9rem 1rem;
}
.field-group-title {
  font-family: var(--font-mono);
  font-size: 0.64rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--gold);
  margin: 0 0 0.7rem;
}
.field-group-body {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 0.7rem 1.4rem;
  align-items: start;
}
.field-group label {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  font-size: 0.72rem;
  color: var(--text-faint);
}

/* View mode: values as text, controls and hints out of the way. Editing
   is a deliberate act rather than the resting state -- almost everything
   here fills itself in. */
/* Scoped to .field-group, NOT the whole fields container. The
   playthroughs block lives inside that container too, and hiding every
   input within it left rows of labels with nothing beside them -- the
   fields were there, just invisible. */
.field-group select,
.field-group input,
.field-group .field-hint {
  display: none;
}
.game-detail-fields.is-editing .field-group select,
.game-detail-fields.is-editing .field-group input,
.game-detail-fields.is-editing .field-group .field-hint {
  display: block;
}
.game-detail-fields.is-editing .field-readout {
  display: none;
}

.field-readout {
  font-size: 0.92rem;
  font-weight: 600;
  color: var(--text);
}

/* Stat row: smaller, quieter labels, with the numbers still carrying the
   weight. */
.game-detail-stat-row {
  gap: 1.6rem;
  padding: 0.75rem 1rem;
}
.game-detail-stat-label {
  font-size: 0.6rem;
  letter-spacing: 0.06em;
}
.game-detail-stat-value {
  font-size: 0.98rem;
}

/* ---------- Library: wider, with cover art ---------- */

/* Every tab is wider than the original 980px reading measure.
   That width suited prose; these are grids of cover art, and the Top 100
   could only fit eight across when ten was the intent. The Library goes
   wider still because it's an eight-column table.

   The NAV keeps a fixed width regardless, so switching tabs doesn't
   shift the tabs themselves sideways -- which it did when only one view
   was wide. */
/* One width for every view.
   Widening `main` for the Library moved the nav sideways whenever you
   switched to it, because the nav is centred inside main. The Library
   gets its extra room from its own table instead, which leaves the page
   frame -- and therefore the tabs -- exactly where they were. */
main {
  max-width: 1240px;
  /* Tighter sides on a phone.
     1.5rem each side is 48px of a 390px screen -- over a tenth of the
     width spent on margin, which is why cover rows looked cramped while
     the page looked empty. 0.6rem keeps content off the bezel without
     charging for it. */
  /* 0.4rem, was 1.25rem (Luke, 28 Sep: the gap between the banner and the
     first thing on each page was "too far apart" -- 47px on Games, Play
     Next and Notes, 35px on Profile, measured). The banner's own bottom
     padding already separates it from the page; this was a second gap on
     top of it. The game page's backdrop and the docked nav both read this
     variable, so they move with it. */
  --main-pad-top: 0.4rem;
  padding: var(--main-pad-top) 0.6rem 1rem;
}
/* No longer hangs 80px past `main` each side. Not worth being the one
   view whose content starts somewhere different -- switching to it moved
   the table out from under the tabs, reading as the page shifting. Fits
   at 1240px; a ninth column should scroll the wrapper, not the page. */

/* The `.view-tabs { max-width: 1240px; width: 100% }` pin that sat here is
   gone. It kept the old full-width tab strip aligned with `main` while the
   Library widened; the nav is inside the banner now and sized by its shape.
   It had to go rather than being left harmless: sitting BELOW the shape
   rules, its `width: 100%` beat the sidebar's `width: 210px` at equal
   specificity -- the later-rule-wins trap this file keeps hitting. */

.game-link-row {
  display: flex;
  align-items: center;
  gap: 0.65rem;
  text-decoration: none;
  color: inherit;
  min-width: 0;
}

/* Deliberately small. A row is a line of text -- a thumbnail tall enough
   to see properly would double the row height and halve how much of the
   library fits on screen, which is the opposite of what a table is for. */
.row-cover-wrap {
  position: relative;
  width: 44px;
  height: 62px;
  flex-shrink: 0;
  border-radius: 4px;
  overflow: hidden;
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
}
.row-cover, .row-cover-placeholder {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.row-cover-placeholder {
  background: linear-gradient(150deg, rgba(var(--act-rgb), 0.10), rgba(var(--glow-warm-rgb), 0.08));
}

/* Platform pills wrap within the cell rather than stretching the column
   -- a game on three platforms otherwise forced every row taller. */
.platform-tag {
  display: flex;
  flex-wrap: wrap;
  gap: 0.15rem;
  max-width: 100%;
}
#library-table td { vertical-align: middle; }

/* Rows were 68px tall -- the status select and cover thumb each pushed
   them out. Tightening both keeps roughly twice as many games on screen,
   which is the point of a table view. */
#library-table td { padding-top: 0.35rem; padding-bottom: 0.35rem; }
#library-table select {
  padding: 0.25rem 0.4rem;
  font-size: 0.75rem;
}
.row-cover-wrap { width: 44px; height: 62px; }
.game-name-cell { line-height: 1.25; }

/* Library action buttons on one left-aligned row.
   Deliberately OUTSIDE .section-head: that container uses
   justify-content:space-between, which pushed these to the right edge
   regardless of any alignment set on them. */
/* THE LIBRARY'S ACTIONS: their own row, above the filters, drawn to stand
   out from them. Wraps on a narrow screen rather than squeezing labels. */
/* Phone-only pieces of the library markup, hidden at desktop width. The
   phone block below swaps them in: short button labels, the "Played on"
   line in place of the pills, and the short date. */
.lib-label-short,
.played-on,
.date-card { display: none; }

.library-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  align-items: center;
  margin: 0 0 1.1rem; /* top 0 (28 Sep): level with the shelf, 22px under the banner like every page */
}

/* Bigger than the filters and the toggles -- 2.4rem to their 2rem -- with
   an icon, so they read as the things you DO on this page. */
.library-actions .lib-action {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  height: 2.4rem;
  padding: 0 1.05rem;
  border-radius: 10px;
  font-size: 0.88rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.library-actions .lib-action:hover { transform: translateY(-1px); }
.library-actions .lib-action-icon {
  width: 16px;
  height: 16px;
  flex: 0 0 auto;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The two errands: the same white glass as a selected toggle, with a
   gold icon -- gold as a detail, not a fill, so + Add game stays the one
   gold button. */
.library-actions .lib-action-glass {
  color: var(--text);
  background: rgba(255, 255, 255, 0.07);
  border: 1px solid rgba(255, 255, 255, 0.12);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
/* The view's colour, not the site accent: with Library pink, gold icons
   were the last thing on the page still in another view's colour. */
.library-actions .lib-action-glass .lib-action-icon { stroke: var(--va, var(--accent)); }
.library-actions .lib-action-glass:hover {
  background: rgba(255, 255, 255, 0.11);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.14),
    0 6px 18px rgba(0, 0, 0, 0.3);
}
/* RETRO, FROM THE BANNER (experiment, 22 Sep). The site under the
   wordmark had drifted to a generic clean-dark look and stopped feeling
   like the same product as its own banner (Luke). These borrow the
   wordmark's two signatures -- Righteous, and the sunset gradient -- for
   the Library's actions and table headers.

   + Add game is an ARCADE BUTTON: a solid gold face, dark label, and a
   darker gold base under it that it presses down into. The press is the
   fun. (Its face was the sunset for one pass; see below.) */
/* LABELS IN INTER, NOT RIGHTEOUS. Righteous was tried here and at button
   size its letterforms were hard to read (Luke) -- it is drawn for the
   wordmark's size, not 15px. It stays for display type; the retro here
   comes from the sunset and the arcade press, not the face. */
.library-actions .lib-action {
  font-weight: 700;
  font-size: 0.9rem;
  letter-spacing: 0.01em;
}
/* ONE COLOUR, NOT THE GRADIENT. The sunset face was "a little loud"
   (Luke) -- the gradient is the wordmark's signature, and on a button it
   competed with it. Solid gold now, sitting on a base of darker gold
   rather than purple, so the whole button is one hue. The arcade press
   stays: that is the retro, and it costs nothing in cleanliness. */
.library-actions .lib-action-lead {
  color: var(--va-ink);
  background: var(--va);
  border: none;
  box-shadow:
    0 3px 0 var(--va-deep),
    inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
.library-actions .lib-action-lead .lib-action-icon { stroke: var(--va-ink); }
.library-actions .lib-action-lead:hover {
  transform: translateY(1px);
  background: var(--va-light);
  box-shadow:
    0 2px 0 var(--va-deep),
    inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
.library-actions .lib-action-lead:active {
  transform: translateY(3px);
  box-shadow: 0 0 0 var(--va-deep), inset 0 1px 0 rgba(255, 255, 255, 0.3);
}

/* The two errands: a dark face inside a thin sunset border -- the
   gradient as a frame rather than a fill, so + Add game stays the one
   that is lit. Two backgrounds clipped to different boxes is how a
   border gets a gradient: the dark one fills the padding box, the sunset
   shows only in the 1px transparent border around it. */
/* THE SUNSET BORDER ONLY ON HOVER (Luke). At rest they are plain panel
   buttons with a hairline edge -- two gradient frames always on was more
   colour than the row needed. Pointing at one lights its edge, which
   makes the gradient a response rather than a decoration. The hairline is
   drawn the same two-background way so nothing shifts when it swaps. */
.library-actions .lib-action-glass:not(.lib-action-lead) {
  border: 1px solid transparent;
  background:
    linear-gradient(var(--bg-panel), var(--bg-panel)) padding-box,
    linear-gradient(var(--hairline), var(--hairline)) border-box;
  box-shadow: none;
}
.library-actions .lib-action-glass:not(.lib-action-lead):hover {
  background:
    linear-gradient(var(--bg-panel-raised), var(--bg-panel-raised)) padding-box,
    var(--va-edge) border-box;
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.3);
}

/* ---------- The Library view, in the banner's language (experiment) ----

   Scoped to #view-library so nothing else in the app moves. The full
   sunset appears once -- the header rule -- and
   everywhere else its colours work alone: violet on hover, pink on focus
   and on the row you are pointing at, gold for game titles you can open.
   Righteous only where the type is big enough to read (the empty state). */

/* Filters and search: the same 10px corners as the action buttons above
   them, so the two rows read as one kit. */
#view-library .controls select,
#view-library .controls input[type="search"],
#view-library #search {
  height: 2.25rem;
  border-radius: 10px;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
#view-library .controls select:hover,
#view-library #search:hover { border-color: var(--va); }
#view-library .controls select:focus-visible,
#view-library #search:focus {
  outline: none;
  border-color: var(--va);
  box-shadow: 0 0 0 3px rgba(var(--va-rgb), 0.22);
}

/* The table: rounder, and lifted off the page a little. */
#view-library .table-wrap {
  border-radius: 14px;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.04),
    0 12px 32px rgba(0, 0, 0, 0.28);
}
/* Softer dividers: the grey hairline between every row was the loudest
   repeated line on the page, and competed with the sunset rule above. */
#view-library tbody tr { border-bottom-color: rgba(255, 255, 255, 0.05); }
/* The row you point at: a violet wash, and a pink bar down its left edge
   -- one colour each, not the gradient. */
#view-library tbody tr:hover { background: rgba(var(--va-rgb), 0.08); }
#view-library tbody tr:hover td:first-child {
  box-shadow: inset 3px 0 0 var(--va);
}
#view-library .game-link-row:hover .game-link,
#view-library .game-link:hover { color: var(--sun-gold); }

#view-library .status-select {
  border-radius: 8px;
  background: var(--bg-panel-raised);
  transition: border-color 0.15s ease;
}
#view-library .status-select:hover { border-color: var(--va); }
#view-library .status-select:focus-visible {
  outline: none;
  border-color: var(--va);
}

/* Covers: a touch rounder, and a faint ring instead of the hairline box,
   so the art sits in the row rather than in a frame. */
#view-library .row-cover-wrap {
  border-radius: 6px;
  border-color: transparent;
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 2px 6px rgba(0, 0, 0, 0.35);
}

/* ---------- Your Top Games, retro (experiment) ----------------------

   ONE COLOUR FOR EVERY RANK. Two passes coloured the ranks -- the sunset
   spread across the five, then arcade gold/silver/bronze -- and Luke's
   verdict was that the numbers do not need to shift at all. The retro is
   in the face and the size; the order says the rest. */

/* HIDDEN ON SCREEN (Luke: not needed). Position says the order, and the
   open entry's big number says which one you are on. Kept for screen
   readers, which cannot see position -- the same clip as .sr-only. */
#view-top100 .top5-tile-rank {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
}
/* The open cover's ring: gold, whichever rank it is. */
#view-top100 .top5-tile.is-selected .top5-tile-btn,
#view-top100 .top5-tile.is-selected .top5-tile-btn:hover {
  box-shadow:
    0 0 0 2px var(--bg),
    0 0 0 4px var(--va),
    0 10px 26px rgba(0, 0, 0, 0.5);
}

/* The open entry's big number: Righteous, in amber -- white here read as
   flat (Luke). One colour for every rank; this is the podium's only
   colour, beside the gold ring on the open cover. */
#view-top100 .top5-rank {
  font-family: var(--retro-face);
  font-weight: 400;
  font-size: 2.6rem;
  color: var(--va);
}
/* THE REVIEW AS A QUOTE CARD. It was a muted paragraph floating beside
   the cover -- your own words about your favourite game, set as the
   quietest text on the page (Luke: "the review text needs to look
   better"). Now a panel with a big amber opening quote mark in Righteous,
   full-strength text at reading size, and a measure that stays readable.

   On hover it takes the Library's "you are pointing at this" violet edge.
   (A small "Edit" tag shown on hover was tried and removed -- see below.) */
#view-top100 .top5-review {
  position: relative;
  max-width: 70ch;
  margin: 0;
  padding: 1.1rem 1.35rem 1.15rem 3.1rem;
  /* Muted, not full white: at --text the review glared, and read louder
     than the title above it (Luke). The quote mark and the card mark it
     as the important thing; the colour lets it be read, not shouted. */
  color: var(--text-muted);
  font-size: 1rem;
  line-height: 1.7;
  background: var(--bg-panel);
  border: 1px solid rgba(255, 255, 255, 0.06);
  border-radius: 14px;
}
#view-top100 .top5-review::before {
  content: "\201C";
  position: absolute;
  left: 0.85rem;
  top: 0.35rem;
  font-family: var(--retro-face);
  font-size: 2.8rem;
  line-height: 1;
  color: var(--va);
  pointer-events: none;
}
/* The card is not itself clickable any more (29 Sep): the pencil at its
   corner is the way in (.top5-review-wrap), so the violet hover edge that
   said "this card is a button" went with the clickable card. */
/* No review: not a card. An empty quote would be a frame around nothing. */
#view-top100 .top5-review-empty {
  background: none;
  border: none;
  padding: 0;
  color: var(--text-faint);
}
#view-top100 .top5-review-empty::before { content: none; }

/* The missing-rating/review button: the Library's action style, smaller,
   with an icon for which thing is missing. */
#view-top100 .top5-rate-btn {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  height: 2.2rem;
  padding: 0 0.95rem;
  font-weight: 600;
}
#view-top100 .top5-btn-icon {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: var(--va);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* THE LIBRARY'S OUTLINED BUTTONS, here too (Luke): the view options --
   Top 5 | Top 100, the tier groupings -- and "Edit your review". Separate
   rounded buttons with a hairline edge at rest. The
   segmented box around the options goes, because each is its own button
   now. (States, below: hairline at rest, violet on hover, sunset when
   selected.) Two backgrounds, one per box, draw the edge -- same as Library, so
   nothing shifts when the hairline swaps for the sunset. */
#view-top100 .view-toggle-sub {
  display: flex;
  gap: 0.4rem;
  border: none;
  border-radius: 0;
  overflow: visible;
}
#view-top100 .view-toggle-sub .view-toggle-btn,
#view-top100 .top5-actions .btn {
  border-radius: 10px;
  border: 1px solid transparent;
  background:
    linear-gradient(var(--bg-panel), var(--bg-panel)) padding-box,
    linear-gradient(var(--hairline), var(--hairline)) border-box;
  color: var(--text-muted);
  transition: color 0.15s ease;
}
/* HOVER: a violet edge and brighter text -- "you are pointing at this".
   Not the sunset: that is kept for what is SELECTED, so hovering the
   other option never looks like choosing it. */
#view-top100 .view-toggle-sub .view-toggle-btn:hover,
#view-top100 .top5-actions .btn:hover {
  color: var(--text);
  background:
    linear-gradient(var(--bg-panel), var(--bg-panel)) padding-box,
    linear-gradient(var(--va), var(--va)) border-box;
}
/* SELECTED: the sunset outline, held -- NO FILL (Luke: "don't love the
   filled in highlighting"). It was a white glass fill, and a first cut
   of it stacked translucent layers so the sunset bled through the whole
   face and it came out as a gradient pill. The outline alone marks it. */
#view-top100 .view-toggle-sub .view-toggle-btn.is-active,
#view-top100 .view-toggle-sub .view-toggle-btn.is-active:hover {
  color: var(--text);
  font-weight: 600;
  background:
    linear-gradient(var(--bg-panel), var(--bg-panel)) padding-box,
    var(--va-edge) border-box;
  box-shadow: none;
}
/* Full height again: the toggle's own 1px border is gone, so the
   "2rem less 2px" that made room for it would leave these short. */
#view-top100 .view-toggle-sub .view-toggle-btn { height: 2rem; }

/* THE LONG LIST'S GROUP HEADINGS -- "1-10", "11-20" -- in Righteous, one
   plain colour, matching the podium's one colour above it. */
#view-top100 #topx-list .tier-label {
  font-family: var(--retro-face);
  font-weight: 400;
  color: var(--text);
}

/* Nothing matched: big enough for Righteous to read, in solid pink. */
#view-library #library-empty {
  font-family: var(--retro-face);
  font-size: 1.35rem;
  letter-spacing: 0.02em;
  color: var(--va);
  width: max-content;
  margin: 0 auto;
}
/* The wrapper is only a positioning context for the popover -- letting it
   grow made it fill the row and force the button onto its own line. */
.library-actions .add-game-wrap {
  flex: 0 0 auto;
}


/* ---------- Game detail backdrop ---------- */

/* The cover art bled across the top of the page, heavily blurred and
   faded into the background. Gives each game its own colour without the
   art competing with the text sitting on it. */
.game-detail-section {
  position: relative;
}

.game-detail-backdrop {
  position: absolute;
  /* Exactly main's top padding, so the art starts flush under the banner.
     This was the literal 2.5rem that main's padding used to be. main later
     went to 1.25rem and this did not, leaving the band reaching 20px too
     high -- and because the banner is a static sibling while this is
     positioned, it PAINTS OVER the banner rather than tucking behind it, so
     the overshoot showed as blurred key art across the wordmark.
     Inherited from main, which is the only way the two cannot disagree:
     main's top padding differs again under 640px. */
  top: calc(var(--main-pad-top, 1.25rem) * -1);
  left: 50%;
  transform: translateX(-50%);
  width: 100vw;
  height: 300px;
  background-size: cover;
  background-position: center 30%;
  /* Heavy blur and low opacity: this is a wash of the game's colour, not
     a picture. At full strength the text on top becomes unreadable and
     every page looks different in a bad way. */
  filter: blur(46px) saturate(1.3);
  opacity: 0.3;
  pointer-events: none;
  z-index: 0;
  /* Faded out at the bottom so it dissolves into the page rather than
     ending on a hard line. */
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 0%, transparent 100%);
}

/* Everything else sits above the wash. */
/* Real key art, not a colour wash.
   The base rule blurs heavily and drops opacity because it's showing a
   cropped 2:3 COVER, which has no composition left once it's a 300px
   band. Landscape artwork is made to sit behind something, so it gets
   shown: much less blur, more opacity, and taller.
   Still not full strength -- a page header has to stay readable on top,
   and that's the constraint that killed the earlier attempt. */
.game-detail-backdrop.has-artwork {
  height: 380px;
  filter: blur(2px) saturate(1.05);
  opacity: 0.5;
  background-position: center 25%;
}

/* Bottom fade, so the art dissolves into the page instead of ending on a
   hard horizontal line 380px down. */
.game-detail-backdrop.has-artwork::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, rgba(10, 6, 15, 0) 30%, var(--bg) 100%);
}

.game-detail-section > *:not(.game-detail-backdrop) {
  position: relative;
  z-index: 1;
}

/* ---------- Game detail: display type and section icons ---------- */

/* The display face the page headings already use. It was only on the
   game title here, so every section below read as plain body text and
   the page felt like a form rather than a page about a game. */
.field-group-title,
#dlc-block .card-row-title,
#achievements-block .card-row-title,
#playthroughs-block .secondary-platform-title {
  font-family: var(--font-display);
  font-size: 1.05rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  display: flex;
  align-items: center;
  gap: 0.45rem;
}

/* COLOUR SPLIT OUT OF THE RULE ABOVE.
   It painted all four gold, which put ACHIEVEMENTS, DLC and PLAYTHROUGHS
   in gold while RATING AND REVIEW and ABOUT THIS GAME -- the same kind of
   heading, on the same page -- stayed in the text colour, because they
   are not in that selector list. Two treatments for one thing.
   The section ICONS keep the gold. A gold trophy beside a white heading
   is how "Rating and Review" already reads, and it makes the icon the
   accent rather than the whole line. */
.field-group-title {
  color: var(--gold);
}

.section-icon {
  display: inline-flex;
  align-items: center;
  color: var(--gold);
  opacity: 0.85;
}

/* Field labels in mono, small and quiet -- they name the value rather
   than competing with it. */
.field-group label {
  font-family: var(--font-mono);
  font-size: 0.64rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-faint);
}
/* Hints sit inside the label, so they inherited its uppercase mono and
   came out shouting a full sentence. Explicitly reset. */
.field-group .field-hint {
  font-family: var(--font-body);
  text-transform: none;
  letter-spacing: 0;
  font-size: 0.66rem;
  line-height: 1.35;
}
.field-readout {
  font-family: var(--font-body);
  font-size: 0.95rem;
  font-weight: 600;
  text-transform: none;
  letter-spacing: 0;
  color: var(--text);
}

/* Stat row labels match, so the two blocks read as one system. */
.game-detail-stat-label {
  font-family: var(--font-mono);
}
/* Body face rather than the condensed display one, and smaller. The
   display font is built for headings -- at this size, packed with
   numbers and units, it read as cramped. */
.game-detail-stat-value {
  font-family: var(--font-body);
  font-size: 0.92rem;
  font-weight: 600;
  letter-spacing: 0;
  color: var(--text);
}
/* Fills the width on a phone, rather than sizing to content.
   Flex-wrap left "Player Score" alone on its own line with half the
   screen empty beside it, while every other block on the page ran edge
   to edge -- so the one panel that did not looked broken rather than
   different.
   Two equal columns, and an odd last tile takes the full width so a row
   never ends halfway. */
/* TWO COLUMNS WAS A PHONE LAYOUT THAT NEVER GOT ITS MEDIA QUERY.
   The comment above describes "a two-column phone grid", and the rule was
   written for one -- but it sits at the top level, so it applied at every
   width. On a 1100px page that is four short figures in a 2x2 block with
   half the row empty, pushing everything below it down for nothing.
   auto-fit asks for as many 150px columns as fit: four across on desktop,
   two on a phone, without either needing to be spelled out. */
/* NO LONGER A GRID, AND NO LONGER TILES.
   Equal tracks are a dashboard behaviour: four bordered, tinted, centred
   cells in equal columns is a KPI widget row, and that is what the page
   had started to look like. Two stats split the full width and left a
   hole in the middle; four filled it and read as instrumentation.
   A content-sized flex row instead, with the figures carrying the
   emphasis and a hairline between them rather than a box around each. */
.game-detail-stat-row {
  display: grid;
  /* CAPPED AT 230px, NOT 1fr.
     With `1fr` as the maximum, auto-fit hands the leftover width to the
     tracks that exist -- so three stats stretched to a third of the row
     each and two took half, and a game with fewer figures got wider
     panels than a game with more. The tile grew to fill space rather
     than to fit "4.6h".
     A fixed maximum sizes the panel to its contents and leaves the
     spare width alone, so a row of two and a row of four use the same
     tile. */
  grid-template-columns: repeat(auto-fit, minmax(150px, 230px));
  justify-content: start;
  padding: 0.7rem 0.95rem;
  gap: 0.6rem;
}

/* Cells fill their track.
   Two things were shrinking them. `min-width` sized cells for the old
   flex layout, and -- the real one -- `.game-detail-stat:has(.stat-caption)
   { max-width: 8.5rem }` capped any cell containing a caption. That cap
   is correct on DESKTOP, where it stops "Overwhelmingly Positive" making
   one cell forty characters wide next to neighbours showing "51.5h". On
   a two-column phone grid there is nothing to be wide relative to, so it
   just leaves a gap.
   Hence the override rather than deleting it: the rule is right where it
   was written and wrong here. */
.game-detail-stat-row > .game-detail-stat,
.game-detail-stat-row > .game-detail-stat:has(.stat-caption) {
  min-width: 0;
  max-width: none;
  justify-self: stretch;
  width: 100%;
}
.game-detail-stat-label {
  font-size: 0.58rem;
}

/* A little larger than prod's 0.98rem, and no more. The tile sets the
   scale here: inside a bordered panel the figure only has to lead its
   own cell, not carry the row, which is what the unboxed version needed
   it to do. */
.game-detail-stat-row .game-detail-stat-value {
  font-size: 1.3rem;
  line-height: 1.15;
}

/* A gold hairline down the left of each group ties them together as a
   set without adding another border. */
.field-group {
  border-left: 2px solid var(--gold-soft);
}

/* ---------- Detail page: visual summary ---------- */

/* Replaces the labelled field list in view mode. The fields are still
   there behind Edit -- this is what you see when you're looking rather
   than changing, and a shape reads faster than a column of "Label:
   Value" pairs. */
.detail-visual-summary {
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
  /* The bottom margin was 0.4rem, which put "Released Jun 9, 2026" hard
     against the "Rating and Review" heading -- the date read as a
     caption belonging to the section below it rather than as the end of
     the summary above. A section break needs more room than a line
     break. */
  margin: 1rem 0 1.8rem;
}

/* Everything true about this game at a glance. */
.summary-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
}
.summary-chip {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  padding: 0.3rem 0.6rem;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--hairline);
  color: var(--text-muted);
}
.summary-chip.owned {
  background: rgba(var(--act-rgb), 0.10);
  border-color: rgba(var(--act-rgb), 0.3);
  color: var(--gold);
}
.summary-chip.subscription {
  border-style: dashed;
  border-color: rgba(var(--act-rgb), 0.35);
  color: var(--gold);
}
.summary-chip.status-completed {
  background: rgba(110, 200, 120, 0.12);
  border-color: rgba(110, 200, 120, 0.35);
  color: #96d8a0;
}
.summary-chip.status-currently_playing {
  background: rgba(102, 192, 244, 0.12);
  border-color: rgba(102, 192, 244, 0.35);
  color: #9ad4f5;
}
/* BETWEEN SESSIONS (#115): the sunset's violet (6 Oct, chosen on a
   prototype from four: dashed blue, faded blue, violet, neutral with a
   pause mark). A colour of its own rather than a dimmed Currently Playing,
   so the two never read as the same status at a glance -- and violet is
   not a verdict the way green (finished) or red (bounced) are. */
.summary-chip.status-between_sessions {
  background: rgba(var(--sun-violet-rgb), 0.12);
  border-color: rgba(var(--sun-violet-rgb), 0.4);
  color: #c3a6ea;
}
.summary-chip.status-bounced_off,
.summary-chip.status-no_thanks {
  background: rgba(230, 100, 100, 0.10);
  border-color: rgba(230, 100, 100, 0.3);
  color: #f09a9a;
}

.summary-metric {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  flex-wrap: wrap;
}
.summary-metric-label {
  font-family: var(--font-mono);
  font-size: 0.64rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-faint);
  min-width: 8rem;
}
.summary-metric-value {
  font-family: var(--font-display);
  font-size: 1.1rem;
  color: var(--gold);
}

/* Ten pips rather than a number -- a filled proportion is judged by eye
   in a way "8.5 / 10" isn't. */
.rating-pips {
  display: flex;
  gap: 3px;
}
/* Bars span the column width so they sit under the number rather than
   being narrower than it. */
.review-rating .rating-pips { gap: 2px; }
.review-rating .rating-pip { width: 13px; height: 22px; }
/* Ten bars on a red-to-green scale. The colour is set per pip in JS
   because it belongs to the POSITION, not the score -- the third bar is
   amber whether the rating is 3 or 10, so the ramp reads as a scale
   rather than as decoration that shifts with the value. */
.rating-pip {
  width: 12px;
  height: 20px;
  border-radius: 3px;
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--hairline);
}
.rating-pip.is-filled {
  border-color: transparent;
}

/* Released, started, finished on one line: a label over each date, a
   chevron between (Luke, 29 Sep -- chosen over a rail, a vertical list
   and cards, on the real page). No dots and no connecting rule. */
.summary-timeline {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  flex-wrap: wrap;
  padding-top: 0.2rem;
}
.timeline-point {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
}
.timeline-label {
  font-family: var(--font-mono);
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-faint);
}
.timeline-date {
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--text);
}
.timeline-arrow {
  color: var(--text-faint);
  font-size: 1.25rem;
  line-height: 1;
}

/* Display for this container is set further down: edit mode only. */

/* ---------- Store links ---------- */

/* ONE PER ROW.
   Seven chips wrapping in a narrow sidebar column made a ragged block
   three lines deep with a different shape on every game -- and the price
   on the Steam chip made that chip twice the width of its neighbours,
   which is exactly the kind of thing a wrap handles badly.
   A column also lets each row carry more than a word, which is what the
   price needs. */
.store-links {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  /* Separate surfaces again, so they need air between them. */
  gap: 0.35rem;
  margin-top: 0.6rem;
}
/* The label left, the price right, so the prices line up down the column
   instead of sitting wherever each name happens to end. */
.store-link {
  display: flex;
  align-items: baseline;
  /* flex-start, not space-between: the name and its chevron are one
     thing and sit together; the price pushes itself right. */
  justify-content: flex-start;
  gap: 0;
}
.store-link-price {
  color: var(--text);
  font-weight: 600;
}
/* ROWS, NOT BUTTONS.
   Each link was an outlined, filled, uppercase-mono box at full column
   width. Four of them stacked read as a settings panel -- a list of
   controls to operate rather than places to go. They are navigation, so
   they get the weight navigation gets: the store's own colour, a rule
   between them, and a chevron saying it leads somewhere.
   Sentence case and the body face, because "PLAYSTATION" in mono is a
   label and "PlayStation" is a name. */
/* THE PANEL IS BACK, ROUNDER.
   Stripping it to bare rows went too far the other way: with no fill and
   no edge the column read as empty, and the only thing holding a row
   together was a hairline under it. The fill is what makes a row look
   like somewhere to go.
   What does NOT come back is the uppercase mono and the hard 6px corner.
   The name is a name, so it stays sentence case in the body face, and
   the radius is 12px -- soft enough to read as a surface rather than a
   form control, which is what the old tight corner did. */
.store-link {
  font-family: var(--font-body);
  font-size: 0.82rem;
  letter-spacing: 0;
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--hairline);
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.04);
  text-decoration: none;
  color: var(--text-muted);
}
/* NO CHEVRON. It was there to say "this leads somewhere" once the panel
   had been stripped away; with the panel back the cell says it, and an
   arrow on every row was one more mark to read in a column of six.
   The price still holds the right edge so prices align down the column,
   which is what that alignment was for. */
.store-link-price { margin-left: auto; }
/* WHERE YOU HAVE IT, on its pill (renderStoreLinks): a check for owned, a
   heart for wishlisted, and the owned pill's edge in its own store colour
   so it reads as yours at a glance. */
.store-link-mark { margin-right: 0.35rem; font-size: 0.85em; }
/* (The owned pill's fill and edge are set with the tinted rows below.) */
/* SMALLER AND CLEANER (Luke, 23 Sep). At 0.5rem x 0.75rem padding, a
   0.82rem label and 12px corners, seven stacked store rows were a column
   of big buttons as tall as the cover above them. A compact row -- a
   notch smaller type, tighter padding, 8px corners, a fainter edge --
   keeps the column (prices still line up on the right) at roughly two
   thirds of the height. The phone keeps its pill row (below). */
/* TINTED, SMALLER, NARROWER (Luke, 29 Sep, picked on the real page from
   rows / compact / soft / solid / tinted / bar). The rows had become so
   faint -- a 2.5% fill and a 7% edge -- that they did not read as
   something to click. Each one is now tinted in its own store's colour:
   an 8% fill and a 35% edge of currentColor, which the store classes
   below set. The owned one is filled deeper with a full-colour edge.
   And the column is capped at 12.5rem rather than the cover's width,
   "a little less wide"; prices still line up on its right edge. */
.store-links { gap: 0.2rem; max-width: 12.5rem; }
.store-link {
  padding: 0.24rem 0.5rem;
  font-size: 0.74rem;
  border-radius: 6px;
  background: color-mix(in srgb, currentColor 8%, transparent);
  border: 1px solid color-mix(in srgb, currentColor 35%, transparent);
}
.store-link.is-owned {
  background: color-mix(in srgb, currentColor 16%, transparent);
  border-color: currentColor;
}
.store-link-price { font-size: 0.72rem; }
/* THE WHOLE CELL LIGHTS UP, not just its edge. With no chevron the row
   needs to answer "is this a thing I can press" on hover by itself, and
   a border that changes colour is easy to miss on a 1px line. Fill and
   edge together read as the row coming forward.
   The store's own colour stays on the name -- the hover is the app's
   gold, so six rows highlight the same way rather than six ways. */
.store-link:hover {
  color: var(--text);
  border-color: var(--gold-soft);
  background: rgba(var(--act-rgb), 0.1);
}
/* The store's colour on the NAME only. It used to tint the border too,
   which at six storefronts stacked made the column read as six different
   warnings. One neutral edge, six coloured names. */
.store-link.steam       { color: #9ad4f5; }
.store-link.playstation { color: #a8b4ff; }
.store-link.xbox        { color: #96d8a0; }
/* Stores nothing syncs from, so they had no colour until this row started
   offering every storefront rather than only the one a game came from. */
.store-link.nintendo    { color: #ff8a90; }
.store-link.epic        { color: #cfcfcf; }
.store-link.gog         { color: #c9a0ff; }

/* The cover-art backdrop picks up the game's own colour, sampled from
   its art. Falls back to gold when the CDN blocks the canvas read. */
.game-detail-backdrop {
  background-color: var(--game-accent, transparent);
}

/* Secondary detail under a stat's headline number. Keeps "Main 16.9h"
   readable at a glance while "100% 25h" and review counts stay available
   without crowding the same line. */
.stat-sub {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.6rem;
  font-weight: 400;
  color: var(--text-faint);
  letter-spacing: 0.03em;
  margin-top: 0.15rem;
}
.game-detail-stat-value {
  line-height: 1.25;
}
/* The old inline count is superseded by .stat-sub. */
.critic-count { display: none; }

/* ---------- Tiled view ---------- */

.tiles-toolbar {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 1.1rem;
  flex-wrap: wrap;
}
.tiles-share-state {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  color: var(--gold);
}

.tiles-title {
  font-family: var(--font-display);
  font-size: 1.35rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--text);
  margin: 0 0 0.1rem;
}

.custom-tile-set {
  margin-top: 1.8rem;
}
.tiles-set-head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
}
.tiles-title-input {
  font-family: var(--font-display);
  font-size: 1.3rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--text);
  background: transparent;
  border: none;
  border-bottom: 1px solid var(--hairline);
  padding: 0.1rem 0;
  min-width: 240px;
}
.tiles-title-input:focus { outline: none; border-bottom-color: var(--gold); }

/* Category label becomes editable in place rather than through a
   separate form -- the tile IS the thing being edited. */
.award-label-input {
  font-family: inherit;
  font-size: 0.7rem;
  font-weight: 600;
  text-align: center;
  color: var(--text);
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 4px;
  margin: 0.35rem 0.35rem 0.4rem;
  padding: 0.2rem 0.3rem;
  width: calc(100% - 0.7rem);
}
.award-label-input:focus { outline: none; border-color: var(--gold); }

/* ---------- Top 100 controls ---------- */

/* ONE TOOLBAR: the view's options, then the search, on one line.
   This was `display: block`, stacking them, because a space-between group
   on the right slid sideways whenever the filter buttons hid. Pinning each
   half to its own edge solves that too -- hiding the options cannot move a
   search that is anchored to the right -- without spending a row on it:
   stacked, the search sat alone on a line above the controls it belongs
   with. */
.top100-section .section-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  /* flex-start, said explicitly. The base .section-head is space-between,
     and inheriting it pushed the search to the far right edge on every
     rankings view -- the thing Luke asked twice not to have. */
  justify-content: flex-start;
  /* 0.5rem across, the gap Library's toolbar uses between its controls, so
     every search sits the same distance from what it follows. */
  gap: 0.75rem 0.5rem;
  margin-bottom: 0.7rem;
}
/* Straight after the options, not pinned to the far edge: the options
   and the search are one group of controls, and a search stranded at the
   right edge of a wide pane reads as belonging to something else. */
.top100-section .section-head .controls {
  justify-content: flex-start;
}

/* ONE GAP UNDER THE BANNER, on every pane with a toolbar. The first
   section-head carried .section-head's 1.75rem top margin on top of
   main's 1.25rem padding -- 48px on Library, 56 on Tiers, where the
   calendar and Play Next's list started nearer. 0.75rem here makes the
   first toolbar 32px under the banner on Library and the rankings alike. */
.gamelib-pane > .section-head:first-child { margin-top: 0; }

/* ---------- Detail page: tightened ---------- */

/* Edit mode only. View mode shows the chips, the time-played pill and the
   released/started/finished timeline -- and nothing else. */
.game-detail-fields { display: none; }

/* FLOATS rather than expanding the page.
   Inline, this pushed Rating, Achievements and Playthroughs a screenful
   down the moment you opened it, so editing one field meant losing your
   place in everything else. Fixed positioning leaves the page exactly
   where it was and returns it untouched.
   Sized against the viewport rather than the content: the form is long,
   and a panel taller than the screen cannot be dismissed. */
.game-detail-fields.is-editing {
  display: flex;
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: min(880px, calc(100vw - 2rem));
  max-height: min(85vh, 920px);
  overflow-y: auto;
  z-index: 120;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 1.1rem 1.2rem 1.3rem;
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
  margin-bottom: 0;
}

/* THE PANEL FITS WITHOUT SCROLLING (Luke, 28 Sep: "it should all fit"),
   chosen from a prototype of two layouts. Your record beside Dates, then
   the playthroughs full width. What made it scroll was the hint line under
   every field and a Game Type group of seven selects; the hints now show
   only for the field being edited, and Game Type shrank to the two guesses
   a person corrects, which moved into Your record.

   The max-height and overflow above stay as the safety net: a game with
   many playthroughs on a short screen still scrolls rather than running
   off the bottom. */
.game-detail-fields.is-editing {
  display: grid;
  width: min(1180px, calc(100vw - 2rem));
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  grid-template-areas: "head head" "record dates" "runs runs";
  column-gap: 0.8rem;
  row-gap: 0.7rem;
  align-items: start;
}
.game-detail-fields.is-editing .detail-edit-head { grid-area: head; }
.game-detail-fields.is-editing #detail-record-group { grid-area: record; }
.game-detail-fields.is-editing #detail-dates-group { grid-area: dates; }
.game-detail-fields.is-editing #detail-runs-group { grid-area: runs; }
.game-detail-fields.is-editing .field-group { padding: 0.75rem 0.9rem; }
.game-detail-fields.is-editing .field-group-title { margin-bottom: 0.5rem; }
.game-detail-fields.is-editing .field-group-body {
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: 0.55rem 0.9rem;
}
/* Controls shrink to their column instead of pushing it wider: a select
   sizes itself to its longest option. */
.game-detail-fields.is-editing .field-group :is(select, input:not([type="hidden"])) {
  min-width: 0;
  width: 100%;
}
.game-detail-fields.is-editing .field-group-body > * { min-width: 0; }
.game-detail-fields.is-editing .field-group .field-hint { display: none; }
.game-detail-fields.is-editing .field-group label:focus-within .field-hint { display: block; }
.game-detail-fields.is-editing .owned-per-platform { grid-column: 1 / -1; }
/* Played on sits under Owned on and reads like it: the same label, the
   site's platform pills, nothing to click (6 Oct). */
.played-on-readout .played-on-pills { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.3rem; }
.played-on-readout .played-on-none { font-size: 0.85rem; color: var(--text-faint); }
.game-detail-fields.is-editing .owned-per-platform .field-label {
  font-size: 0.72rem;
  font-weight: 400;
  color: var(--text-faint);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
#detail-first-played:disabled { opacity: 0.6; }
/* Read-outs on a game with playthroughs (#162): set by the run marked
   Finished, not typed here. */
#detail-completed-at:disabled, #detail-year-completed:disabled { opacity: 0.6; }


/* PLAYTHROUGHS, ONE LINE EACH: platform, started, ended, hours. (The
   label box went with #162: a run is named by its position.) */
.game-detail-fields:not(.is-editing) #detail-runs-group { display: none; }
#detail-runs-list { display: grid; gap: 0.35rem; margin-bottom: 0.5rem; }
#detail-runs-list .playthrough-row {
  display: grid;
  /* The last column fits the remove button or a synced run's platform
     tag, whichever the row has -- a fixed 2rem let "PlayStation" spill. */
  grid-template-columns: 8.5rem minmax(0, 1fr) minmax(0, 1fr) 4.5rem minmax(2rem, auto);
  gap: 0.5rem;
  align-items: end;
  padding: 0;
  border: 0;
  background: none;
}
#detail-runs-list .pt-cell { font-size: 0.66rem; }
/* FINISHED, a second line under each run (#162): the tick, then its day
   and year. Under the run rather than a seventh column -- six already
   fill the panel, and this is a different kind of fact from the ones
   above it: those say when the run was played, this says what it was. */
#detail-runs-list .pt-finish {
  grid-column: 1 / -1;
  /* The tick, then the day and the year in the panel's own form: a small
     label over each box. A grid, so the three sit on one line at a
     phone's width too -- as flex items the panel's column-direction
     labels stacked them down four lines. */
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) 5.5rem;
  align-items: end;
  gap: 0.5rem;
  padding-bottom: 0.55rem;
  border-bottom: 1px solid var(--hairline);
}
#detail-runs-list .playthrough-row:last-child .pt-finish { border-bottom: 0; }
/* The tick and its word side by side, at the height of the boxes beside
   them. By id and class: the panel's `label` rule (a column, uppercase)
   is what every other field here wants. */
#detail-runs-list .pt-finish .pt-done-label {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 0.45rem;
  height: 2.1rem;
  margin: 0;
  font-size: 0.8rem;
  color: var(--text);
  text-transform: none;
  letter-spacing: 0;
  cursor: pointer;
}
#detail-runs-list .pt-done { width: 1rem; height: 1rem; margin: 0; accent-color: var(--gold); }
#detail-runs-list .pt-finish .pt-year { width: 100%; }
#detail-runs-list .pt-finish :is(input[type="date"], input[type="number"]):disabled { opacity: 0.4; }
/* Not dimmed with the rest of a synced row: Finished is the one thing on
   it that is yours to change. */
#detail-runs-list .is-locked .pt-finish :is(input):not(:disabled) { opacity: 1; }
#detail-runs-list .pt-remove {
  background: none;
  border: 1px solid var(--hairline);
  border-radius: 6px;
  color: var(--text-faint);
  height: 2.1rem;
  cursor: pointer;
}
#detail-runs-list .pt-remove:hover { color: var(--text); border-color: var(--text-faint); }
#detail-runs-list .pt-locked {
  font-size: 0.62rem;
  color: var(--text-faint);
  align-self: center;
  text-align: center;
  line-height: 1.1;
}
#detail-runs-list .is-locked :is(input, select):disabled { opacity: 0.6; }
/* A BUILT PLAYTHROUGH'S PLATFORM AND HOURS LOOK LOCKED (#177, 5 Oct). They
   come from the play records and are disabled in the markup (factRowHtml),
   but the row is not .is-locked, so the rule above never reached them: the
   select kept its arrow and both read at full strength, as if they could be
   changed. Same 0.6 as a synced run's, and no arrow on a select that
   cannot open. */
#detail-runs-list .playthrough-row[data-kind="built"] :is(.pt-platform, .pt-hours):disabled {
  opacity: 0.6;
  cursor: default;
  appearance: none;
  -webkit-appearance: none;
  background-image: none;
}

/* OWNED ON, AS CHIPS: ticked and gold-edged when owned, struck through when
   not, dashed when nobody knows. */
.owned-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.3rem; }
.owned-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.35rem 0.75rem 0.35rem 0.55rem;
  border-radius: 999px;
  font: inherit;
  font-size: 0.82rem;
  cursor: pointer;
  border: 1px solid var(--hairline);
  background: transparent;
  color: var(--text-muted);
}
.owned-chip-mark {
  display: inline-grid;
  place-items: center;
  width: 1.1rem;
  height: 1.1rem;
  border-radius: 50%;
  font-size: 0.7rem;
  background: rgba(255, 255, 255, 0.06);
}
.owned-chip.is-yes { border-color: var(--gold); color: var(--text); background: rgba(255, 255, 255, 0.04); }
.owned-chip.is-yes .owned-chip-mark { background: var(--gold); color: var(--bg-panel); }
.owned-chip.is-no { color: var(--text-faint); text-decoration: line-through; text-decoration-color: rgba(255, 255, 255, 0.25); }
.owned-chip.is-unknown { border-style: dashed; }
/* "+ Nintendo": a platform you could add, not one you have. Faint and
   dashed so a row of them reads as options, never as ownership. */
.owned-chip.is-add {
  border-style: dashed;
  color: var(--text-faint);
  font-size: 0.76rem;
  padding: 0.25rem 0.65rem 0.25rem 0.45rem;
}
.owned-chip.is-add:hover { color: var(--text-muted); border-color: rgba(255, 255, 255, 0.3); }
/* PS Plus: playable, not kept. Dashed gold rather than solid, and the
   subscription named after the platform. */
.owned-chip.is-subscription.is-yes { border-style: dashed; }
.owned-chip.is-subscription.is-yes .owned-chip-mark { background: transparent; color: var(--gold); box-shadow: inset 0 0 0 1px var(--gold); }
.owned-chip-via {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  color: var(--gold);
  text-decoration: none;
}
.owned-chip.is-no .owned-chip-via { color: var(--text-faint); }

/* PLAYED ON, AS OWNED-ON CHIPS THAT CANNOT BE TAPPED (6 Oct). The ones
   you played ticked, the rest faint and dashed; the answer is the
   playthroughs', so none of them is a control. See renderPlayedOn. */
.owned-chip.is-static { cursor: default; }
.owned-chip.is-off {
  border-style: dashed;
  color: var(--text-faint);
  opacity: 0.55;
  padding-left: 0.75rem;
}

/* A BUILT PLAYTHROUGH AS A CARD (6 Oct, chosen on the real page; see
   factRowHtml). Its name is the title, edited in place; one line of
   fields under it, each with its own caption directly above, so no box is
   read against a header rows away. `.pt-card` beats the older grid rule
   for .playthrough-row (the stored-run rows keep that layout). */
#detail-runs-list .playthrough-row.pt-card {
  display: block;
  padding: 0.55rem 0.75rem 0.65rem;
  margin-bottom: 0.15rem;
  border: 1px solid var(--hairline);
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.02);
}
#detail-runs-list .playthrough-row.pt-card:last-child { margin-bottom: 0; }
.pt-card .pt-title { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.45rem; }
/* The name looks like the list's own run name (mono, caps, the accent)
   until you point at it; blank, its placeholder IS that name. */
.pt-card .pt-label {
  flex: 0 1 16rem;
  min-width: 0;
  height: 1.6rem;
  padding: 0 0.3rem;
  margin: 0 0 0 -0.3rem;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 4px;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--accent);
}
.pt-card .pt-label::placeholder { color: var(--accent); opacity: 0.85; }
.pt-card .pt-label:hover { border-bottom-color: var(--hairline); }
.pt-card .pt-label:focus { border-color: var(--hairline); background: rgba(255, 255, 255, 0.04); outline: none; }
/* "n/24" only while you are naming it. */
.pt-card .pt-count { font-size: 0.65rem; color: var(--text-faint); font-variant-numeric: tabular-nums; visibility: hidden; flex: 1; }
.pt-card .pt-title:focus-within .pt-count { visibility: visible; }
#detail-runs-list .pt-card .pt-remove { width: 1.6rem; height: 1.6rem; padding: 0; font-size: 0.85rem; }
#detail-runs-list .pt-card .pt-locked { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.05em; }

.pt-card .pt-grid {
  display: grid;
  /* Platform wide enough for "PlayStation + Steam". */
  grid-template-columns: 10.5rem 9.2rem auto 9.2rem 5rem auto 4.6rem;
  column-gap: 0.6rem;
  align-items: end;
  /* Packed to the left. Left to stretch, the grid handed the card's spare
     width to its two auto columns -- the arrow and Finished -- and Year
     ended up stranded at the far edge. */
  justify-content: start;
}
.pt-cell2 { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
.pt-caption {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.56rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  line-height: 1;
}
/* The field row's boxes only: the name above them has its own size. */
.pt-card .pt-grid :is(input, select):not([type="checkbox"]) {
  height: 2rem;
  width: 100%;
  min-width: 0;
  box-sizing: border-box;
  margin: 0;
  padding: 0 0.5rem;
  font-size: 0.82rem;
}
/* Clear, beside a date's caption: quiet, and only while the cell holds a
   date box -- a "+ Set date" has nothing to clear. */
.pt-date-clear {
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  text-transform: none;
  letter-spacing: 0;
  font-size: 0.62rem;
  color: var(--text-faint);
  cursor: pointer;
}
.pt-date-clear:hover { color: var(--accent); text-decoration: underline; }
.pt-cell2:not(:has(input[type="date"])) .pt-date-clear { display: none; }
.pt-card .pt-arrow { height: 2rem; display: grid; place-items: center; color: var(--text-faint); }
/* A built run's platform and hours: text, in a box the size of the
   select beside it, so the columns line up across typed and built runs. */
.pt-card .pt-fixed {
  display: flex;
  align-items: center;
  height: 2rem;
  box-sizing: border-box;
  padding: 0 0.6rem;
  border: 1px solid var(--hairline);
  border-radius: 6px;
  background: rgba(255, 255, 255, 0.02);
  font-size: 0.82rem;
  color: var(--text-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.pt-card .pt-suffix { position: relative; display: block; }
.pt-card .pt-suffix .pt-hours { padding-right: 1.4rem; }
.pt-card .pt-suffix i {
  position: absolute; right: 0.55rem; top: 50%; transform: translateY(-50%);
  font-style: normal; font-size: 0.78rem; color: var(--text-faint); pointer-events: none;
}
.pt-card .pt-findrow { display: flex; align-items: center; gap: 0.5rem; height: 2rem; }
.pt-card .pt-findrow .pt-done { width: 1rem; height: 1rem; margin: 0; accent-color: var(--accent); flex: none; }

/* "+ SET DATE" (#178): an empty date is a button until pressed, so a
   stray tap has no date box to commit today into. The accent, like a
   link; disabled (a synced run, or Finished unticked) it is a dash. */
.pt-setdate {
  height: 2rem;
  padding: 0 0.25rem;
  font: inherit;
  font-size: 0.8rem;
  text-align: left;
  white-space: nowrap;
  color: var(--accent);
  background: none;
  border: 0;
  cursor: pointer;
}
.pt-setdate:hover:not(:disabled) { text-decoration: underline; }
.pt-setdate:disabled { color: var(--text-faint); cursor: default; padding-left: 0.6rem; }



.detail-edit-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(10, 6, 15, 0.8);
  z-index: 110;
}
.detail-edit-backdrop[hidden] { display: none; }

/* Sticks while the form scrolls, so the way out is always on screen --
   the reason this panel needs its own close button at all is that
   "Edit game details" is behind the backdrop. */
.detail-edit-head {
  position: sticky;
  top: -1.1rem;
  margin: -1.1rem -1.2rem 0.2rem;
  padding: 0.9rem 1.2rem 0.7rem;
  background: var(--bg-panel);
  border-bottom: 1px solid var(--hairline);
  display: flex;
  align-items: center;
  justify-content: space-between;
  z-index: 1;
}
.detail-edit-title {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--gold);
  margin: 0;
}

/* Rating beside the review: the score as a number, bars underneath.
   These are the two things on the page that are yours rather than the
   sync's, so they belong together. */
.review-block {
  display: flex;
  gap: 1.2rem;
  /* stretch, so the rating column has the review's full height to centre
     itself within. */
  align-items: stretch;
  margin-top: 1.4rem;
}
.review-block .game-detail-review-label { flex: 1; }

/* Number stacked ON TOP of the scale rather than beside it -- the bars
   are the scale the number sits on, so reading down from one to the
   other makes the relationship obvious. */
/* Fixed width and a minimum height, so the block occupies the same
   space whatever the review says. The number was already a fixed size --
   what changed was the context: against three lines of review it read as
   large, against twenty it read as small. Giving the column its own
   footprint removes the comparison. */
/* Vertically centred in the review's height rather than pinned to the
   top. Top-aligning left a growing void underneath as the review got
   longer -- the rating looked stranded rather than paired with it. */
.review-rating {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  flex: 0 0 200px;
  width: 200px;
  /* Capped so a very long review doesn't strand the rating halfway down
     the page -- past this it stays put near the top of the block. */
  max-height: 340px;
}

/* NOTHING TO RESERVE THE SPACE FOR.
   `flex: 0 0 200px` above is a HEIGHT, because this sits in a column --
   it gives the big number a stable footprint so the block does not
   resize as the score changes. On an unrated game there is no number,
   and that 200px became a column of blank with "Not rated" floating in
   the middle of it, which is what it looked like: a rendering fault
   rather than an empty state.
   The padding goes with it. It offsets the caption from the number
   above, and there is no number above. */
/* Width too, not just height. The 200px is there to give the big score
   a stable footprint; with no score the column was still 200px of
   nothing, which is what held "+ Add rating" and "+ Add review" a third
   of the page apart and made the pair read as two unrelated controls.
   Collapsed, they sit side by side as the peer invitations they are. */
.review-rating.is-empty {
  flex: 0 0 auto;
  width: auto;
  min-height: 0;
}
.review-rating-number {
  font-family: var(--font-display);
  /* Big Shoulders is a CONDENSED face, so it grows tall faster than it
     grows wide -- which is why this can take a size that would overflow
     the column in a normal typeface. Scaling vertically on top of that
     stretches it further without widening it at all. */
  /* Was 8.5rem with scaleY(1.4) -- about 8.6rem of rendered height, which
     stood taller than the whole review paragraph beside it. The number is
     the headline, not the content, and at that size it read as a
     scoreboard bolted onto the page.
     Now about 4.9rem rendered, which sits INSIDE the height of a typical
     review rather than exceeding it. Still by far the largest thing in
     the block, so it keeps the emphasis without setting the height of
     the row. */
  font-size: 5.4rem;
  font-weight: 700;
  line-height: 0.72;
  /* Vertical stretch rather than a bigger point size: a larger size
     widens the number too, and "9.5" is two glyphs plus a stop -- it
     runs out of column width long before it runs out of height. */
  transform: scaleY(1.25);
  transform-origin: bottom center;
  margin-bottom: 0.5rem;
}

/* Playthroughs as one line each in view mode. */
.playthrough-line {
  display: flex;
  align-items: baseline;
  gap: 0.7rem;
  padding: 0.4rem 0;
  border-bottom: 1px solid var(--hairline);
}
.playthrough-line:last-child { border-bottom: none; }
/* "finished Apr 4, 2020" at the end of a run's line (#162). Gold and bold
   on the ORIGINAL completion only -- the earliest finished run, which is
   what the game's own date says. A completed replay reads in the line's
   own quiet text. Chosen on the real page from four options. */
.playthrough-finished.is-original { color: var(--gold); font-weight: 600; }
.playthrough-label {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--gold);
  min-width: 9rem;
}
.playthrough-meta {
  font-size: 0.82rem;
  color: var(--text-muted);
}

/* The add-a-playthrough button only makes sense while editing. */
#playthroughs-block .section-head button { display: none; }
.game-detail-section:has(.game-detail-fields.is-editing) #playthroughs-block .section-head button {
  display: inline-flex;
}

/* Currently Playing and Highest Rated cards, sized closer to the
   favourites grid above them.
   The cover wrap has its own hard-coded 150px width, so shrinking only
   the card left the art wider than its container -- which cropped every
   cover and pushed the rows out of alignment. Both have to move, and the
   cover keeps a 2:3 portrait ratio rather than a fixed height. */
.card-row .activity-card,
#top-rated-year-row .activity-card,
#upnext-unreleased-row .activity-card {
  width: 104px;
  flex: 0 0 104px;
}
.card-row .activity-card-cover-wrap,
#top-rated-year-row .activity-card-cover-wrap,
#upnext-unreleased-row .activity-card-cover-wrap {
  width: 104px;
  height: auto;
  aspect-ratio: 2 / 3;
}
.activity-card-name {
  font-size: 0.72rem;
  line-height: 1.25;
}
.activity-card-stat,
.activity-card-stat-secondary {
  font-size: 0.62rem;
}

/* The edit control sits with the IGDB match row at the top of the page,
   where the other page-level actions are, rather than buried between the
   stats and the review. */
/* Sits directly beside "Match to IGDB" rather than being pushed to the
   right edge. They're both actions on this game, so a gap between them
   made them look unrelated. */
.igdb-match-row #detail-edit-toggle {
  margin-left: 0.2rem;
}
.igdb-match-row {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex-wrap: wrap;
}

/* A read-only review reads as text, not an input waiting to be filled.

   AS THE TOP 5'S QUOTE CARD (Luke, 23 Sep: "format the review area of
   game detail to look similar to the top 5 review area"). Same panel,
   corner, measure, muted reading colour and amber opening quote -- one
   look for your own words wherever they appear. The values mirror
   #view-top100 .top5-review; change them together.

   No "Review" heading over the card while reading -- over a quote card
   under "Rating and Review" it said it three times. (It hid via the
   label's font-size when the card WAS the label's textarea; now the
   label steps aside altogether, below.) */
/* THE READ VIEW IS ITS OWN ELEMENT NOW (29 Sep), not the textarea made
   read-only: a textarea can only show plain text, and reviews now carry a
   little formatting (reviewText.js). #detail-review-read shows the review
   formatted; the textarea is only on screen while writing. The quote mark
   hangs off the card itself -- the reason it sat on the label (a textarea
   takes no ::before) went with the textarea. */
.review-read {
  position: relative;
  display: block;
  width: 100%;
  max-width: 70ch;
  padding: 1.1rem 1.35rem 1.15rem 3.1rem;
  font-size: 1rem;
  line-height: 1.7;
  color: var(--text-muted);
  background: var(--bg-panel);
  border: 1px solid rgba(255, 255, 255, 0.06);
  border-radius: 14px;
}
.review-read[hidden] { display: none; }
/* The pencil, top right of the card (review-edit-btn). On desktop it shows
   when the card is hovered or the pencil has keyboard focus; a phone has
   no hover, so there it always shows (phone block). */
.review-read-wrap { position: relative; max-width: 70ch; }
.review-edit-btn {
  position: absolute;
  top: 0.55rem;
  right: 0.55rem;
  display: grid;
  place-items: center;
  width: 1.9rem;
  height: 1.9rem;
  padding: 0;
  border-radius: 8px;
  border: 1px solid var(--hairline);
  background: var(--bg-panel-raised, var(--bg-panel));
  color: var(--text-muted);
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.review-edit-btn[hidden] { display: none; }
.review-read-wrap:hover .review-edit-btn,
.top5-review-wrap:hover .review-edit-btn,
.review-edit-btn:focus-visible { opacity: 1; }
/* The Top 5's review card carries the same pencil (renderTop5). */
.top5-review-wrap { position: relative; max-width: 70ch; }
.review-edit-btn:hover { color: var(--text); border-color: var(--gold-soft); }
.review-read::before {
  content: "\201C";
  position: absolute;
  left: 0.85rem;
  top: 0.35rem;
  font-family: var(--retro-face);
  font-size: 2.8rem;
  line-height: 1;
  color: var(--act);
  pointer-events: none;
}
/* While reading, the label and its textarea step aside for the card. A
   class rather than `hidden`: the label's `hidden` already means "no
   review yet", set elsewhere, and the two must not overwrite each other. */
.game-detail-review-label.is-reading { display: none; }

/* A LONG REVIEW IS CUT, AND OPENS IN A SHEET (Luke, 29 Sep, picked from
   now / clamp / scroll / sheet on the real page: "without taking up the
   whole phone"). About twelve lines show -- eight at first, "a little
   vertically bigger" once seen -- the last fading out, and "Read the full
   review" opens the whole of it over the page. Short reviews show whole,
   with no fade and no button -- is-cut is set only when the text runs
   past the cut. */
.review-read { max-height: calc(1.6em * 12 + 2.25rem); overflow: hidden; }
.review-read.is-cut {
  -webkit-mask-image: linear-gradient(180deg, #000 75%, transparent);
  mask-image: linear-gradient(180deg, #000 75%, transparent);
}
/* UNDER THE CARD (Luke, 29 Sep, "overlay" from now / overlay / row /
   links, on the real page). "Read the full review" sits ON the card's
   faded foot, as a pill; the share switch is centred below the card.
   A review short enough not to be cut has no button, and the switch
   simply sits under the card. */
.review-actions {
  display: flex;
  flex-direction: column;
  align-items: center;
  max-width: 70ch;
}
.review-more-btn {
  position: relative;
  z-index: 1;
  margin: -3.1rem auto 0;
  background: var(--bg-panel-raised, var(--bg-panel));
  border-radius: 999px;
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
}
.review-more-btn[hidden] { display: none; }
/* The share switch is inside .review-footer-row since #153; on a desktop
   that row is not a box, so this spacing is still the switch's. */
.review-actions .review-more-btn:not([hidden]) + .review-footer-row .review-share-toggle { margin-top: 1.75rem; }
.review-footer-row { display: contents; }
/* Phone-only pieces of the game page (#153); see the phone block. */
.review-edit-text { display: none; }

/* THE PAGE HOLDS STILL behind the full-review sheet (Luke, 29 Sep) and the
   game's edit panel. `page-locked-everywhere` is set by applyPageLock
   (app.js) while either is open, keyed on the sheet being visible -- it
   was `html.sheet-open`, set by the review sheet alone. A wheel stops
   here; a finger on iOS does not, so the phone block fixes the body
   instead (`html.page-locked`, #132). */
html.page-locked-everywhere,
html.page-locked-everywhere body { overflow: hidden; }

/* Formatting inside a review -- the read card, the sheet, and community
   reviews share it. Quiet: a review is someone's words, and the marks are
   there to give it shape, not to make it look like a document. */
.review-formatted > :first-child { margin-top: 0; }
.review-formatted > :last-child { margin-bottom: 0; }
.review-formatted p { margin: 0 0 0.8em; }
.review-formatted h4 {
  margin: 1.1em 0 0.35em;
  font-family: "Big Shoulders Display", sans-serif;
  font-size: 1.05em;
  font-weight: 700;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--text);
}
.review-formatted strong { color: var(--text); font-weight: 700; }
.review-formatted ul { margin: 0 0 0.8em; padding-left: 1.2em; }
.review-formatted li { margin: 0.15em 0; }
.review-formatted blockquote {
  margin: 0 0 0.8em;
  padding: 0.1em 0 0.1em 0.8em;
  border-left: 2px solid rgba(var(--act-rgb), 0.5);
  font-style: italic;
}

/* The sheet: the whole review, over the page, CENTRED (Luke, 29 Sep: "a
   tad smaller and more centered, so we can see top and bottom"). It rose
   from the bottom edge to 85% of the screen, so on a phone its foot sat
   under the tab bar and the page behind showed only as a band on top.
   Now a card with space all round, its own edges visible top and bottom;
   the text scrolls inside it. */
.review-sheet {
  position: fixed;
  inset: 0;
  z-index: 90;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background: rgba(0, 0, 0, 0.6);
}
.review-sheet[hidden] { display: none; }
.review-sheet-card {
  display: flex;
  flex-direction: column;
  width: min(100%, 40rem);
  max-height: min(78vh, 46rem);
  background: var(--bg-panel-raised, var(--bg-panel));
  border: 1px solid var(--hairline);
  border-radius: 16px;
  padding: 1rem 1.2rem 1.1rem;
}
/* The heading stays put; the review scrolls under it. */
.review-sheet-card .review-sheet-text { overflow-y: auto; min-height: 0; }
/* The game's name with your rating beside it, centred on the same line
   (Luke: "the ten middle aligned with the text"). */
.review-sheet-head {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding-bottom: 0.7rem;
  margin-bottom: 0.8rem;
  border-bottom: 1px solid var(--hairline);
}
.review-sheet-title { margin: 0; min-width: 0; }
.review-sheet-rating {
  font-family: "Big Shoulders Display", sans-serif;
  font-weight: 700;
  font-size: 1.15rem;
}
.review-sheet-rating:empty { display: none; }
.review-sheet-close { margin-left: auto; }
.review-sheet-text { font-size: 0.95rem; line-height: 1.65; color: var(--text-muted); }

/* The formatting toolbar, over the box while writing. Buttons, not a
   menu: five marks, each one tap. */
.review-toolbar {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.3rem;
  margin: 0 0 0.4rem;
}
.review-toolbar[hidden] { display: none; }
.review-toolbar .btn { min-width: 2.2rem; }
/* A thin divider between the formatting and Notes, which is not one. */
.review-toolbar .toolbar-sep { width: 1px; align-self: stretch; margin: 0.15rem 0.2rem; background: var(--hairline); }
.review-toolbar .notes-open-btn { display: inline-flex; align-items: center; gap: 0.35rem; margin: 0; }
.review-toolbar .notes-open-btn[hidden] { display: none; }
/* In the Top 5 editor the toolbar sits under a left-aligned label, so it
   starts at the left rather than centring. */
.rate-field .review-toolbar { justify-content: flex-start; margin: 0.15rem 0 0.35rem; }
.review-toolbar .fmt-bold { font-weight: 800; }
.review-toolbar .fmt-italic { font-style: italic; }

/* ---------- Cover-only cards (experiment) ---------- */

/* Names hidden on the Top 100 and Favourites grids -- the art is the
   identifier, and a title under every tile turned two dense grids into
   walls of small text.
   Kept in the DOM rather than removed so screen readers and the hover
   tooltip still have them; only the visual label goes. */
/* Also applies to the per-year lists, which are nested <ol>s and so
   don't match a selector scoped to .top100-list alone. */
.top100-list .top100-grid-name,
.top100-year-list .top100-grid-name,
.favorites-grid .favorite-name {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* Without the caption the card is just its cover, so the wrapper doesn't
   need the extra vertical space it was reserving for text. */
.top100-list .top100-grid-item,
.top100-year-list .top100-grid-item,
.favorites-grid .favorite-card {
  gap: 0;
}

/* ---------- Library table headers ---------- */

/* The sortable headers sat higher than the rest. Their .sort-arrow is an
   inline span at a smaller font size, and an empty one still contributes
   a line box -- so those cells got a different content height and the
   text rode up. Fixing the alignment on the cell and giving the arrow a
   reserved, non-shifting slot lines all eight up on one baseline. */
#library-table thead th {
  vertical-align: middle;
  white-space: nowrap;
  line-height: 1.2;
}
/* A SUNSET RULE under the column names (retro experiment). Righteous was
   tried on the names themselves and was hard to read at that size, so the
   brand is carried by the line instead: a 2px solid amber rule where a
   grey hairline was. (It was the wordmark's sunset gradient for a pass;
   no gradients outside the wordmark now.) */
#library-table thead tr {
  background-image: var(--va-edge);
  background-size: 100% 2px;
  background-position: bottom;
  background-repeat: no-repeat;
}
#library-table thead th { border-bottom-color: transparent; }
#library-table .sort-arrow {
  display: inline-block;
  width: 0.8em;
  text-align: left;
  /* Same line-height as the label, so an arrow appearing on sort doesn't
     nudge the header it belongs to. */
  line-height: inherit;
  vertical-align: baseline;
  margin-left: 0.15rem;
}

/* ---------- Detail sections: one treatment ---------- */

/* Playthroughs, DLC and the stat row all sat in bordered panels while
   Achievements was a plain section with a rule above it. That made three
   parts of one page look like three different kinds of thing.
   Achievements reads better -- the content carries itself and the border
   just adds weight -- so the rest follow it. */
.playthroughs-block,
.dlc-block {
  background: transparent;
  border: none;
  border-top: 1px solid var(--hairline);
  border-radius: 0;
  padding: 1.5rem 0 0;
  margin: 1.5rem 0;
}

.game-detail-stat-row {
  background: transparent;
  border: none;
  border-radius: 0;
  padding: 0.4rem 0 0.2rem;
  /* stretch, so every panel matches the tallest -- Time to Beat carries
     a sub-line and the others were sitting high against it. */
  align-items: stretch;
}

/* The field groups keep their panels -- they only appear in edit mode,
   where a boxed form is exactly right. */

/* In VIEW mode the groups are read-only summaries, so they take the same
   flat treatment as Achievements and Playthroughs. In EDIT mode they get
   their panel back, where a boxed form is the right thing. */
.game-detail-fields:not(.is-editing) .field-group {
  background: transparent;
  border: none;
  border-top: 1px solid var(--hairline);
  border-radius: 0;
  padding: 1.1rem 0 0;
}
.game-detail-fields:not(.is-editing) {
  gap: 0;
}

/* ---------- Stat row and timeline: softer typography ---------- */

/* BACK TO MONO, WHICH REVERSES THE NOTE THAT USED TO BE HERE.
   It said the labels went to the body face because mono "read as
   terminal output" in a row of four standalone figures. That was true of
   the row as it was: small figures in bordered tiles, where the label
   competed with the number.
   It stopped being true once the boxes went and the figures doubled in
   size -- and it left the page speaking two ways at once. "LOOK IT UP",
   "FIRST PLAYTHROUGH" and "YOUR RATING" are all mono uppercase; only
   these labels and the timeline were not, so a small label meant one
   thing on the left of the page and another on the right.
   One rule now: section headings are the display face, small labels are
   mono uppercase. Matches .look-it-up-label exactly. */
.game-detail-stat-label {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}
.game-detail-stat-value {
  font-family: var(--font-body);
  font-size: 1.05rem;
  font-weight: 600;
}

/* The secondary times sit under the headline figure rather than flush
   left, so "+Extra 78h · 100% 132h" reads as belonging to the 52h above
   it instead of starting a new column. */
.stat-sub {
  font-family: var(--font-body);
  font-size: 0.7rem;
  font-weight: 400;
  letter-spacing: 0;
  color: var(--text-muted);
  text-align: center;
  margin-top: 0.25rem;
}
/* Each figure in its own tinted panel. The row was four numbers floating
   on the page background with nothing but whitespace separating them --
   the panels give each one an edge to sit against and let the colour say
   what kind of number it is. Backgrounds are kept very low opacity so
   the border reads as the shape and the tint as a hint. */
.game-detail-stat {
  align-items: center;
  text-align: center;
  /* Centred within the stretched panel, so a cell without a sub-line
     sits level with one that has it. */
  justify-content: center;
  padding: 0.6rem 0.9rem;
  border-radius: 10px;
  border: 1px solid var(--hairline);
  background: rgba(255, 255, 255, 0.025);
  /* No min-width. It sized cells for the old flex layout and now fights
     the grid track: cells came out 144, 174 and 136 wide inside columns
     meant to be equal. The grid decides the width. */
  min-width: 0;
}

/* One colour for the whole row, taken from the game's own cover art.
   Four different colours implied four different KINDS of number, which
   isn't true -- they're all just figures about this game. Using the
   sampled cover colour instead means the row belongs to the game rather
   than to a fixed palette, and it changes as you move between games.
   Falls back to gold when the cover can't be sampled. */
.game-detail-stat {
  border-color: var(--game-accent-line, rgba(var(--act-rgb), 0.28));
  background: var(--game-accent-fill, rgba(var(--act-rgb), 0.05));
}
.game-detail-stat .game-detail-stat-value {
  color: var(--game-accent, var(--gold));
}

/* Timeline labels follow suit. */
.timeline-label {
  font-family: var(--font-body);
  font-size: 0.68rem;
  text-transform: none;
  letter-spacing: 0.01em;
  color: var(--text-muted);
}
.timeline-date {
  font-size: 0.85rem;
}

/* Chips keep a little mono character -- they're short labels where the
   letterforms read as deliberate rather than technical. */
.summary-chip {
  font-size: 0.64rem;
}

/* ---------- Calendar with cover art ---------- */

.calendar-day {
  /* A row shorter -- 130px pushed the last week below the fold on a
     laptop, and the art is still legible at this size. */
  min-height: 104px;
  position: relative;
  overflow: hidden;
}

/* The art fills the whole cell rather than sitting as a thumbnail inside
   it. Four 38px thumbnails squeezed each cover to a sliver, and a
   release day usually has one game worth looking at anyway -- so one
   fills the square and the arrows step through any others. */
.calendar-day-games {
  position: absolute;
  inset: 0;
}

.calendar-game {
  position: absolute;
  inset: 0;
  display: none;
  text-decoration: none;
}
.calendar-game.is-active { display: block; }

/* Owned, said on the tile. A sentence at the top of the month could not
   answer it for one square.
   Only the positive case: a pill on everything you do NOT own turns a
   month of releases into a list of things you have not bought. */
.calendar-owned-pill {
  position: absolute;
  top: 0.25rem;
  right: 0.25rem;
  z-index: 2;

  padding: 0.08rem 0.32rem;
  border-radius: 999px;
  /* Opaque, because it sits on cover art of any colour, and the same
     dark chip the other on-cover controls use so they read as one
     family. */
  background: rgba(10, 7, 14, 0.82);
  color: var(--text);

  font-family: var(--font-mono);
  font-size: 0.55rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  line-height: 1.5;
  /* The day number sits top left; this must not wrap under it on a
     narrow cell. */
  white-space: nowrap;
  pointer-events: none;
}

.calendar-game img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  /* Full brightness at rest. Dimming to 0.55 and lifting on hover meant
     the calendar looked washed out until you pointed at it -- the day
     number and label are legible against the art on their own, since
     both carry their own shadow or gradient. */
  opacity: 1;
}

.calendar-game-fallback {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  padding: 0.4rem;
  font-size: 0.62rem;
  line-height: 1.2;
  text-align: center;
  color: var(--text-muted);
}

/* Name across the bottom, over a gradient so it reads against art of any
   brightness. */
.calendar-game-label {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 1.2rem 0.35rem 0.3rem;
  font-size: 0.6rem;
  font-weight: 600;
  line-height: 1.15;
  color: var(--text);
  background: linear-gradient(to top, rgba(10, 6, 15, 0.92), transparent);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Platform colour as an inset edge rather than a border, so it doesn't
   inset the artwork. */
.calendar-game.platform-steam       { box-shadow: inset 0 0 0 2px rgba(102, 192, 244, 0.65); }
.calendar-game.platform-playstation { box-shadow: inset 0 0 0 2px rgba(122, 140, 255, 0.65); }
.calendar-game.platform-xbox        { box-shadow: inset 0 0 0 2px rgba(110, 200, 120, 0.65); }
.calendar-game.platform-nintendo    { box-shadow: inset 0 0 0 2px rgba(230, 100, 100, 0.65); }

/* The day number sits above the art. */
.calendar-day-number {
  position: relative;
  z-index: 2;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
}

.calendar-nav-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 3;
  width: 18px;
  height: 26px;
  border: none;
  border-radius: 4px;
  background: rgba(10, 6, 15, 0.7);
  color: var(--text);
  font-size: 0.9rem;
  line-height: 1;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.12s ease;
}
.calendar-day:hover .calendar-nav-arrow { opacity: 1; }
.calendar-nav-arrow:hover { background: rgba(10, 6, 15, 0.9); color: var(--gold); }
.calendar-nav-arrow.prev { left: 2px; }
.calendar-nav-arrow.next { right: 2px; }

.calendar-slide-count {
  position: absolute;
  top: 3px;
  right: 4px;
  z-index: 3;
  font-family: var(--font-mono);
  font-size: 0.55rem;
  color: var(--text);
  background: rgba(10, 6, 15, 0.7);
  border-radius: 3px;
  padding: 0.05rem 0.25rem;
}

/* Two lines, so cards in a row stay the same height.
   2.5em, NOT 2.1: three top-level `.activity-card-name` rules exist --
   0.85rem/1.3 with 2.6em, then a cut to 0.72rem/1.25, then this one,
   last and winning, at 2.1em. Two lines at 1.25 is 2.5em, so one-line
   names came up short and the stat rows sat at two heights. Moves with
   the line-height above. */
.activity-card-name {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  min-height: 2.5em;
}
.activity-card-stat,
.activity-card-stat-secondary {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ---------- The Game Awards ---------- */

.game-awards-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  margin-bottom: 0.9rem;
}
.game-awards-row:empty { display: none; }

.award-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-size: 0.66rem;
  padding: 0.24rem 0.55rem;
  border-radius: 999px;
  white-space: nowrap;
  cursor: default;
}

/* GOLD FOR A WIN, SILVER FOR A NOMINATION (Luke, 29 Sep) -- the medals,
   in every theme. They were the theme accent and a grey, so on a blue
   theme a win was blue and matched the genre and Owned pills. Fixed
   colours (--medal-gold / --medal-silver, in :root and in no theme),
   not --gold: --gold is the theme's accent, not gold. The filled
   and outlined trophy still says the same thing by shape. */
.award-chip.won {
  background: rgba(var(--medal-gold-rgb), 0.14);
  border: 1px solid rgba(var(--medal-gold-rgb), 0.5);
  color: var(--medal-gold);
  font-weight: 600;
}
.award-chip.nominated {
  background: rgba(var(--medal-silver-rgb), 0.08);
  border: 1px solid rgba(var(--medal-silver-rgb), 0.35);
  color: var(--medal-silver);
}
.award-chip svg { flex-shrink: 0; }

/* Overflow tile: how many earned achievements aren't shown. Sits in the
   last slot of the second row so the grid stays two even rows. */
.achievement-more {
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 600;
  color: var(--text-muted);
  background: rgba(255, 255, 255, 0.04);
  border: 1px dashed var(--hairline);
}
.achievement-more:hover { color: var(--gold); border-color: var(--gold-soft); }

/* A half point fills half a bar. The gradient is set inline per pip
   because the fill colour changes along the red-to-green scale.

   THE TRACK STAYS. This used to carry `border-color: transparent`, copied
   from .is-filled where it is right -- a full bar IS the colour, so an
   outline round it is noise. On a half bar it deleted the slot: the
   unfilled right side is rgba(255,255,255,0.06), which against the card
   is the same near-nothing as the 2px gap either side of it. So 8.5
   rendered as eight bars, a floating sliver, and one outlined empty box
   -- nine slots where there are ten, and no way to see the half was half
   OF something.

   background-clip keeps the fill inside the border rather than painting
   under it, so the hairline draws as a clean track instead of laying a
   grey line along the top of the green. */
.rating-pip.is-half {
  background-clip: padding-box;
}

/* The platinum badge holds an icon now, not text. */
.platinum-badge {
  display: inline-flex;
  align-items: center;
  padding: 0.15rem 0.3rem;
  color: #cfd6e6;
}

/* ---------- Top 100 grouped by year ---------- */

.top100-year-group {
  list-style: none;
  margin-bottom: 1.6rem;
}

.top100-year-heading {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  font-family: var(--font-display);
  font-size: 1.5rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  color: var(--text);
  margin: 0 0 0.5rem;
  padding-bottom: 0.3rem;
  border-bottom: 1px solid var(--hairline);
}

/* The count sits with the year rather than in a separate line -- it's a
   detail about the year, not a statement of its own. */
.top100-year-count {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  font-weight: 400;
  color: var(--text-faint);
}

.top100-year-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* The tiles toolbar reads as one row of equals now that "+ New tile set"
   is always present rather than appearing only in edit mode. */
.tiles-toolbar { margin-bottom: 0.9rem; }
/* The toolbar is display: flex, which beats the UA's [hidden] rule. */
.tiles-toolbar[hidden] { display: none; }

/* The year list is a nested <ol> inside an <li>, so it needs the grid
   declared explicitly -- .top100-list's own rule is scoped to the top
   level and the nested one inherited nothing, collapsing every card. */
.top100-year-list {
  display: grid;
  grid-template-columns: repeat(10, minmax(0, 1fr));
  gap: 0.5rem;
}
.top100-year-heading {
  flex-wrap: nowrap;
  white-space: nowrap;
}
.top100-year-count { white-space: nowrap; }

/* When grouped by year, the outer list stops being a grid.
   #top100-list is a 10-column grid, so each year GROUP was landing in a
   single 86px cell and its nested list inherited that width -- which is
   why every card measured about one pixel across. The groups stack; the
   grid lives on the per-year lists instead. */
#top100-list:has(.top100-year-group) {
  display: block;
}

/* ---------- Top 100: breathing room ---------- */

/* Eight across rather than ten. Removing the titles made room for
   bigger art, but keeping ten columns spent that room on nothing --
   a hundred covers at ten-wide is a wall. Eight gives each cover about
   25% more area and lets the grid read as rows rather than a texture. */
/* Ten across. Eight was right at 980px; the page is 1240px now and the
   covers were getting larger than they need to be. */
.top100-list,
.top100-year-list {
  grid-template-columns: repeat(10, minmax(0, 1fr));
  gap: 0.6rem;
}

/* Row separation matters more than column separation here: the eye
   scans a ranking downward, and a slightly wider vertical gutter is
   what turns a block into countable rows. */
.top100-list { row-gap: 1.1rem; }
.top100-year-list { row-gap: 1rem; }

/* The stat bar sat over the art on every card, so a hundred of them
   read as a hundred dark stripes. Hidden until hover -- the rank badge
   is the thing you're scanning for, and the rating and hours are detail
   you want on the one card you've stopped at. */
.top100-grid-stat-bar {
  opacity: 0;
  transition: opacity 0.12s ease;
}
.top100-grid-item:hover .top100-grid-stat-bar,
.top100-grid-cover-wrap:hover .top100-grid-stat-bar {
  opacity: 1;
}

/* Rank badge slightly larger and higher contrast, since it's now the
   only thing on the card at rest. */
.top100-grid-rank-badge {
  font-size: 0.78rem;
  padding: 0.12rem 0.4rem;
  background: rgba(10, 6, 15, 0.82);
}

/* A little lift on hover so the card you're pointing at separates from
   its neighbours. */
.top100-grid-cover-wrap {
  transition: transform 0.12s ease;
}
.top100-grid-item:hover .top100-grid-cover-wrap {
  transform: translateY(-3px);
}

/* ---------- Sync health ---------- */

/* Collapsed to one line when everything is fine. The point is to make a
   broken backfill visible, not to add a dashboard nobody reads -- so at
   rest it says "everything syncing normally" and nothing more. */
.health-block {
  margin-top: 0.8rem;
  padding-top: 0.8rem;
  border-top: 1px solid var(--hairline);
}

.health-toggle {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  gap: 0.5rem;
  background: none;
  border: none;
  padding: 0.2rem 0;
  color: var(--text-muted);
  font-family: var(--font-body);
  font-size: 0.74rem;
  cursor: pointer;
}
.health-toggle:hover { color: var(--text); }
.health-chevron { color: var(--text-faint); }

.health-list {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  margin-top: 0.5rem;
}

.health-row {
  display: flex;
  align-items: baseline;
  gap: 0.45rem;
  font-size: 0.7rem;
}
.health-step {
  color: var(--text);
  flex-shrink: 0;
}
.health-detail {
  color: var(--text-faint);
  font-family: var(--font-mono);
  font-size: 0.62rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.health-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  flex-shrink: 0;
  display: inline-block;
}
.health-dot.ok   { background: var(--ok); }
.health-dot.warn { background: var(--gold); }
.health-dot.err  { background: #e66464; }

/* Tier headings match the year headings -- Tiers and the Top 100 list
   are two views of one thing and shouldn't look like different pages. */
/* THE RANKINGS' VIEW OPTIONS -- Top 5 | Top 100, the tier groupings, the
   tile sets. They were "the second navigation row", set a touch smaller
   than a primary toggle above them so the two read as parent and child.
   There is no toggle above them any more -- the sub-views are in the
   banner -- and they share a line with the search, so they are full size:
   2rem, the height of the search beside them. At the old size they were
   27px next to a 32px field, which is the kind of mismatch that makes a
   toolbar look assembled.
   No margin: the toolbar's gap does the spacing. */
.sub-nav-row { margin: 0; }
.view-toggle-sub { margin: 0; }
/* Less 2px: the toggle's own 1px border sits outside the buttons, and
   the search's border is inside its 2rem. */
.view-toggle-sub .view-toggle-btn { height: calc(2rem - 2px); }

/* THE NUMBER INSIDE THE HEADING.
   It has to read as part of "Top 25" rather than as a form field parked
   next to it -- so it inherits the heading's font, size and colour, and
   carries a dotted underline as the only sign it can be changed. A
   border or a filled background would make the title look like it had a
   text input stuck to the end of it.
   The spin buttons go: they are tiny, they sit at the wrong optical
   size against display type, and the field is one or two digits. */
/* THE COUNT FIELD, standing on its own beside the sub-nav.
   It spent a revision inside the active toggle button, where it had to
   be quiet enough not to wreck the button -- which is what made it
   impossible to find. Out here it can just look like a field. */
.sub-nav-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.6rem 1.1rem;
}
/* ORDER PINNED, because `.view-toggle` carries `order: 2` for its place
   in the controls bar -- and flexbox honours that over DOM order, so the
   field rendered to the LEFT of the toggle it belongs to. Set on both
   children rather than just one: relying on the default for the other
   is what made this depend on a rule written for somewhere else. */
.sub-nav-row .view-toggle-sub { order: 1; }
.sub-nav-row .topx-count-control { order: 2; }
.topx-count-control {
  display: flex;
  align-items: center;
  gap: 0.45rem;
}
.topx-count-control[hidden] { display: none; }
.topx-count-control label {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}
.topx-count-input {
  width: 3.4rem;
  padding: 0.28rem 0.45rem;
  border-radius: 8px;
  border: 1px solid var(--hairline);
  background: rgba(255, 255, 255, 0.04);
  color: var(--text);
  font: inherit;
  font-size: 0.82rem;
  text-align: center;
}
.topx-count-input:hover { border-color: var(--text-dim); }
.topx-count-input:focus,
.topx-count-input:focus-visible {
  outline: none;
  border-color: var(--accent, var(--text));
}


/* THE NUMBER INSIDE THE HEADING.
   It has to read as part of "Top 25" rather than as a form field parked
   next to it -- so it inherits the heading's font, size and colour, and
   carries a dotted underline as the only sign it can be changed. A
   border or a filled background would make the title look like it had a
   text input stuck to the end of it.
   The spin buttons go: they are tiny, they sit at the wrong optical
   size against display type, and the field is one or two digits. */
/* IT HAS TO LOOK LIKE A FIELD.
   The first version inherited the button's type with only a dotted
   underline to mark it, on the reasoning that a border would make the
   label look like it had an input stuck to the end of it. That was true
   and it was the wrong trade: the underline is two pixels of dotted
   hairline inside an already-highlighted pill, and the first person to
   see it could not tell the number could be changed at all. An
   affordance nobody finds is not restraint, it is a missing control.
   So: inset, bordered, and a hair smaller than the label beside it. It
   reads as "Top [20]" -- a word and a field -- which is what it is. */

.tier-label {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  font-family: var(--font-display);
  font-size: 1.5rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  margin: 0 0 0.5rem;
  padding-bottom: 0.3rem;
  border-bottom: 1px solid var(--hairline);
}
.tier-count {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  font-weight: 400;
  color: var(--text-faint);
}
.tier-group { margin-bottom: 1.6rem; }

/* "+ New" sits with the tile sets rather than the page controls -- it
   creates one of the things the row is listing. */
/* NOT GOLD. It sat in a row of tile-set buttons wearing the colour this
   interface uses for the ACTIVE one, so "+ New" read as the selected set
   -- the one thing in the row it could never be. It is an ordinary
   button that happens to come last; the hairline is what separates it
   from the sets, and that is separation enough. */
.tiles-new-btn {
  border-right: 1px solid var(--hairline);
}

/* The picker shows why it chose a game, not just which one. */
.upnext-pick-body { position: relative; }
.upnext-pick-facts {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  margin-top: 0.5rem;
}

/* Acclaimed & unplayed uses the same card as the other rows -- and, since
   #152, the same stat colour: its gold was the one row whose stat did not
   match the rest. */

/* PLAY NEXT: THE STAT FOLLOWS THE TITLE (#152, 1 Oct). Every title
   reserved two lines so the stats in a row sat level; a one-line title
   then had a line of air between it and its stat, which is most titles.
   Chosen on the real page from that, this, and one-line titles with an
   ellipsis: no reservation, the stat straight under the title, and the
   spare space at the bottom of the card -- the usual answer in a grid of
   covers. Two-line titles still show whole.
   Scoped to Play Next: the profile's rows keep their own rules above. */
#view-upnext .activity-card-name { min-height: 0; }
/* The pull-up on the first stat took back some of the reserved air; with
   no reservation it would put the stat against the title. */
#view-upnext .activity-card-name + .activity-card-stat { margin-top: 0; }
/* The second stat line sits under the first as one fact in two lines,
   with a hair between them. */
#view-upnext .activity-card-stat + .activity-card-stat-secondary { margin-top: -0.2rem; }

/* ---------- Play Next: the queue (#150) ---------- */

/* THREE PLACES, ALWAYS. A queued game is its cover; a free place is a
   dashed box with a "+". Chosen on the real page over showing only what
   is queued: the cap is the feature, and three places say it without a
   sentence. Same card as every row below it, so the row lines up. */
.queue-message { margin: 0.35rem 0 0; font-size: 0.78rem; color: var(--gold); }
.queue-message[hidden] { display: none; }

.queue-slot {
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  text-align: left;
  font: inherit;
  color: inherit;
  align-items: stretch;
}
.queue-slot .activity-card-cover-wrap {
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  box-shadow: none;
  border: 1.5px dashed var(--hairline);
}
/* A place is not a cover: it does not lift or take the view's ring when
   pointed at, only its border lights. */
#view-upnext .queue-slot:hover .activity-card-cover-wrap {
  transform: none;
  box-shadow: none;
  border-color: var(--va);
}
.queue-slot-plus { font-size: 1.6rem; line-height: 1; color: var(--text-faint); }
.queue-slot:hover .queue-slot-plus { color: var(--text); }
.queue-slot .activity-card-name { color: var(--text-faint); font-weight: 500; }
/* The place you pressed stays lit while adding. */
.queue-slot[aria-pressed="true"] .activity-card-cover-wrap { border-color: var(--gold); }
.queue-slot[aria-pressed="true"] .queue-slot-plus { color: var(--gold); }

/* ON A QUEUED COVER: swap top left, x top right, and no number (1 Oct --
   the number, the x and the swap were three things on a 104px cover).
   Always showing on a phone, which has no pointer to reveal them with; on
   a desktop they wait for the pointer -- that half is in the one
   @media (hover: hover) block. The ::after is the finger's share of a
   25px button. */
.queue-card-btn {
  position: absolute;
  top: 5px;
  width: 1.55rem;
  height: 1.55rem;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 50%;
  background: rgba(10, 6, 15, 0.84);
  color: #fff;
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
  transition: opacity 0.12s ease, background 0.12s ease;
}
.queue-card-btn::after { content: ""; position: absolute; inset: -5px; }
.queue-card-btn:hover { background: #000; }
.queue-card-swap { left: 5px; }
.queue-card-remove { right: 5px; }
/* The game marked to leave: ringed, its swap button lit, until the "+"
   that replaces it is pressed or adding ends. */
#view-upnext .queue-card.is-target .activity-card-cover-wrap { box-shadow: 0 0 0 2px var(--gold); }
.queue-card.is-target .queue-card-swap { background: var(--gold); color: #1a1206; }

/* THE "+" ON THE OTHER ROWS' COVERS: in the markup always, on the page
   only while adding (#view-upnext.is-queue-adding). A tick on a game
   already queued; pressing that takes it out. */
.queue-add { display: none; }
#view-upnext.is-queue-adding .queue-add {
  position: absolute;
  right: 5px;
  top: 5px;
  width: 1.6rem;
  height: 1.6rem;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 50%;
  background: rgba(10, 6, 15, 0.86);
  color: #fff;
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25);
  font: 700 1.05rem/1 var(--font-body);
  cursor: pointer;
}
#view-upnext.is-queue-adding .queue-add::after { content: ""; position: absolute; inset: -6px; }
#view-upnext.is-queue-adding .queue-add:hover { background: #000; box-shadow: 0 0 0 1px #fff; }
#view-upnext.is-queue-adding .queue-add.is-queued {
  background: var(--gold);
  color: #1a1206;
  font-size: 0.82rem;
  box-shadow: none;
}

/* The bar that says what the "+" buttons are for, and ends it. Fixed, so
   it is there however far down the rows you have gone; under every sheet
   (z 90). The phone block lifts it clear of the phone's bottom bar. */
.queue-add-bar {
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  bottom: 1.25rem;
  z-index: 60;
  box-sizing: border-box;
  width: min(calc(100vw - 24px), 26rem);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.55rem 0.55rem 0.55rem 0.9rem;
  border-radius: 14px;
  border: 1px solid var(--gold);
  background: rgba(10, 6, 15, 0.95);
  color: var(--text);
  font: 600 0.82rem/1.25 var(--font-body);
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
}
.queue-add-bar[hidden] { display: none; }
.queue-add-done {
  flex: 0 0 auto;
  border: 0;
  border-radius: 9px;
  background: var(--gold);
  color: #1a1206;
  font: 800 0.8rem/1 var(--font-body);
  padding: 0.55rem 0.85rem;
  cursor: pointer;
}

/* "Which game should this replace?" -- the full review's popup, narrower,
   with the queued covers as the three answers. */
.queue-swap .queue-swap-card { width: min(100%, 25rem); }
.queue-swap-ask { margin: 0.5rem 0 0.9rem; font-size: 0.9rem; color: var(--text-muted); }
.queue-swap-ask strong { color: var(--text); }
.queue-swap-row { display: flex; gap: 0.75rem; justify-content: center; }
.queue-swap .queue-swap-pick {
  flex: 1 1 0;
  min-width: 0;
  max-width: 104px;
  width: auto;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  text-align: left;
  font: inherit;
  color: inherit;
  align-items: stretch;
}
.queue-swap .queue-swap-pick .activity-card-cover-wrap { width: 100%; height: auto; aspect-ratio: 2 / 3; }
.queue-swap .queue-swap-pick:hover .activity-card-cover-wrap,
.queue-swap .queue-swap-pick:focus-visible .activity-card-cover-wrap {
  box-shadow: 0 0 0 2px var(--gold);
  transform: none;
}
.queue-swap .queue-swap-pick:focus-visible { outline: none; }
.queue-swap .queue-swap-pick .activity-card-name { font-size: 0.72rem; line-height: 1.25; min-height: 0; }

/* The game page's chip: the one chip on the status line that is a button.
   Dashed while it is an offer, solid once the game is queued. */
.queue-chip { cursor: pointer; background: none; border-style: dashed; line-height: inherit; }
.queue-chip:hover { color: var(--text); border-color: var(--text-muted); }
.queue-chip.is-queued { border: 1px solid var(--gold); color: var(--gold); }

/* Up Next uses the Top 100 grid so the two look like one product. */
#upnext-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(10, minmax(0, 1fr));
  gap: 0.6rem;
  row-gap: 1.1rem;
}
#upnext-grid .top100-grid-name { display: none; }

/* Wishlist games are marked rather than hidden -- they're legitimate
   answers to "what next", they just aren't in hand yet. */
.upnext-unowned {
  font-size: 0.58rem;
  background: rgba(10, 6, 15, 0.82);
  color: var(--text-muted);
}

@media (max-width: 1100px) {
  .top100-list,
  .top100-year-list { grid-template-columns: repeat(5, minmax(0, 1fr)); }

  #upnext-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}

/* IGDB's description of the game, above your own review -- what it is,
   then what you thought of it. */
.game-about {
  margin: 1.5rem 0;
  padding-top: 1.5rem;
  border-top: 1px solid var(--hairline);
}
.game-about-text {
  margin: 0.5rem 0 0;
  font-size: 0.86rem;
  line-height: 1.55;
  color: var(--text-muted);
  /* Long enough to be useful, short enough not to push your own review
     off the screen. */
  max-width: 68ch;
}

/* The cleanup card is a link now -- you often can't decide whether to
   keep something without looking at it first. */
.cleanup-card-link {
  display: flex;
  gap: 1rem;
  align-items: center;
  text-decoration: none;
  color: inherit;
  width: 100%;
}
.cleanup-card-link:hover .cleanup-name { color: var(--gold); }

/* (The "Open game details" hint line and its rules went on 1 Oct: the
   card is still the link, and its name turning gold on hover says so.) */

/* Marks DLC hours that are already inside the base game's total, so the
   number doesn't look like it was left out of the sum by mistake. */
.dlc-included {
  font-family: var(--font-mono);
  font-size: 0.56rem;
  color: var(--text-faint);
  margin-left: 0.35rem;
  padding: 0.05rem 0.3rem;
  border: 1px solid var(--hairline);
  border-radius: 3px;
}

/* The collection offer: what this bundle contains, and a way to add it. */
.collection-offer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
  margin: 1.5rem 0;
  padding: 0.9rem 1.1rem;
  border-radius: var(--radius);
  border: 1px solid var(--gold-soft);
  background: rgba(var(--act-rgb), 0.06);
}
.collection-offer-title {
  margin: 0;
  font-size: 0.88rem;
  font-weight: 600;
  color: var(--text);
}
.collection-offer-note {
  margin: 0.2rem 0 0;
  font-size: 0.72rem;
  color: var(--text-muted);
}
/* The games you have link to their pages, styled like "Played as part
   of" on the game page -- the same link in the other direction. */
.collection-offer-note a {
  color: var(--gold);
  text-decoration: none;
}
.collection-offer-note a:hover {
  text-decoration: underline;
}
.collection-offer-missing {
  font-style: italic;
}
/* Who made it and, on a collection, what it contains -- one line under the
   name (Luke, 24 Sep). The byline keeps its own spacing below; the row
   only lines the two up, and wraps the tile underneath on a narrow screen.
   The tile is only as wide as what it says: full width left most of it
   empty beside a three-name list. */
.game-detail-byline-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  column-gap: 1rem;
  row-gap: 0.4rem;
  margin: 0.2rem 0 0.6rem;
}
.game-detail-byline-row > .game-detail-byline {
  margin: 0;
}
.game-detail-byline-row > .collection-offer {
  margin: 0;
  width: fit-content;
  max-width: 100%;
  padding: 0.35rem 0.8rem;
}
/* One line beside the byline rather than a title over a list -- two lines
   made the tile taller than the row it sits in (Luke, 24 Sep). The names
   still wrap under the title if the screen is too narrow for both. */
.game-detail-byline-row .collection-offer-text {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  column-gap: 0.45rem;
}
.game-detail-byline-row .collection-offer-title {
  font-size: 0.8rem;
}
.game-detail-byline-row .collection-offer-title::after {
  content: ":";
}
.game-detail-byline-row .collection-offer-note {
  margin: 0;
  font-size: 0.8rem;
}
/* "Part of: <collection>" on a game inside one -- the same tile, so the
   two directions of the link read as one thing. */
.game-detail-byline-row > .compilation-note {
  margin: 0;
  width: fit-content;
  max-width: 100%;
  padding: 0.35rem 0.8rem;
  border-radius: var(--radius);
  border: 1px solid var(--gold-soft);
  background: rgba(var(--act-rgb), 0.06);
  font-size: 0.8rem;
}
.compilation-note-label {
  font-weight: 600;
  color: var(--text);
}

/* Tiles hidden by the search, rather than removed -- keeping them in the
   DOM means clearing the search restores the grid instantly and doesn't
   disturb anything mid-edit. */
.award-card.tile-filtered-out { display: none; }

/* The Tiled sub-filters sit inline with the search rather than on the
   row above -- they're a set of things you switch between, and the
   search filters within whichever one you're on, so they belong to the
   same control group. */
.top100-section .controls {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex-wrap: wrap;
}
/* Explicit order: the tile sets, then the search. Without this the
   search kept its source position and sat between them.
   #top100-view-toggle was `order: 1` here until the primary nav moved
   out of .controls and up to #gamelib-nav, above both panes. The rule
   went with it rather than being left to match nothing -- a dead
   ordering rule is how the next person spends an afternoon wondering
   why changing it does nothing. */
#top100-filter-toggle:not([hidden]) { order: 2; }
/* The wrapper, not the input -- the search is inside #search-wrap, and
   ordering the input had no effect because it isn't the flex child. */
.top100-section .controls #search-wrap,
.top100-section .controls .search-wrap { order: 3; }

/* ---------- Profile summary ---------- */

/* No total hours and no backlog count, deliberately. Hours only go up,
   and a backlog counter turns the page into a list of debts. These are
   ratios and named games instead. */
/* A grid that closes its own gaps.
   Flex-wrap left a ragged last row and an obvious hole whenever the
   number of tiles wasn't a multiple of the row length -- and hiding a
   stat made it worse.
   `auto-flow: dense` is what makes show/hide safe: when a tile goes, the
   next one that FITS moves up into the space. So the user chooses what
   to show and the layout guarantees it still lines up -- rather than
   handing them a resize handle and asking them to fix it themselves.
   Sizing is a property of the STAT, not a user choice: a bare number is
   always small, a histogram always needs width. There is a right answer
   per widget, so the user isn't asked. */
/* A STACK OF BANDS, not one grid.
   One grid with spans of 1, 2 and 3 only fills its rows if the column
   count is six, and `auto-fit` picks that count from the container
   width -- so it was six at exactly one window size and ragged at every
   other. Five counters filled the row, then the named pair came up a
   track short and the histogram three short.
   Each band is its own grid now, holding one kind of tile, so a row ends
   flush by construction rather than by arithmetic. */
.profile-stats {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  margin: 1.2rem 0 1.6rem;
}

.profile-stat-band {
  display: grid;
  gap: 0.6rem;
  align-items: stretch;
}

/* The minimum decides how many fit per row, and it differs by kind: a
   counter holds four characters, a named tile holds a game title. Above
   the minimum `auto-fit` stretches every track equally, so the tiles
   within a band are always identical to each other. */
.profile-stat-band-counters { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
.profile-stat-band-named    { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }

/* IN THE SIDEBAR THEY ARE A LIST, NOT TILES.
   Two-up boxes with a 2rem figure each is a dashboard widget, and a
   dashboard widget in a 340px column beside a page of cover art sticks
   out badly -- it was the loudest thing on the page and the least
   important.
   One per line instead, label left and figure right, divided by a
   hairline: a details panel, which is the job this column is doing.
   Every band becomes a single column, so the grid arithmetic stops
   mattering here entirely. */
.profile-side .profile-stat-band {
  grid-template-columns: 1fr;
  gap: 0;
}

.profile-side .profile-stat {
  display: flex;
  flex-direction: row;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  /* No box. The row below is what separates one from the next, and a
     border round each as well would be two lines doing one job. */
  border: 0;
  border-radius: 0;
  background: none;
  /* Roomier than the first pass, which packed nine rows into 35px each
     and made a details panel hard to read at a glance. The point of the
     list was to stop the stats shouting, not to make them squint. */
  padding: 0.6rem 0.1rem;
  border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}

/* The label carries the meaning, so it reads at the body size and in the
   body colour. It was the muted grey a tile label uses, where the figure
   beside it was doing the work -- in a row they carry equally.
   0.9rem rather than 0.82: this column sits over a blurred cover-art
   backdrop, and small text over a busy, moving background needs more
   size than the same text on a flat one. */
.profile-side .profile-stat-label {
  font-size: 0.9rem;
  color: var(--text);
  letter-spacing: 0;
  text-transform: none;
}

/* THE SURFACE COMES FROM `.spotlight-block` NOW, the same panel the
   other two sidebar sections use, so this only has to undo the margins
   it carries for the main column.
   It had a bespoke copy of that glass for a while -- correct result,
   duplicated declaration, and a third surface to keep in step with the
   two it was imitating. */
.profile-side .profile-stats {
  margin: 0;
}
.profile-side .profile-stat-band .profile-stat:last-child { border-bottom: 0; }

/* Much smaller. The figure was 2rem to carry a tile; in a row it only
   has to out-weigh its own label. */
.profile-side .profile-stat-value {
  font-size: 1.25rem;
  line-height: 1;
  white-space: nowrap;
}
.profile-side .profile-stat-head { margin: 0; }

/* A NAMED STAT KEEPS TWO LINES. "Call of Duty: Modern Warfare" beside
   its own label in 340px leaves about eight characters for the title,
   so these stack: label, then the game under it. Still one row of the
   list, still the same hairline. */
.profile-side .profile-stat-named {
  flex-direction: column;
  align-items: flex-start;
  gap: 0.15rem;
}
.profile-side .profile-stat-named .profile-stat-game { font-size: 0.9rem; }
.profile-side .profile-stat-sub { font-size: 0.72rem; }

/* THE HISTOGRAM GETS ITS OWN LINES. As a row it was a label on the left
   and ten bars crushed into whatever was left -- about 90px, which is
   not a chart, it is a smudge. Stacked like the named stats: label,
   then the full width of the column underneath.
   Taller too. 46px was set for a tile three times this wide. */
.profile-side .profile-stat-wide {
  flex-direction: column;
  align-items: stretch;
  gap: 0.4rem;
}
.profile-side .rating-histogram {
  height: 64px;
  margin-top: 0;
}
/* Readable ticks. At 0.55rem under a 64px chart the numbers under the
   bars were decoration rather than a scale. */
.profile-side .histogram-tick { font-size: 0.62rem; }

/* Same treatment as the stat panels on a game's detail page: a bordered
   tile with a low-opacity tint, label and icon above the figure. The two
   pages should look like one product. */
.profile-stat {
  display: flex;
  flex-direction: column;
  /* Label ABOVE, number below, tight together -- they are one unit and
     the old 0.35rem let them drift apart. */
  gap: 0.15rem;
  padding: 0.9rem 1.1rem;
  border-radius: 10px;
  /* Border dropped to a tint alone.
     A visible box around every figure is a lot of lines for a page that
     is mostly artwork; the fill is enough to group things without
     drawing a grid over the profile. */
  border: 1px solid transparent;
  background: rgba(255, 255, 255, 0.03);
}

.profile-stat-head {
  display: flex;
  align-items: center;
  gap: 0.35rem;
}
/* Icons hidden rather than deleted.
   Every label carried a tiny glyph, which is decoration standing in for
   hierarchy -- and a row of seven different pictograms reads as busier
   than the numbers it labels. The markup stays so nothing has to be
   rewired if a future design wants them back. */
.profile-stat-icon { display: none; }

/* One colour across every tile, taken from the profile's accent.
   Category colouring encoded meaning but weakly -- nobody learns that
   blue means scale -- and four colours across seven tiles read as busy.
   Falls back to gold when nothing has been chosen. */
.profile-stat {
  border-color: var(--profile-accent-line, rgba(var(--act-rgb), 0.28));
  background: var(--profile-accent-fill, rgba(var(--act-rgb), 0.05));
}
.profile-stat .profile-stat-value,
.profile-stat .profile-stat-icon,
.profile-stat .profile-stat-game {
  color: var(--profile-accent, var(--gold));
}

/* The span under a named stat -- "eight years" beneath Rocket League.
   Deliberately quieter than the game name. The NAME is the answer; the
   duration is the evidence, and a profile that's mostly cover art can't
   afford two loud things in one tile. */
.profile-stat-sub {
  display: block;
  margin-top: 0.15rem;
  font-family: var(--font-mono);
  font-size: 0.68rem;
  color: var(--text-faint);
}

.profile-stat-value {
  font-family: var(--font-display);
  font-size: 2.4rem;
  font-weight: 700;
  line-height: 1;
  color: var(--gold);
}
/* The histogram card has no single figure, so its head shouldn't sit
   apart from the bars. */
.profile-stat-wide .profile-stat-head { margin-bottom: 0.1rem; }
.profile-stat-suffix {
  font-size: 0.9rem;
  color: var(--text-muted);
}
/* Sentence case, body face, no letterspacing.
   These were mono / UPPERCASE / letterspaced with a small icon -- the
   crypto-dashboard house style, and the reason the panel read as
   generated: it is the default a hundred templates reach for.
   The number is the content. A label is a caption and should behave like
   one, so it steps back and lets the figure be the loud thing. */
.profile-stat-label {
  font-family: var(--font-body);
  font-size: 0.78rem;
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
  color: var(--text-muted);
}

/* A named game rather than a number -- "you finished a 132-hour game" is
   a boast in a way an aggregate isn't. */
.profile-stat-named {
  /* No max-width: the grid decides the span now, and a fixed cap made a
     two-column tile narrower than its own cell. */
}
.profile-stat-game {
  font-size: 0.95rem;
  font-weight: 600;
  color: var(--text);
  text-decoration: none;
  line-height: 1.2;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.profile-stat-game:hover { color: var(--gold); }

/* The distribution, not the average. An average of 7.8 says nothing;
   the SHAPE says whether you rate everything highly or spread out --
   which is what makes a Top 100 legible to someone else. */
/* Bounded rather than greedy. `flex: 1 1 260px` let it swallow a whole
   row on a wide page, where a distribution of ten small bars became a
   billboard -- it's one stat among six, not the headline. */
.profile-stat-wide {
  /* No flex sizing and no max-width.
     These are leftovers from when this row was flex-wrap. Under the grid
     they fight it: the tile was correctly given `span 6` and then capped
     at 360px anyway, so a full-width histogram rendered a third of its
     cell and left the row looking broken -- the exact fault the packing
     exists to prevent.
     Width is the grid's job now. */
  min-width: 0;
}

.rating-histogram {
  display: flex;
  align-items: flex-end;
  gap: 3px;
  height: 46px;
  margin-top: 0.3rem;
  /* NO CAP. It used to be 340px, on the argument that the tile fills its
     row and the chart inside should keep a sane width -- which was right
     about the chart and left the tile two thirds empty, because the tile
     was the full width of the page.
     The tile is a third of a row now, sharing with the named stats, so
     the chart fills it and lands at about the width the cap was reaching
     for anyway. */
}
.histogram-col {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: center;
  height: 100%;
  gap: 2px;
}
.histogram-bar {
  width: 100%;
  border-radius: 2px 2px 0 0;
  min-height: 0;
  /* Same red-to-green scale as the rating bars on a game's page, so a
     column here means the same thing a bar there does. */
  transition: opacity 0.12s ease;
}
.histogram-col:hover .histogram-bar { opacity: 0.75; }
.histogram-tick {
  font-family: var(--font-mono);
  font-size: 0.5rem;
  color: var(--text-faint);
  line-height: 1;
}

/* The accent picker's rules (.profile-accent-picker, .palette-current,
   .accent-swatch) lived here; the picker was removed on 28 Sep. */

/* The histogram was kept on its red-to-green scale rather than the
   accent; on 28 Sep Luke chose one accent colour for it (PROFILE
   MAKEOVER, end of file) -- the ticks under the bars carry the scale. */

/* ---------- Gameplay Analysis: fit, don't scroll ---------- */

/* Every chart container fits its panel. Anything that needed dragging
   was hiding half its own data. */
.overview-chart-block,
/* `.chart-wrap` deliberately NOT clamped here.
   It used to be, and that made a chart on a phone squash rather than
   scroll -- and because this rule sits later in the file than the mobile
   block, any `overflow-x: auto` there was silently overridden. The mobile
   rule is the one that should decide. */
/* .gantt-wrap gets max-width only -- no overflow.
   It no longer scrolls sideways on a phone: labels stack above the bars
   and the chart fits. Setting overflow here would sit later in the file
   than the mobile block and silently win, which is the trap this file
   has fallen into three times now -- see .chart-wrap above. */
.overview-summary-row {
  overflow-x: hidden;
  max-width: 100%;
}
.gantt-wrap {
  max-width: 100%;
}
.chart-wrap {
  max-width: 100%;
}
.overview-summary-row {
  /* Summary cells wrap onto a second line rather than running off the
     edge -- a year you can't see is a year you don't have. */
  flex-wrap: wrap;
}
.overview-chart-block canvas {
  max-width: 100%;
}

/* Chart colours live in app.js -- PLATFORM_CHART_COLORS and CHART_ACCENT.
   They're applied by Chart.js at render time, so there's nothing to
   declare here. This comment used to sit above rules that were removed,
   describing a palette that had since moved. */

/* Retired reads as an accomplishment, not a failure -- it's the
   multiplayer equivalent of Completed, so it takes the same green. */
.summary-chip.status-retired {
  background: rgba(110, 200, 120, 0.12);
  border-color: rgba(110, 200, 120, 0.35);
  color: #96d8a0;
}

/* Multiplayer is a label about the game, not a judgement about you --
   neutral rather than the coloured status chips beside it. */
.summary-chip.multiplayer {
  background: rgba(122, 140, 255, 0.12);
  border-color: rgba(122, 140, 255, 0.35);
  color: #a8b4ff;
}

/* Asks about the multiplayer tag rather than guessing. Quiet enough to
   ignore, since the answer usually doesn't matter much. */
.multiplayer-suggestion {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex-wrap: wrap;
  margin: 1.2rem 0;
  padding: 0.7rem 0.9rem;
  border-radius: 10px;
  border: 1px dashed var(--hairline);
  background: rgba(255, 255, 255, 0.02);
}
.multiplayer-suggestion-text {
  font-size: 0.8rem;
  color: var(--text-muted);
  flex: 1;
  min-width: 200px;
}
.multiplayer-suggestion-text strong { color: var(--text); font-weight: 600; margin-right: 0.25rem; }
/* Yes and No travel together (#182): one unit that wraps as a whole. */
.multiplayer-suggestion-actions { display: inline-flex; gap: 0.5rem; flex: none; }

/* ---------- Public profile ---------- */
/*
   A page a stranger sees, so the artwork does the work and the chrome
   gets out of the way. Deliberately not the app shell restyled -- it has
   no tabs, no settings and no controls of any kind.
*/

.public-hero {
  position: relative;
  padding: 2.5rem 1.5rem 2rem;
  background-size: cover;
  background-position: center;
}

/* A scrim only when there's a background image behind the text. Without
   one the hero already sits on the page background and a second layer
   would just dull it. */
.public-hero.has-image::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, rgba(10, 6, 15, 0.55), rgba(10, 6, 15, 0.9));
}

.public-hero-inner {
  position: relative;
  z-index: 1;
  max-width: 980px;
  margin: 0 auto;
  display: flex;
  align-items: center;
  gap: 1.1rem;
}

.public-avatar-wrap {
  width: 72px;
  height: 72px;
  flex: 0 0 72px;
  border-radius: 50%;
  overflow: hidden;
  border: 2px solid var(--profile-accent, var(--gold));
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-panel);
}
.public-avatar { width: 100%; height: 100%; object-fit: cover; }
.public-avatar-placeholder {
  font-family: var(--font-display);
  font-size: 1.8rem;
  color: var(--profile-accent, var(--gold));
}

.public-name {
  font-family: var(--font-display);
  font-size: clamp(1.8rem, 6vw, 2.6rem);
  font-weight: 800;
  margin: 0;
  line-height: 1.1;
}

.public-tagline {
  margin: 0.1rem 0 0;
  font-size: 0.85rem;
  color: var(--text-muted);
}

.public-main {
  max-width: 980px;
  margin: 0 auto;
  padding: 1.5rem 1.5rem 3rem;
}

/* Stats sit in a row, not a grid of boxes. Three numbers is a sentence,
   not a dashboard, and a shared page shouldn't look like an admin panel. */
.public-stats {
  display: flex;
  flex-wrap: wrap;
  gap: 1.75rem;
  margin-bottom: 2.25rem;
}
.public-stat { display: flex; flex-direction: column; }
.public-stat-value {
  font-family: var(--font-display);
  font-size: 2rem;
  font-weight: 800;
  line-height: 1;
  color: var(--profile-accent, var(--gold));
}
.public-stat-label {
  font-family: var(--font-mono);
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-faint);
  margin-top: 0.3rem;
}

.public-section { margin-bottom: 2.25rem; }

/* Covers at a generous size, no titles underneath. The artwork is the
   content -- a caption under every tile turns it into a spreadsheet. */
.public-cover-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
  gap: 0.6rem;
}
.public-cover {
  display: block;
  aspect-ratio: 2 / 3;
  border-radius: 8px;
  overflow: hidden;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
}
.public-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.public-cover-blank {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  font-family: var(--font-display);
  font-size: 1.4rem;
  color: var(--text-faint);
}

/* Tiled sets. Wider cells than the plain cover grid, because each one
   carries a label and a game name underneath -- the label IS the content
   here, unlike the favourites grid where the artwork stands alone. */
.public-tile-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
  gap: 0.75rem;
  margin-bottom: 1.75rem;
}
.public-tile { display: flex; flex-direction: column; }
.public-tile-label {
  margin-top: 0.45rem;
  font-weight: 600;
  font-size: 0.82rem;
  line-height: 1.25;
}
.public-tile-game {
  font-size: 0.72rem;
  color: var(--text-faint);
  line-height: 1.25;
  /* Two lines, height reserved, so labels stay aligned across the row --
     same fix as the Tiled view in the app. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  min-height: 2.5em;
}

/* The one piece of self-promotion, kept quiet. A shared page that shouts
   its own branding is an advert; this is a footnote. */
.public-footer {
  margin-top: 2.5rem;
  text-align: center;
  font-size: 0.72rem;
}
.public-footer a { color: var(--text-faint); text-decoration: none; }
.public-footer a:hover { color: var(--gold); }

.public-error {
  text-align: center;
  color: var(--text-muted);
  padding: 3rem 0;
}

/* .activity-card-bounce ("bouncing soon" under a Currently Playing card)
   lived here. The line was removed in the profile makeover (28 Sep); the
   reason is at its old place in loadCurrentlyPlayingUnified. */

/* Which playthrough a DLC belonged to.
   Its own line rather than another item in the meta row -- it is a
   sentence, not a stat, and burying it among "15.0h · finished 2022"
   would lose the one fact nobody else's tracker can produce. */
.dlc-row-run {
  /* Full width of the wrapped row, so it always starts a new line. */
  flex: 0 0 100%;
  margin-top: 0.2rem;
  font-size: 0.72rem;
  color: var(--accent);
}
/* Answer buttons. Inline with the question rather than below it -- "played
   during your [second] [third]" reads as one sentence, which is what it
   is. */
.dlc-run-pick {
  font: inherit;
  font-style: normal;
  margin-left: 0.35rem;
  padding: 0.1rem 0.45rem;
  border-radius: 999px;
  border: 1px solid var(--hairline);
  background: transparent;
  color: var(--text);
  cursor: pointer;
}
.dlc-run-pick:hover:not(:disabled) {
  border-color: var(--gold);
  color: var(--gold);
}
.dlc-run-pick:disabled { opacity: 0.4; cursor: default; }

/* The uncertain form reads as a question, so it shouldn't carry the same
   weight as a stated fact. */
.dlc-row-run.is-question {
  color: var(--text-faint);
  font-style: italic;
}

/* A section switched off.
   `display: none` rather than the `hidden` attribute, because several of
   these sections already set `hidden` themselves for their own reasons --
   recent achievements when empty, highest rated when there's nothing this
   year. Two things toggling one attribute means one of them silently
   wins. */
.profile-section.section-off { display: none; }

/* Section toggles, profile edit mode only. */
.profile-sections {
  margin-top: 1rem;
  padding-top: 0.9rem;
  border-top: 1px solid var(--hairline);
}
.profile-sections-label {
  margin: 0 0 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-faint);
}
.profile-sections-list {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1.1rem;
}

/* Inside the panel these become a VERTICAL list.
   Wrapped into rows they were a grid, and dragging within a grid is
   ambiguous -- it isn't clear whether you are moving something left or
   up. A column has one axis and one meaning. */
.profile-edit-panel .profile-sections-list {
  flex-direction: column;
  flex-wrap: nowrap;
  gap: 0.1rem;
}
.profile-edit-panel .profile-section-toggle {
  padding: 0.3rem 0.35rem;
  border-radius: 6px;
}
.profile-edit-panel .profile-section-toggle:hover {
  background: rgba(255, 255, 255, 0.04);
}
.profile-section-toggle {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.8rem;
  cursor: pointer;
}

/* Sharing controls, profile edit mode only. */
.profile-share {
  margin-top: 1rem;
  padding-top: 0.9rem;
  border-top: 1px solid var(--hairline);
}
.profile-share-toggle {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.85rem;
  cursor: pointer;
}
.profile-share-handle {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  margin-top: 0.6rem;
  flex-wrap: wrap;
}
/* The prefix is shown rather than implied, so it's obvious the handle
   becomes a URL someone else can open. */
.profile-share-prefix {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--text-faint);
}
#profile-handle-input {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  padding: 0.3rem 0.5rem;
  border-radius: 6px;
  border: 1px solid var(--hairline);
  background: var(--bg-panel);
  color: var(--text);
  width: 10rem;
}
.profile-share-message {
  margin: 0.5rem 0 0;
  font-size: 0.72rem;
  color: var(--text-faint);
}

/* ---------- Game detail: top bar, look-it-up, video ---------- */

/* Back on the left, Edit on the right.
   Edit used to sit inside the IGDB match row beside "Change match" --
   two unrelated actions side by side, where the page-level one read as
   part of the matching control. */
.game-detail-topbar {
  position: relative;
  z-index: 1;
  /* The parent is itself a flex container, so this sized to its own
     content -- 233px of two buttons -- and `space-between` had nothing to
     distribute. Edit sat beside Back on the left instead of opposite it.
     width:100% makes it span the row so the split works. */
  width: 100%;
  display: flex;
  align-items: center;
  /* SIDE BY SIDE NOW, not at opposite ends (Luke, 23 Sep): Back and Edit
     game details sit together on the left, aligned on one line. Opposite
     ends put a screen's width between two controls you use together, and
     on a phone the two did not even share a baseline. The save state
     follows Edit, so "Saved" appears beside the thing that caused it. */
  justify-content: flex-start;
  gap: 0.5rem;
  margin-bottom: 0.75rem;
}
/* THESE TWO SIT ON THE HERO ARTWORK, so they cannot be transparent.
   `.btn-ghost` is a transparent fill, a hairline border and muted text --
   correct against the page background, and a coin toss against a cover.
   Baldur's Gate 3's hero is pale gold behind both of them and they all
   but vanish; a dark hero would hide them the other way.
   So: a dark translucent plate with a blur behind it, and text at full
   contrast. The blur is what keeps it readable over busy art rather than
   just over bright art -- a flat scrim has to be almost opaque to beat
   detail, and that reads as a box pasted on the image.
   Scoped to the topbar. Ghost buttons elsewhere are on panels and should
   stay quiet. */
.game-detail-topbar .btn {
  background: rgba(10, 7, 16, 0.62);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  border-color: rgba(255, 255, 255, 0.22);
  color: var(--text);
}
.game-detail-topbar .btn:hover,
.game-detail-topbar .btn:focus-visible {
  background: rgba(10, 7, 16, 0.82);
  border-color: rgba(255, 255, 255, 0.45);
}
/* The save state sits between them on the same artwork. */
.game-detail-topbar .detail-save-state {
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
}

/* ONE GAP ALONG THE BAR (1 Oct). Back to Edit was 8px and Edit to + Mark
   completed was 19px, at every width. Two causes: this group's gap was
   0.6rem against the bar's 0.5rem, and the save state sits between the
   two buttons -- an empty span is still a flex item, so it took a gap on
   each side while saying nothing. The gap now matches the bar's, and the
   save state takes no room until it has something to say. */
.game-detail-topbar-right {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.game-detail-topbar .detail-save-state:empty { display: none; }
.game-detail-topbar-right #detail-edit-toggle { order: -1; }
/* Back carries a bottom margin and align-self: flex-start from its
   standalone style, which sat it 8px higher than Edit once the two shared
   a line. In the bar, both centre on it. */
.game-detail-topbar #game-detail-back { margin-bottom: 0; align-self: center; }

/* External links, under the cover with the IGDB match.
   Labelled, because "PS STORE / IGDB" unlabelled reads as metadata about
   the game rather than somewhere to go. */
.look-it-up {
  margin-top: 0.75rem;
}
.look-it-up-label {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  margin-bottom: 0.3rem;
}

/* The IGDB match row now sits in the narrow cover column, so it wraps
   instead of running on one line as it did beside the title. */
.game-detail-cover .igdb-match-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  margin-top: 0.75rem;
}
.game-detail-cover .igdb-match-status {
  flex: 0 0 100%;
}

/* Trailer, 16:9, filling the About column. */
.game-video {
  margin-top: 1rem;
  aspect-ratio: 16 / 9;
  width: 100%;
  max-width: 640px;
  border-radius: 8px;
  overflow: hidden;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
}
.game-video iframe,
.game-video video {
  width: 100%;
  height: 100%;
  display: block;
  border: 0;
}

/* Up Next headline: the question, and the button that answers it.
   "Pick one for me" was a small button beside a sort dropdown -- an odd
   place for the single control that does what the page is for. */
/* Just the controls now -- the heading and blurb are gone, so this row
   holds "Pick one for me" and Refine and nothing else. 1.5rem below it
   was spacing for a title block that no longer exists, and it pushed the
   first row of covers down for nothing. */
.upnext-headline {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 1.5rem;
  margin-bottom: 0.6rem;
}

/* PSN verification code.
   Large and monospaced because it gets read off this screen and typed
   into a console or the PlayStation app -- a code that's hard to
   transcribe fails in a way that looks like the app being broken. */
.psn-verify-step { margin-top: 0.75rem; }
.psn-verify-code {
  font-family: var(--font-mono);
  font-size: 1.3rem;
  letter-spacing: 0.12em;
  color: var(--gold);
  background: var(--bg-panel);
  border: 1px dashed var(--hairline);
  border-radius: 6px;
  padding: 0.5rem 0.75rem;
  margin: 0.5rem 0 0.75rem;
  display: inline-block;
  user-select: all;
}

/* Drag handle in the stat list.
   A grip, not the whole row: the row also holds a checkbox, and making
   the label draggable would make toggling feel like it might move
   something. */
.profile-section-toggle.is-draggable { cursor: default; }
.drag-handle {
  cursor: grab;
  color: var(--text-faint);
  font-size: 0.85rem;
  line-height: 1;
  padding: 0 0.15rem;
  user-select: none;
}
.drag-handle:active { cursor: grabbing; }

/* ---------- Profile edit panel ---------- */

/* Slides in from the right rather than injecting controls into the page.
   Edit mode used to insert six controls between the sections they edit,
   so entering it reflowed everything -- you were editing a page that had
   just moved. This leaves the profile alone. */
.profile-edit-panel {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  width: min(380px, 100vw);
  z-index: 60;
  overflow-y: auto;
  padding: 1.25rem;
  background: var(--bg-panel);
  border-left: 1px solid var(--hairline);
  box-shadow: -18px 0 40px -20px rgba(0, 0, 0, 0.8);
}
.profile-edit-panel[hidden] { display: none; }

/* The page makes room for the panel rather than sitting under it.
   A fixed drawer covered the right-hand stats and sections -- which is
   exactly what you need to see while dragging them. Shifting the content
   keeps both on screen at once.
   Padding rather than margin: the profile background is a full-bleed
   image, and a margin would leave a bare strip beside it. */
/* The page shifts only while the PANEL is open, not for all of edit mode.
   Tying it to edit mode meant arranging sections inside a grid 380px
   narrower than the real one, so the layout you built was not the layout
   you got. */
@media (min-width: 901px) {
  .profile-content.panel-open {
    padding-right: calc(min(380px, 100vw) + 1.5rem);
    transition: padding-right 160ms ease;
  }

  /* YOU AND THE CRITICS: one row per direction. Each side shows five
     (CRITIC_NAMES_SHOWN), and auto-fit at 240px fitted four, so the fifth
     wrapped alone onto a row of its own (Luke). Five equal columns, with
     each game a stacked card (below) so a column this narrow never has to
     cut a name off. */
  #view-overview .critic-row {
    grid-template-columns: repeat(5, minmax(0, 1fr));
    gap: 0.6rem;
  }
}

.profile-edit-panel-head {
  /* Sticky, so Done is reachable from anywhere in the panel.
     The panel scrolls its own content, and the head scrolled away with
     it -- leaving the only way out somewhere above the fold, on a
     control people reach for precisely when they have finished
     scrolling. */
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--bg-panel);
  padding-bottom: 0.75rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 1.25rem;
}
.profile-edit-panel-head h3 {
  font-family: var(--font-display);
  font-size: 1.2rem;
  text-transform: uppercase;
  margin: 0;
}

/* A rule between groups rather than boxes around them: the panel is
   already a container, and nesting boxes inside a box is the look this
   redesign is trying to get away from. */
.profile-edit-group {
  padding-bottom: 1rem;
  margin-bottom: 1rem;
  border-bottom: 1px solid var(--hairline);
  /* Anchors the avatar popover. It is `top: calc(100% + 0.5rem)`, and the
     nearest positioned ancestor was `.profile-edit-panel` -- fixed and as
     tall as the window -- so it opened below the whole drawer. It is the
     only absolutely-positioned thing in the panel. */
  position: relative;
}
.profile-edit-group:last-child {
  border-bottom: 0;
  margin-bottom: 0;
}

/* The panel supplies its own spacing, so the blocks inside it drop the
   separators they carried when they sat inline in the page. */
.profile-edit-panel .profile-sections,
.profile-edit-panel .profile-share {
  margin-top: 0;
  padding-top: 0;
  border-top: 0;
}

/* The drag grips lived here -- six dots in the corner of every section
   and every stat tile. Both grids have a fixed order now, so there is
   nothing to grab.

   `position: relative` STAYS. The grips needed a positioned ancestor,
   but so does anything else that wants to sit in a corner of a section,
   and removing it would move whatever does that next. */
.profile-body > [data-section],
.profile-stat { position: relative; }

/* A dashed edge in edit mode, so what is draggable is obvious before
   anyone goes looking for a handle. */
.profile-content.is-editing .profile-body > [data-section],
.profile-content.is-editing .profile-stat {
  outline: 1px dashed rgba(255, 255, 255, 0.12);
  outline-offset: 3px;
  border-radius: 10px;
}

/* Sortable's drag states. */
.sortable-ghost { opacity: 0.35; }
.sortable-chosen { outline-color: var(--gold) !important; }

/* "How long have you got?" — chips beside Pick one for me.
   A question rather than a stored preference: length is a mood, and the
   answer changes between a Tuesday and a free weekend. */
.upnext-controls {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

/* "Refine" rather than four chips permanently on show.
   The chips beside the button made this read as a search form, and the
   point of "pick one for me" is not having to specify anything. */
.upnext-refine-toggle {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  background: none;
  border: 0;
  color: var(--text-muted);
  cursor: pointer;
  padding: 0.3rem 0;
  text-decoration: underline;
  text-underline-offset: 3px;
}
.upnext-refine-toggle:hover { color: var(--gold); }

.upnext-refine {
  margin-top: 0.75rem;
  padding: 0.85rem 1rem;
  border: 1px solid var(--hairline);
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.02);
}
.upnext-refine[hidden] { display: none; }
.upnext-refine-row { margin-bottom: 0.7rem; }
.upnext-refine-row:last-child { margin-bottom: 0; }
.upnext-refine-label {
  display: block;
  font-size: 0.78rem;
  color: var(--text-muted);
  margin-bottom: 0.35rem;
}

.upnext-length,
.upnext-genres {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
}
.length-chip {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 0.3rem 0.7rem;
  border-radius: 999px;
  border: 1px solid var(--hairline);
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
}
.length-chip:hover { color: var(--text); border-color: var(--text-faint); }
.length-chip.is-active {
  color: var(--bg);
  background: var(--gold);
  border-color: var(--gold);
}

.upnext-pick-empty {
  color: var(--text-muted);
  font-size: 0.9rem;
  margin: 0;
  padding: 0.9rem 0;
}

/* ---------- Rating and Review, Community Reviews ---------- */

/* The rating column and the textarea side by side, with the share toggle
   under the box it applies to. */
.review-body { flex: 1; min-width: 0; }

.review-share-toggle {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.8rem;
  font-size: 0.8rem;
  color: var(--text-muted);
  cursor: pointer;
}
.review-share-toggle[hidden] { display: none; }
/* A SWITCH, NOT A CHECKBOX (29 Sep): an on/off setting, drawn as one --
   and a small one, so the line reads as an option rather than a form. */
#review-is-public {
  appearance: none;
  -webkit-appearance: none;
  position: relative;
  width: 1.9rem;
  height: 1.05rem;
  margin: 0;
  flex: none;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.12);
  border: 1px solid var(--hairline);
  cursor: pointer;
  transition: background 0.15s ease;
}
#review-is-public::after {
  content: "";
  position: absolute;
  top: 1px;
  left: 1px;
  width: calc(1.05rem - 4px);
  height: calc(1.05rem - 4px);
  border-radius: 50%;
  background: var(--text-muted);
  transition: transform 0.15s ease, background 0.15s ease;
}
#review-is-public:checked { background: rgba(var(--act-rgb), 0.55); }
#review-is-public:checked::after { transform: translateX(0.85rem); background: var(--text); }
#review-is-public:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }

.community-reviews { margin-top: 2rem; }

/* Vertical, not a side-scroller.
   Reviews are paragraphs, and a horizontal row of paragraphs is a shape
   nobody reads -- you cannot skim it, and each card has to be truncated
   to fit. Three stacked, then "See more". */
.community-review-list {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  margin: 0.75rem 0;
}

.community-review {
  padding: 0.9rem 1rem;
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.025);
}
/* Who, what, then the score. `space-between` spread three items across
   the row and left the game name floating in the middle; the first two
   are one phrase. */
.community-review-head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-bottom: 0.4rem;
}
.community-review-author {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  color: var(--text);
  font-weight: 600;
  text-decoration: none;
}
.community-review-author:hover { color: var(--gold); }
.community-review-avatar {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  object-fit: cover;
}
.community-review-avatar.is-blank { background: var(--bg-panel); }

/* Marks a review from someone you follow.
   A quiet label, not a badge -- it says why this one is at the top, and
   nothing more. No follower counts anywhere in this app. */
.community-review-followed {
  /* No auto margin. It had one to push itself right when this row was
     `space-between`; the score owns that job now, and two auto margins
     split the free space and stranded this in the middle of the row. */
  margin-right: 0.15rem;
  font-family: var(--font-mono);
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

/* The rating, not a pip row.
   A number beside a name is enough at this size -- ten pips per review,
   three reviews deep, is more chart than the reviews deserve. */
/* The game the verdict is about. Quiet: the name is context for the
   number, not a heading -- and on the game's own page the reader already
   knows it. The separator is on the element rather than in the markup so
   it cannot end up orphaned when the name is missing. */
.community-review-game {
  font-size: 0.82rem;
  color: var(--text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
.community-review-game::before {
  content: "\00b7";
  margin-right: 0.5rem;
  color: var(--text-faint);
}

.community-review-rating {
  /* NEXT TO THE GAME, not at the far edge. Pushed right it was a number
     floating alone across the panel from the name and the game, and you
     had to travel to connect the verdict to its subject. Read together
     the line is one phrase: slumus, on Elden Ring, 10/10.

     The denominator is spelled out because a bare "7" is ambiguous on a
     page that also shows critic scores out of 100, and half-points mean
     an 8.5 is ordinary here. */
  font-family: var(--font-display);
  font-size: 1.3rem;
  font-weight: 700;
  color: var(--gold);
  line-height: 1;
  white-space: nowrap;
}
/* Quieter than the score, so "/10" reads as a unit rather than a second
   number. Same colour, so it stays part of the same word. */
.community-review-rating-max {
  font-size: 0.72em;
  font-weight: 600;
  opacity: 0.62;
}

.community-review-text {
  margin: 0;
  color: var(--text-muted);
  line-height: 1.55;
  white-space: pre-wrap;
}

/* ---------- Follow feed ---------- */

/* Sentences, not cards.
   Each line is one fact -- who, what, when, how long. A card grid would
   make eight completions look like a content wall; eight sentences read
   in about as many seconds, which is the right size for this. */
.follow-feed {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
  margin-top: 0.75rem;
}
.feed-line {
  display: flex;
  align-items: flex-start;
  gap: 0.6rem;
}
.feed-avatar {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  object-fit: cover;
  flex: 0 0 auto;
  margin-top: 0.1rem;
}
.feed-avatar.is-blank { background: var(--bg-panel); }
.feed-text {
  margin: 0;
  color: var(--text-muted);
  line-height: 1.5;
}
.feed-name {
  color: var(--text);
  font-weight: 600;
  text-decoration: none;
}
.feed-name:hover { color: var(--gold); }
.feed-text strong {
  color: var(--text);
  font-weight: 600;
}

/* Quieter than the name and the game -- it is an invitation, not the
   fact the line is reporting. */
.feed-review-link {
  color: var(--gold);
  text-decoration: none;
  font-size: 0.88rem;
}
.feed-review-link:hover { text-decoration: underline; }

/* "Not in your library yet."
   A statement, not a warning -- the game is here because someone you
   follow finished it, which is a recommendation rather than a problem to
   fix. No colour, no icon, no call to action beyond the store links that
   were already below it. */
.detail-not-owned {
  margin: 0.75rem 0 0;
  font-size: 0.85rem;
  color: var(--text-muted);
}
.detail-not-owned[hidden] { display: none; }

/* "Add to your library" in a feed line. A button that reads as a link --
   it is an offer inside a sentence, not a control. */
.feed-add-link {
  font: inherit;
  font-size: 0.88rem;
  background: none;
  border: 0;
  padding: 0;
  color: var(--gold);
  cursor: pointer;
}
.feed-add-link:hover { text-decoration: underline; }
.feed-add-link:disabled { opacity: 0.5; cursor: default; }

/* Follow button. (.public-contact, the Discord line under it, was
   removed with its setting on 28 Sep.) */
.public-follow {
  margin-left: auto;
  text-align: right;
}

/* Size buttons, bottom-right of each section in edit mode.
   Opposite corner from the drag grip so the two controls never overlap,
   and so grabbing one is never a mis-hit on the other. */
/* The S/M/L/XL size buttons lived here. Section widths are declared in
   PROFILE_SECTIONS now and closed up by packSectionRows, so there is
   nothing to press.
   Their whole comment was about a specificity fight to keep them hidden
   outside edit mode -- a control that needed a gate to stay out of the
   way was already a sign it was not earning its place. */

/* Bar drill-down: which games are in the bar you clicked.
   In the summary row under the chart, not a modal -- you are comparing
   it against the bar you just clicked, and a dialog covers that. */
.drilldown { width: 100%; }
.drilldown-head {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  margin: 0 0 0.4rem;
}

/* The games by this studio you have not started. Same treatment as the
   head above so they read as siblings.
   Deliberately quiet: the moment it looks like a heading with a count,
   this stops being "more from a studio you love" and becomes a backlog. */
.drilldown-subhead {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
  margin: 1rem 0 0.4rem;
  padding-top: 0.85rem;
  border-top: 1px solid var(--hairline);
}
/* Two columns, name left and hours right.
   Pills wrapping in a row read as tags -- you cannot scan them, and the
   hours end up wherever the text happens to break. A two-column list
   sorted most-played first is a list, which is what this is. */
.drilldown-games {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 0.1rem 1.5rem;
}
.drilldown-game {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.3rem 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.04);
  color: var(--text);
  text-decoration: none;
  font-size: 0.88rem;
}
.drilldown-game:hover { color: var(--gold); }
.drilldown-game-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.drilldown-game-hours {
  color: var(--text-faint);
  font-size: 0.78rem;
  flex: 0 0 auto;
}
.drilldown-empty { color: var(--text-muted); margin: 0; font-size: 0.85rem; }
/* "and 12 more" under an Hours bar's ten games (#103): the count after the
   names, quiet, so the list reads as ten names plus a fact. */
.drilldown-more { color: var(--text-muted); margin: 6px 0 0; font-size: 0.8rem; }

/* The page behind the panel does not scroll.
   Scrolling inside a fixed panel chains to the document once the panel
   hits its end, so the profile slid away underneath and the panel
   appeared to jump. `overscroll-behavior: contain` stops the chain. */
.profile-edit-panel { overscroll-behavior: contain; }
/* body.panel-locked { overflow: hidden } lived here, for every width. On
   desktop the page behind now scrolls while the panel is open (Luke, 28
   Sep: "so you can check things"); the lock is phone-only, where the panel
   is the whole screen -- in the (max-width: 640px) block. */

/* ---------- Gameplay Analysis: one chart, four metrics ---------- */

/* The selector sits beside the title rather than above the chart, so the
   heading reads "How you play — Hours / Games / Avg length / Avg days"
   as one line rather than a title with a toolbar under it. */
.overview-metric-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 0.6rem;
}
.overview-metrics {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
}

/* Full width: this block replaced four, and the point of consolidating
   was to give the remaining chart room rather than to save vertical
   space and keep it cramped. */
.overview-metric-block {
  grid-column: 1 / -1;
}

/* ---------- Gameplay Analysis: sections and the new blocks ---------- */

/* Group headings give the page an order.
   Twelve blocks used to sit in build order with nothing saying which was
   worth reading first. These are the question -- "what kind of player am
   I" -- decomposed. */
.overview-group-title {
  font-family: var(--font-display);
  font-size: 1.4rem;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--text);
  margin: 2.5rem 0 0.75rem;
}
.overview-group-title:first-of-type { margin-top: 1.5rem; }

/* Critics: named games as chips, both directions. */
.critic-lead {
  color: var(--text-muted);
  font-size: 0.9rem;
  margin: 0.75rem 0 0.4rem;
}
.critic-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
}
/* Rows, not pills.
   Wrapped pills of differing width read as tags and cannot be scanned --
   and the whole point here is comparing two numbers down a column. */
.critic-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 0.1rem 1.5rem;
}
.critic-chip {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  padding: 0.35rem 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.04);
  color: var(--text);
  text-decoration: none;
  font-size: 0.9rem;
}
.critic-chip:hover { color: var(--gold); }

/* The direction, before the name. A column of arrows is readable at a
   glance in a way "6 vs 92" never was. */
.critic-arrow {
  flex: 0 0 auto;
  font-size: 0.9rem;
  line-height: 1;
}
.critic-chip.is-up .critic-arrow { color: #7ec850; }
.critic-chip.is-down .critic-arrow { color: #c9737b; }

.critic-name {
  flex: 1 1 auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.critic-nums {
  flex: 0 0 auto;
  font-size: 0.82rem;
  color: var(--text-faint);
}
.critic-nums strong { color: var(--text); font-weight: 600; }
.critic-vs { opacity: 0.5; margin: 0 0.15rem; }

/* THE CRITIC CARD (Analysis, new format). Name on top -- wrapping to a
   second line rather than ellipsing, since "SHADOW OF THE COL..." and
   "Thank Goodness You're ..." were the complaint -- and the two scores on
   a line of their own beneath it, where they can always fit. A faint
   card so five across read as five things, edged in the view's colour
   on hover. */
#view-overview .critic-chip {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: 0.45rem;
  row-gap: 0.2rem;
  align-items: start;
  padding: 0.6rem 0.75rem;
  border: 1px solid rgba(255, 255, 255, 0.06);
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.02);
  transition: border-color 0.15s ease, background 0.15s ease;
}
#view-overview .critic-chip:hover {
  color: var(--text);
  border-color: rgba(var(--va-rgb), 0.6);
  background: rgba(var(--va-rgb), 0.06);
}
#view-overview .critic-arrow { grid-row: 1; grid-column: 1; line-height: 1.35; }
#view-overview .critic-name {
  grid-row: 1;
  grid-column: 2;
  white-space: normal;
  overflow: visible;
  text-overflow: clip;
  line-height: 1.35;
  overflow-wrap: anywhere;
}
#view-overview .critic-nums { grid-row: 2; grid-column: 2; }

/* Bounce findings: sentences, not a chart.
   A chart here would invite counting, and the count is the thing this
   section must not be about. */
.bounce-finding {
  color: var(--text-muted);
  line-height: 1.6;
  margin: 0 0 0.5rem;
}
.bounce-finding:last-child { margin-bottom: 0; }

/* Rare achievements: icon plus name, sorted rarest first. */
.rare-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
  gap: 0.5rem;
  margin-top: 0.75rem;
}
.rare-item {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.5rem 0.6rem;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.03);
  color: var(--text);
  text-decoration: none;
}
.rare-item:hover { background: rgba(255, 255, 255, 0.06); }
.rare-icon {
  width: 36px;
  height: 36px;
  border-radius: 6px;
  flex: 0 0 auto;
  object-fit: cover;
}
.rare-icon.is-blank { background: var(--bg-panel); }
.rare-text {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.rare-text strong {
  font-size: 0.88rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.rare-text span {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  color: var(--text-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ---------- Theme picker note ---------- */

/* The accent palette's other rules (.palette-group, -title, -swatches,
   -search) went with the picker on 28 Sep; the note is still the theme
   picker's hint line. */
.palette-group-note {
  font-size: 0.75rem;
  color: var(--text-faint);
  margin: 0.35rem 0 0;
}
/* The theme picker (30 Sep): a search box over the list of finished games,
   in place of a select -- see renderThemePicker. The list is bounded and
   scrolls; the picked row is gold with a tick. */
.theme-game-combo { position: relative; }
.theme-game-search { width: 100%; min-width: 0; }
/* Dropped under the box while it has focus, over whatever is below rather
   than pushing it down -- a dropdown, not a list in the form. */
.theme-game-list {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(100% + 0.25rem);
  z-index: 5;
  display: flex;
  flex-direction: column;
  max-height: 11rem;
  overflow-y: auto;
  border: 1px solid var(--hairline);
  border-radius: 8px;
  background: var(--bg-panel-raised);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
}
.theme-game-list[hidden] { display: none; }
.theme-game-row {
  font: inherit;
  font-size: 0.85rem;
  text-align: left;
  color: var(--text);
  background: none;
  border: 0;
  border-bottom: 1px solid var(--hairline);
  padding: 0.45rem 0.7rem;
  cursor: pointer;
}
.theme-game-row:last-child { border-bottom: 0; }
.theme-game-row:hover { color: var(--gold); }
.theme-game-row.is-current { color: var(--gold); }
.theme-game-row.is-current::before { content: "\2713\00a0"; }
.theme-game-empty { padding: 0.45rem 0.7rem; margin: 0; }

/* ---------- Theme: avatar frame ---------- */

/* .profile-content.has-theme::before lived here: the theme game's art at
   16% behind the page. The theme game's art is the banner now (28 Sep). */

/* The ring. Colour comes from the accent -- the site theme's since 28 Sep
   -- rather than being a second colour decision. */
.profile-avatar-wrap.has-frame {
  box-shadow: 0 0 0 3px var(--accent, var(--gold));
  border-radius: 50%;
}

/* The platinum trophy, and the only unfakeable thing on the profile.
   Sits at the bottom of the ring rather than replacing it -- the ring
   says which game, the badge says how deeply. */
.profile-avatar-wrap.has-platinum {
  box-shadow: 0 0 0 3px #bcd0e6;
}
.profile-avatar-wrap.has-platinum::after {
  content: "🏆";
  position: absolute;
  bottom: -6px;
  left: 50%;
  transform: translateX(-50%);
  font-size: 0.85rem;
  line-height: 1;
  background: var(--bg-panel);
  border-radius: 50%;
  padding: 2px 3px;
}

/* The page's answer, stated before the charts.
   Larger than body text but not a heading -- it is a sentence, and
   setting it as a title would make it look like a section label rather
   than something to read. */
/* .overview-lede and its three companions were deleted with the
   Gameplay Analysis lede they styled. */

/* ---------- Hours by year, hand-entered ---------- */

/* Only appears for games nothing syncs -- Nintendo and pre-sync history.
   Deliberately plain: this is a small correction tool, not a feature to
   show off, and it sits on a page that is already dense. */
.year-hours-block { margin-top: 1.5rem; }

.year-hours-rows {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  margin: 0.6rem 0;
}
.year-hours-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.year-hours-year {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text-muted);
  min-width: 3.5rem;
}
.year-hours-input { width: 6rem; }
.year-hours-unit {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--text-faint);
}
.year-hours-remove {
  background: none;
  border: 0;
  color: var(--text-faint);
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
  padding: 0 0.3rem;
}
.year-hours-remove:hover { color: var(--gold); }

.year-hours-add {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  align-items: center;
}
.year-hours-add input { width: 6rem; }

/* Price, with the date it was read.
   Quiet: it is a fact about a shop, not about the game or about you, and
   it should not compete with anything on this page. */
.detail-price {
  margin: 0.4rem 0 0;
  font-size: 0.82rem;
  color: var(--text-faint);
}
.detail-price[hidden] { display: none; }

/* ---------- Chart explainer notes ---------- */

/* These had NO styling -- one of the classes flagged as unstyled and left
   alone. So every "what this chart means" line rendered as full-strength
   white body text, competing with the chart it was explaining and with
   the section titles above it.
   A note is a note: small, muted, and out of the way. */
.overview-chart-note,
.section-note {
  font-size: 0.8rem;
  line-height: 1.5;
  color: var(--text-faint);
  margin: 0.2rem 0 0.6rem;
  max-width: 62ch;
}

/* The Deck figures on one line, not stacked.
   Three separate rows of "29% of Steam play / 5% of all play / 249.6h on
   Deck" is a list pretending to be a paragraph. */
.overview-summary-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.25rem 1.25rem;
}

/* These three read as a caption for the chart under them, so they centre
   over it. Opt-in, and placed after the last of the five
   `.overview-summary-row` rules -- all at top level, so an earlier one
   would be silently overridden. */
.overview-summary-row.is-centred {
  justify-content: center;
  gap: 0.25rem 2rem;
}

/* ---------- Chart heights ---------- */

/* Set HERE, because maintainAspectRatio is off. Chart.js otherwise sizes
   from the canvas width/height attributes, which meant a 1.25 ratio
   stretched to whatever the container was -- 960px tall on a desktop.

   These are deliberate reading heights, not ratios: a bar chart of six
   years needs about 280px to be legible and gains nothing above it. */
#overview-metric-chart { height: 300px; }
#dev-scatter-chart     { height: 340px; }

/* The Deck chart was the opposite problem -- it sat narrow inside a block
   that had the full width available. Same treatment as everything else in
   the group. */
#deck-block { grid-column: 1 / -1; }
#deck-split-chart { height: 280px; }

/* ---------- Timeline on a phone ---------- */

.gantt-prompt {
  font-size: 0.78rem;
  color: var(--text-faint);
  margin: 0 0 0.4rem;
}
.gantt-prompt[hidden] { display: none; }

/* The tapped game. Reserves its line whether or not anything is tapped,
   so the chart does not jump when the caption appears. */
.gantt-caption {
  min-height: 1.4em;
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--gold);
  margin: 0.4rem 0 0;
}

/* ---------- Dragging on touch ---------- */

/* iOS fires its OWN long-press menu -- copy image, save, share -- before
   Sortable's 120ms delay elapses. So a press-and-hold meant to start a
   drag opened a context menu over the card instead.
   `-webkit-touch-callout` suppresses that menu; `user-select: none` stops
   the text-selection handles appearing on the rank badge; `touch-action`
   tells the browser this element's gestures belong to the page, not to
   scrolling. All three are needed -- each covers a different one of the
   behaviours iOS layers onto a long press. */
.top100-list .top100-grid-item,
.top100-year-list .top100-grid-item,
.top100-unranked-list .top100-grid-item,
.profile-body > [data-section],
.profile-stat {
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}

/* Images are the thing iOS most wants to offer a menu for. */
.top100-grid-cover,
.favorite-card img {
  -webkit-touch-callout: none;
  pointer-events: none;
}

/* The count badge is touch-only -- desktop has hover and the pager.
   Declared with `:not()` rather than as a base rule plus an override:
   appending `display: none` at the end of the file would sit AFTER the
   640px block and hide it on mobile too, which is the exact duplicate-
   media-query trap this stylesheet has hit four times tonight. */
@media (pointer: fine) {
  .calendar-day-count { display: none; }
}

/* ---------- Play Next row spacing ---------- */

/* The gap under a row title was DOUBLED.
   `.section-head` adds 0.9rem below itself, and `.card-row-title` adds
   another 0.75rem inside it -- so "Because you loved…" sat 26px above its
   covers when the intent was 14px. The title's own margin is only needed
   when it is NOT wrapped in a section-head.
   Proximity is the whole job of a heading; twice the gap makes it read as
   floating between two rows rather than belonging to the one below. */
.section-head .card-row-title { margin-bottom: 0; }

/* And less air between rows. 2.5rem was set when the page had a title
   block and fewer rows; with four rows it is most of a screen spent on
   nothing. */
.card-row-block { margin-bottom: 1.6rem; }

/* ---------- Game detail: cover size and the IGDB row ---------- */

/* The IGDB match row is EDIT-MODE ONLY.
   `profile-edit-control` is scoped to `.profile-content.is-editing`, so
   it needed the detail page's own editing class too -- one class doing
   the job on both pages rather than a second mechanism. */
/* Hidden by default and revealed in edit mode -- stated as a PAIR here.
   `.igdb-match-row { display: flex }` at line ~2460 is a single class and
   sits later than `.profile-edit-control { display: none }`, so it won
   and the row showed in both modes. Adding only the reveal rule was not
   enough; the hide has to be specific enough to beat the base rule too.
   Fifth time this stylesheet's ordering has decided an outcome -- see the
   consolidation item in BACKLOG. */
#view-game-detail .igdb-match-row { display: none; }
#view-game-detail.is-editing .igdb-match-row { display: flex; }

/* ---------- Rating and review ---------- */

.game-detail-review-label[hidden] { display: none; }
.review-add-btn[hidden] { display: none; }

/* An invitation, not a form. Sits where the textarea would, so pressing
   it expands in place rather than moving the page. */
.review-add-btn {
  align-self: flex-start;
  margin-top: 0.4rem;
}

/* ---------- Music picker ---------- */

/* YouTube is the primary path, Spotify is behind a disclosure.
   Not a slight on Spotify -- a Spotify embed plays 30 seconds for anyone
   without Premium open on a desktop browser, so on a shared profile it is
   the option that mostly does not work. */
.music-source-label {
  display: block;
  font-size: 0.85rem;
  color: var(--text-muted);
  margin-bottom: 0.4rem;
}
.music-source-label input {
  display: block;
  width: 100%;
  margin-top: 0.25rem;
}
.music-actions { margin-bottom: 0.4rem; }

.music-source-note {
  font-size: 0.75rem;
  color: var(--text-faint);
  margin: 0 0 0.75rem;
  line-height: 1.45;
}

.music-spotify-details summary {
  font-size: 0.8rem;
  color: var(--text-muted);
  cursor: pointer;
  padding: 0.3rem 0;
}
.music-spotify-details summary:hover { color: var(--gold); }
.music-spotify-details input { width: 100%; margin-top: 0.4rem; }

/* ---------- Achievement showcase ---------- */

/* Visible to a screen reader, not on screen. The empty slots show a "+"
   and nothing else, which tells a sighted person everything and a
   screen-reader user nothing at all -- "+" is not a label. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* TEN SLOTS, TWO ROWS OF FIVE, and fixed at five rather than auto-fill.
   The row this replaced used `auto-fill` so a narrow section could be
   three across and a wide one ten -- right for a list whose length was
   whatever recency produced. A showcase is exactly ten, chosen, and the
   arrangement is part of what was chosen: five and five is a shape, and
   "however many fit" is not. */
.achievement-showcase {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 0.5rem;
  margin-top: 0.5rem;
  max-width: 560px;
}

.showcase-slot {
  position: relative;
  aspect-ratio: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 1px solid var(--hairline);
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.03);
  cursor: pointer;
  overflow: hidden;
}
.showcase-slot img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* An empty slot is an INVITATION, so it reads as an action rather than
   as a gap: dashed, and it lights up on hover the way a button does. A
   solid empty box would just look like a missing icon. */
.showcase-slot.is-empty {
  border-style: dashed;
  background: rgba(255, 255, 255, 0.015);
}
.showcase-slot.is-empty:hover {
  border-color: var(--gold-soft);
  background: rgba(var(--act-rgb), 0.08);
}
.showcase-plus {
  font-size: 1.5rem;
  line-height: 1;
  color: var(--text-faint);
}
.showcase-slot.is-empty:hover .showcase-plus { color: var(--gold); }

/* The remove cross only appears on hover: ten permanent crosses over ten
   pieces of art is a toolbar, and the art is the point. */
.showcase-remove {
  position: absolute;
  top: 2px;
  right: 4px;
  font-size: 0.9rem;
  line-height: 1;
  padding: 0 0.25rem;
  border-radius: 4px;
  color: var(--text);
  background: rgba(0, 0, 0, 0.55);
  opacity: 0;
}
.showcase-slot:hover .showcase-remove { opacity: 1; }
.showcase-remove:hover { color: var(--gold); }

/* ---------- Achievement picker ---------- */

.showcase-picker {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.5rem;
  background: rgba(10, 7, 16, 0.72);
}
.showcase-picker[hidden] { display: none; }

.showcase-picker-panel {
  width: min(560px, 100%);
  /* Capped and scrolled INSIDE, not on the page: a list of a few
     thousand achievements would otherwise make the backdrop scroll and
     the panel wander off the top. */
  max-height: min(70vh, 640px);
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  padding: 1rem;
  border: 1px solid var(--hairline);
  border-radius: 14px;
  background: var(--bg-panel);
}
.showcase-picker-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}
.showcase-picker-head h3 {
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.1rem;
  text-transform: uppercase;
  letter-spacing: 0.02em;
}
#showcase-picker-search,
#top5-picker-search {
  width: 100%;
  padding: 0.55rem 0.7rem;
  border-radius: 10px;
  border: 1px solid var(--hairline);
  background: rgba(255, 255, 255, 0.04);
  color: var(--text);
  font: inherit;
  font-size: 0.9rem;
}
.showcase-picker-note {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}
.showcase-picker-results {
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
}

.showcase-option {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.4rem 0.5rem;
  border: 0;
  border-radius: 8px;
  background: none;
  color: var(--text);
  text-align: left;
  cursor: pointer;
}
.showcase-option:hover { background: rgba(255, 255, 255, 0.05); }
.showcase-option img,
.showcase-option .recent-achievement-blank {
  width: 34px;
  height: 34px;
  border-radius: 6px;
  object-fit: cover;
  flex: 0 0 auto;
}
.showcase-option-text { display: flex; flex-direction: column; min-width: 0; }
.showcase-option-name {
  font-size: 0.85rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.showcase-option-game { font-size: 0.72rem; color: var(--text-faint); }

/* Already chosen: shown rather than filtered out, so the list does not
   reshuffle under you as you pick, and so you can see why a trophy you
   were looking for is not offered. */
.showcase-option.is-taken { opacity: 0.45; cursor: default; }
.showcase-option.is-taken:hover { background: none; }
.showcase-option-tag {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 0.58rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

/* ---------- Music picker: a game, then a track from it ---------- */

/* Which game the track came from. The rule is that profile music is
   video game music, and this line is the only place that is VISIBLE --
   a constraint nobody can see is indistinguishable from no constraint. */
.music-from {
  margin: 0.4rem 0 0;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}
.music-from[hidden] { display: none; }

.music-step-back {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0 0 0.5rem;
  font-size: 0.8rem;
  color: var(--text-muted);
}

/* Capped and scrolled INSIDE the panel. The game list is bounded at 12
   but the Spotify results are not the kind of thing to let grow a
   sidebar panel to twice the height of the page. */
.music-results {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  max-height: 260px;
  overflow-y: auto;
  margin-top: 0.4rem;
}

.music-result {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  padding: 0.35rem 0.4rem;
  border: 0;
  border-radius: 8px;
  background: none;
  color: var(--text);
  text-align: left;
  cursor: pointer;
}
.music-result:hover { background: rgba(255, 255, 255, 0.06); }

/* Square, because this holds two different shapes: a 2:3 game cover and
   a 1:1 album image. `object-fit: cover` on a fixed square means neither
   arrives stretched, and a column of mixed aspect ratios is harder to
   scan than a slight crop. */
.music-result-art {
  width: 34px;
  height: 34px;
  border-radius: 5px;
  object-fit: cover;
  flex: 0 0 auto;
  background: rgba(255, 255, 255, 0.06);
}
.music-result-text { display: flex; flex-direction: column; min-width: 0; }
.music-result-name {
  font-size: 0.82rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.music-result-sub { font-size: 0.7rem; color: var(--text-faint); }

/* ---------------------------------------------------------------------
   Top 5
   ---------------------------------------------------------------------
   The one page here meant to be looked at rather than used. Five games,
   cover on the left, your own words on the right.

   RESTRAINT IS THE BRIEF. PRODUCT_OBJECTIVES splits the app in two: the
   profile is mostly cover art so its UI stays quiet and lets the artwork
   carry it; the game detail page is text-heavy and can take stronger
   treatment. This page is both at once -- a large cover AND a paragraph
   of prose -- so it takes the quieter half of that rule. No panel, no
   border, no card chrome. The art and the writing are the design.

   The rank is the only ornament, and it is sized to be read at a glance
   and then ignored. */
/* THE STRIP: FIVE ACROSS, ALWAYS ON ONE LINE.
   The whole reason this shape exists is seeing all five at once, so the
   columns are fractional and the tiles shrink rather than wrap. Five
   equal columns rather than auto-fit: a wrapped fourth tile would be the
   scroll this replaced, just shorter. */
.top5-strip {
  list-style: none;
  margin: 2rem 0 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 0.9rem;
  align-items: start;
  /* Capped so the covers stay cover-sized on a wide monitor instead of
     becoming five posters. The detail below gets the width instead. */
  max-width: 760px;
}

.top5-tile {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

.top5-tile-btn {
  display: block;
  width: 100%;
  padding: 0;
  border: 0;
  border-radius: 8px;
  background: none;
  cursor: pointer;
  /* The selected tile is marked with a ring rather than a scale or a
     border: a border changes the tile's size and shifts the other four.
     box-shadow draws outside the box without affecting layout, and
     unlike outline it can be soft, which is the whole point below. */
  transition: box-shadow 120ms ease;
}
.top5-tile-btn img,
.top5-tile-blank {
  display: block;
  width: 100%;
  aspect-ratio: 3 / 4;
  object-fit: cover;
  border-radius: 8px;
  /* Lifted off the background rather than outlined, same as the big
     cover below -- the shadow is what makes a cover read as an object. */
  box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45);
}
.top5-tile-blank {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0.4rem;
  background: var(--bg-panel-raised);
  color: var(--text-dim);
  font-size: 0.7rem;
  text-align: center;
}
/* NOTHING IS DIMMED. The five covers are the content of this page and
   four of them greyed out is the page hiding most of itself to point at
   one thing -- which looked defensible against placeholder rectangles
   and looked wrong the moment real art was in the strip.
   So the selection is additive instead: a soft ring on the one you
   clicked, and the other four are left alone. The ring is translucent
   and offset by a hairline rather than a hard accent outline, because
   the cover underneath is doing the work and this only has to say which
   one is open. */
.top5-tile.is-selected .top5-tile-btn {
  box-shadow:
    0 0 0 2px rgba(255, 255, 255, 0.14),
    0 0 0 3px color-mix(in srgb, var(--accent, #fff) 55%, transparent),
    0 10px 26px rgba(0, 0, 0, 0.5);
}
/* Discoverability only -- a hint that a tile is clickable at all. It has
   to stay below the selected ring or hovering would look like selecting. */
.top5-tile-btn:hover {
  box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.16), 0 10px 26px rgba(0, 0, 0, 0.5);
}
.top5-tile.is-selected .top5-tile-btn:hover {
  box-shadow:
    0 0 0 2px rgba(255, 255, 255, 0.14),
    0 0 0 3px color-mix(in srgb, var(--accent, #fff) 55%, transparent),
    0 10px 26px rgba(0, 0, 0, 0.5);
}

.top5-tile-rank {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  color: var(--text-faint);
  text-align: center;
}

/* THE OPEN ENTRY. This is the grid the five stacked rows used to share --
   cover column fixed rather than fractional, because a fractional one
   shrinks on a laptop to the point where the art stops being art. */
.top5-detail {
  margin-top: 2.25rem;
  display: grid;
  grid-template-columns: 168px minmax(0, 1fr);
  gap: 1.5rem;
  align-items: start;
}
.top5-detail:empty { display: none; }

.top5-cover img,
.top5-cover-blank {
  width: 168px;
  aspect-ratio: 3 / 4;
  object-fit: cover;
  border-radius: 10px;
  display: block;
  /* Lifted off the background rather than outlined. A border would draw
     a box around every entry and turn a page into a table. */
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
}

.top5-cover-blank {
  background: var(--bg-panel-raised);
}

/* AN EMPTY SLOT. #87.
   Dashed rather than solid, and flat rather than lifted: the drop shadow
   on a real cover is what makes it read as an object, so the absence of
   one is most of what makes this read as a gap waiting to be filled.
   It shares the cover's width rules above, so it tracks all three
   responsive sizes without repeating them. */
.top5-slot-btn {
  width: 100%;
  aspect-ratio: 3 / 4;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 2px dashed var(--hairline);
  border-radius: 10px;
  background: none;
  color: var(--text-faint);
  cursor: pointer;
  transition: border-color 120ms ease, color 120ms ease;
}
.top5-slot-btn:hover,
.top5-slot-btn:focus-visible {
  border-color: var(--accent, var(--text-dim));
  color: var(--text);
}
.top5-slot-plus {
  font-size: 2.5rem;
  line-height: 1;
  font-weight: 300;
}
/* The empty rows are furniture, not content -- they should not compete
   with the games above them for attention. */
.top5-slot-plus { font-size: 1.8rem; }

.top5-picker-cover {
  width: 34px;
  aspect-ratio: 3 / 4;
  object-fit: cover;
  border-radius: 4px;
  flex-shrink: 0;
}
.top5-picker-cover-blank {
  display: block;
  background: var(--bg-panel-raised);
}
.top5-picker-empty {
  margin: 0;
  padding: 1rem 0.5rem;
  color: var(--text-faint);
  font-size: 0.9rem;
}

.top5-head {
  display: flex;
  align-items: baseline;
  gap: 0.65rem;
  margin-bottom: 0.7rem;
}

/* Big, but faint. Large enough to establish the order at a glance,
   quiet enough that the eye goes to the title first. */
.top5-rank {
  font-size: 2.1rem;
  font-weight: 700;
  line-height: 1;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

.top5-name {
  margin: 0;
  font-size: 1.3rem;
  line-height: 1.2;
  color: var(--text);
}

/* UNDER THE COVER, AND BIG ENOUGH TO READ AS A RATING.
   Beside the title it was a small gold number you had to decode -- it
   could as easily have been a year or an hour count. Here it has the
   cover's width to itself, sets large, and states its own scale. */
/* Set for READING, which is the one thing this page asks of you. A
   measure cap matters more than the container width: 1,134 characters
   across a full-width desktop column is a wall, and without this cap the
   review ran to 110 characters a line once the score column came out. */
.top5-review {
  margin: 0;
  max-width: 78ch;
  color: var(--text-muted);
  font-size: 0.95rem;
  line-height: 1.55;
}

/* The Add review button on rows with nothing written. Inline-block so it
   sizes to its text rather than stretching the column -- a full-width
   yellow bar where a paragraph should be would shout louder than any of
   the writing on the page. */
.top5-add-review {
  display: inline-block;
  text-decoration: none;
}

/* A game with no review is not a gap to be filled. It shows its cover and
   its name and stops -- no placeholder, no prompt, no italic apology.
   Nothing collapses because the row is grid-aligned to the cover, which
   is always there. */

.top5-empty {
  color: var(--text-faint);
  padding: 2rem 0;
  /* SPANS BOTH COLUMNS. The detail is a two-column grid and column one is
     the fixed cover width -- without this the empty line renders inside
     168px and wraps after three words. */
  grid-column: 1 / -1;
}

/* A game in your top five with nothing written about it is the one case
   where the blank is worth saying out loud -- the review IS the content
   here, so an empty right-hand column would read as a rendering fault.
   Italic and faint so it is clearly a placeholder, not a review. */
/* The `.top5-edit-action(s)` rules were here, styling the Replace and
   Remove text links on the open entry. Both moved onto the tile as one
   hover `x`, so the rules went with them. */

/* On hover only. The note it replaces argued that "five covers with an x
   floating on each" looks like a page about deletion -- true of one
   always drawn. At rest the strip is five covers. */
.top5-tile-remove {
  position: absolute;
  top: 0.3rem;
  right: 0.3rem;
  z-index: 2;

  /* 1.15rem, from 1.5 (Luke, 23 Sep: "the x looks horrible"). At 1.5rem
     with a 1rem glyph it was a black disc a quarter the width of each
     cover. Smaller, and the glyph thinner and quieter, so it reads as a
     control on the cover rather than a sticker over it. */
  width: 1.15rem;
  height: 1.15rem;
  padding: 0;
  border: 0;
  border-radius: 50%;
  /* Dark rather than accent-coloured, and opaque rather than tinted: it
     sits on cover art, which can be any colour, and a translucent chip
     over a bright cover is unreadable. */
  background: rgba(10, 7, 14, 0.78);
  color: var(--text-muted);
  font-size: 0.72rem;
  line-height: 1;
  cursor: pointer;

  opacity: 0;
  /* `visibility` is what keeps it out of the pointer's way while hidden;
     opacity alone leaves an invisible button swallowing clicks over the
     corner of the cover. */
  visibility: hidden;
  transition: opacity 0.12s ease, visibility 0.12s, background 0.12s ease;
}

/* THE HOVER HALF OF THIS IS IN THE @media (hover: hover) BLOCK (#144,
   1 Oct). Here, unguarded, it cost a phone a tap: iOS shows a :hover
   state on the first touch, and when that state makes something VISIBLE
   that was not, it keeps the tap for the hover and sends no click. So the
   first tap on a cover drew its x and the second one selected it. Read
   from the rule, not reproduced -- it needs a real iPhone. The (hover:
   none) block below already puts the x on the selected tile, which is all
   a touch screen needs. */
.top5-tile-remove:focus-visible {
  opacity: 1;
  visibility: visible;
}

.top5-tile-remove:hover {
  background: rgba(10, 7, 14, 0.95);
}

@media (hover: none) {
  /* No hover on a touch screen, so it would be unreachable -- the same
   trap the info tips and the top-five x have, solved the same way. */

  .top100-rate-btn {
    opacity: 1;
    visibility: visible;
  }

  /* KEYBOARD AND TOUCH BOTH NEED IT REACHABLE -- ON THE SELECTED TILE.
   There is no hover on a touch screen, so the x would be unreachable. It
   was simply always there, on all five, which is exactly the "five
   covers with an x floating on each" the hover rule above was written to
   avoid (Luke, 23 Sep). A tap on a cover already selects it and opens its
   entry, so the x appears on the one you have selected: one tap to pick,
   one to remove. */

  .top5-tile.is-selected .top5-tile-remove {
    opacity: 1;
    visibility: visible;
  }
}

.top5-review-empty {
  color: var(--text-faint);
  font-style: italic;
}

/* WIDE DESKTOP GETS A BIGGER COVER, not a longer line.
   Past about 1100px the art is the thing with room to grow: the measure
   is already at its useful limit, and a line much beyond ~78ch makes the
   eye lose its place returning to the left margin. So the extra width
   goes to the cover and to the gap between them. */
@media (min-width: 1100px) {
  .top5-detail {
    grid-template-columns: 200px minmax(0, 1fr);
    gap: 1.9rem;
  }
  .top5-cover img,
  .top5-cover-blank { width: 200px; }
  .top5-name { font-size: 1.45rem; }
  .top5-rank { font-size: 2.4rem; }
}

@media (max-width: 720px) {
  /* The edit panel: one column, in reading order, and a run's platform and
     label on lines of their own (28 Sep). */
  .game-detail-fields.is-editing {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas: "head" "record" "dates" "runs";
  }
  /* A RUN, ONE FIELD PER LINE (#156, 4 Oct). It was two columns and a
     2rem third: Started, Ended and Hours forced onto one row, Hours one
     digit wide, the dates cut off ("12/29/2"), and the remove button a
     half-width unlabelled slab on a row of its own. Now the platform
     takes the first line with the remove button (or a synced run's tag)
     as a small control at its end, and every field after it has a line
     to itself at the panel's full width. A run is taller and nothing in
     it is clipped. */
  #detail-runs-list .playthrough-row {
    grid-template-columns: minmax(0, 1fr) auto;
    row-gap: 0.45rem;
    align-items: center;
  }
  #detail-runs-list .pt-platform { grid-column: 1; grid-row: 1; }
  #detail-runs-list .pt-remove,
  #detail-runs-list .pt-locked { grid-column: 2; grid-row: 1; }
  #detail-runs-list .pt-remove { width: 2.1rem; padding: 0; }
  #detail-runs-list .pt-cell { grid-column: 1 / -1; }
  #detail-runs-list .pt-cell input { width: 100%; box-sizing: border-box; }
  /* Finished: the tick, then its date and its year, a line each. */
  #detail-runs-list .pt-finish { grid-template-columns: minmax(0, 1fr); }
  #detail-runs-list .pt-finish .pt-done-label { height: auto; padding: 0.15rem 0; }
  /* A space between runs, now that each is several lines deep. */
  #detail-runs-list { gap: 0.9rem; }

  /* DATE BOXES THE WIDTH OF EVERY OTHER BOX (#156). On an iPhone a date
     input is drawn by the system: as wide as its own contents want, its
     text centred, and it does not shrink -- so Released (the one date
     here that is not disabled) was wider than the fields around it with
     its text in the middle, and Started and Completed ran past the
     card's right edge. With the system's drawing switched off it is a
     box like the others: the panel's width, text at the left. Tapping it
     still opens the system's date picker. */
  .game-detail-fields.is-editing input[type="date"] {
    -webkit-appearance: none;
    appearance: none;
    display: block;
    width: 100%;
    min-width: 0;
    max-width: 100%;
    box-sizing: border-box;
    text-align: left;
  }
  .game-detail-fields.is-editing input[type="date"]::-webkit-date-and-time-value { text-align: left; }

  .donut-and-list { grid-template-columns: 1fr; }

  .favorites-grid { grid-template-columns: repeat(2, minmax(0, 90px)); }

  /* Tablet and small laptop: side by side still works, cover shrinks. */

  .top5-detail {
    grid-template-columns: 120px minmax(0, 1fr);
    gap: 1rem;
  }
  .top5-strip { gap: 0.6rem; }
  .top5-cover img,
  .top5-cover-blank { width: 120px; }
  .top5-rank { font-size: 1.8rem; }
  .top5-name { font-size: 1.15rem; }

  /* A CARD ON A PHONE: two fields to a line -- platform and hours, started
     and ended, finished and year -- rather than seven across. */
  #detail-runs-list .playthrough-row.pt-card { display: block; }
  .pt-card .pt-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); row-gap: 0.5rem; }
  .pt-card .pt-arrow { display: none; }
  .pt-card .pt-grid > :nth-child(1) { order: 1; }
  .pt-card .pt-grid > :nth-child(5) { order: 2; }
  .pt-card .pt-grid > :nth-child(2) { order: 3; }
  .pt-card .pt-grid > :nth-child(4) { order: 4; }
  .pt-card .pt-grid > :nth-child(6) { order: 5; }
  .pt-card .pt-grid > :nth-child(7) { order: 6; }
  /* A date box at a phone's font size does not fit half the card: "03/18/201".
     Started and Ended take a line each. */
  .pt-card .pt-grid > .pt-cell-date { grid-column: 1 / -1; }
  .pt-card .pt-label { flex: 1 1 auto; }
}

/* PHONE: THE TITLE ON TOP, THE COVER AND THE REVIEW SIDE BY SIDE UNDER
   IT (#144, 1 Oct).

   It was stacked: the cover on a row of its own (104px of art beside
   247px of nothing), the name under it, the review full width under
   that -- 669px for The Witcher 3, its foot under the bottom bar. Stacked
   BECAUSE side by side did not fit: measured at 375px, a 120px cover and
   the card's desktop padding left the words a 139px column, about
   eighteen characters a line.

   Side by side fits once the card is cut for the column. The cover is
   104px; the card's padding, quote mark and type come down a size, which
   leaves the words 186px, about 27 characters -- short, and readable,
   because the card is also cut at nine lines with the whole review one
   tap away. The entry is 225px, so the five covers, the title and the
   start of the review are on one screen. Chosen on the real page from
   five: as it was with the name beside the cover, this, the title inside
   the right-hand column (a long name wraps there and pushes the card
   down; here it has the full width, and the cover and the card start on
   the same line), no card around the words, and a smaller cover.

   The body's wrapper steps out of the layout (display: contents) so its
   three parts place themselves on this grid. */
@media (max-width: 560px) {
  .top5-detail {
    grid-template-columns: 104px minmax(0, 1fr);
    column-gap: 0.9rem;
    row-gap: 0.6rem;
    align-items: start;
  }
  .top5-body { display: contents; }
  .top5-head { grid-column: 1 / -1; grid-row: 1; margin-bottom: 0; }
  .top5-name { font-size: 1.05rem; }
  .top5-cover { grid-column: 1; grid-row: 2; }
  .top5-review-wrap,
  .top5-review-empty { grid-column: 2; grid-row: 2; }
  .top5-actions { grid-column: 1 / -1; grid-row: 3; margin-top: 0; }
  /* The card, a size down for the column: padding, type, the quote mark
     and the pencil. */
  #view-top100 .top5-review-wrap .top5-review {
    padding: 0.7rem 0.75rem 0.8rem 2.05rem;
    font-size: 0.86rem;
    line-height: 1.5;
    /* A LONG REVIEW IS CUT AT NINE LINES, with the game page's fade and
       its "Read the full review" pill on the card's foot
       (markTop5ReviewCut shows the pill only when something is cut).
       Desktop is not capped. */
    max-height: calc(1.5em * 9 + 0.7rem);
    overflow: hidden;
  }
  #view-top100 .top5-review::before { font-size: 1.9rem; left: 0.5rem; top: 0.35rem; }
  .top5-review-wrap .review-edit-btn { width: 1.6rem; height: 1.6rem; top: 0.35rem; right: 0.35rem; }
  #view-top100 .top5-review.is-cut {
    -webkit-mask-image: linear-gradient(180deg, #000 68%, transparent);
    mask-image: linear-gradient(180deg, #000 68%, transparent);
  }
  .top5-review-wrap .top5-review-more:not([hidden]) {
    display: flex;
    margin: -2.2rem auto 0;
    font-size: 0.68rem;
    padding: 0.3rem 0.6rem;
  }
  /* The strip stays five across even here. It is the one thing on the
     page that must not wrap -- see .top5-strip. At 375px that is roughly
     a 58px cover, which is small but still legible as art, and the entry
     below is where you actually look at a game. */
  .top5-strip { gap: 0.4rem; }
  .top5-cover img,
  .top5-cover-blank { width: 104px; }
  /* Left-aligned under a small cover rather than centred, so it lines up
     with the title below it instead of floating mid-column. */
  .top5-review { max-width: none; }
}

/* ---------------------------------------------------------------------
   Gaming notes
   ---------------------------------------------------------------------
   A header button and a two-step popover: pick a game, then write.
   Geometry copied from .add-game-popover on purpose -- this is the third
   popover anchored to this header and a third set of numbers would make
   them visibly not-quite-siblings. */
.note-wrap {
  position: relative;
}

/* + NOTE, FLOATING. #87.
   Fixed to the viewport, because nothing here is sticky -- the banner
   and tabs scroll away, so a button in either is only reachable at the
   top of a page, and the point is "jot one down mid-session".
   Bottom right: the art grid, the library table and the ranking strips
   all run left-aligned and stop short of that edge. */
/* THE `.note-fab` RULES THAT WERE HERE ARE GONE.
   + Note was a fixed button in the page gutter, positioned off `main`'s
   1240px edge and a measured 12.5rem from the top, with its own phone rule
   to clear the bottom bar. All of it existed because the nav scrolled away
   and a control in the nav would have been unreachable mid-page. The nav
   docks now, so Notes is a tab and the arithmetic goes with it. */

/* Hidden when a panel is open on top of the page. The edit-profile
   drawer slides in from the right, exactly where this lives, and it sat
   across the Edit name row. Every full-screen and side surface is
   listed. `:has()` is used five times in this file already. */
body:has(#profile-edit-panel:not([hidden])) .note-wrap .note-popover,
body:has(#cleanup-overlay:not([hidden])) .note-wrap .note-popover,
body:has(#wrapped-overlay:not([hidden])) .note-wrap .note-popover,
body:has(#showcase-picker:not([hidden])) .note-wrap .note-popover,
body:has(#top5-picker:not([hidden])) .note-wrap .note-popover {
  display: none;
}

.note-popover {
  position: absolute;
  top: calc(100% + 0.5rem);
  right: 0;
  z-index: 30;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 0.9rem;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
  /* (The writing shape is RESIZABLE by its corner -- `resize` needs
     `overflow` other than visible, which the rule below supplies. The
     size you drag to is remembered; see rememberNoteSize.) */
  /* TWO SHAPES. Choosing a game: compact, its natural height -- a search
     and a short list. Writing (.is-writing, set by openNoteWriteStep): the
     roomy ~760 x 480 Luke asked for, resizable by its corner. The big shape
     on the picker was "not what I mean": a small search in a large empty
     box. */
  width: min(92vw, 440px);
  min-width: min(92vw, 320px);
  max-width: 94vw;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.note-popover-title {
  margin: 0;
  font-weight: 600;
  color: var(--text);
}

.note-suggestions-label,
.note-state {
  margin: 0;
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

.note-state {
  text-transform: none;
  letter-spacing: 0;
  font-size: 0.8rem;
}

.note-suggestions,
.note-results {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
}

/* The popover itself is bounded too. The scroll region above caps the
   list, but the write step has a textarea and a growing list of existing
   notes, and on a short window that overflowed the same way. */
.note-popover {
  /* 88vh, not min(560px, 80vh): a popover you can drag bigger should be
     allowed to BE bigger. */
  max-height: 88vh;
  overflow: auto;
}
.note-popover.is-writing {
  width: min(92vw, 760px);
  height: min(82vh, 480px);
  min-height: 220px;
  resize: both;
}

/* The writing step fills whatever size the popover is dragged to: the
   step is a column that grows, and the textarea takes the slack -- so
   dragging the corner gives you more room to WRITE, not empty panel. */
#note-write-step:not([hidden]) {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  min-height: 0;
}
#note-write-step #note-body { flex: 1 1 auto; }
/* Same wrapper inside the note window: "Search for a game" sat at 202px
   in a 489px step, because its span.search-wrap sized to content. */
#note-pick-step > .search-wrap { display: flex; width: 100%; }
#note-pick-step > .search-wrap > input { flex: 1 1 auto; }

/* THE FLOATING WINDOW'S TITLE BAR: what you drag it by, with the close.
   Sticky, so it stays reachable when the window's content scrolls. It
   spans the popover's full width by cancelling the popover's padding. */
.note-float-head {
  position: sticky;
  top: -0.9rem;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 0.55rem;
  margin: -0.9rem -0.9rem 0.1rem;
  padding: 0.5rem 0.55rem 0.5rem 0.8rem;
  background: var(--bg-panel-raised);
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
  cursor: grab;
  user-select: none;
  touch-action: none;
}
.note-float-head:active { cursor: grabbing; }
/* Six dots: the universal "you can drag this". */
.note-float-grip {
  width: 10px;
  height: 14px;
  background-image: radial-gradient(circle, var(--text-faint) 1.2px, transparent 1.4px);
  background-size: 5px 5px;
  flex: none;
}
.note-float-label {
  flex: 1;
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.note-float-close {
  flex: none;
  width: 28px;
  height: 28px;
  border: none;
  border-radius: 8px;
  background: none;
  color: var(--text-muted);
  font-size: 1.2rem;
  line-height: 1;
  cursor: pointer;
}
.note-float-close:hover { color: var(--text); background: rgba(255, 255, 255, 0.08); }

/* ONE SCROLL REGION, CAPPED, holding both the suggestions and the
   results. Neither list had a cap: eight currently-playing rows pushed
   the search input to the bottom edge of the popover, and since results
   render below the input, every match landed off-screen. The popover had
   no height limit either, so it simply ran past the viewport with nothing
   to scroll.

   Capped in `vh` rather than pixels so a short window gets a short list
   rather than a popover taller than the screen. */
.note-scroll {
  max-height: min(340px, 45vh);
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
}

.note-game-row {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  width: 100%;
  padding: 0.35rem 0.4rem;
  background: transparent;
  border: 0;
  border-radius: 6px;
  color: var(--text);
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.note-game-row:hover,
.note-game-row:focus-visible {
  background: var(--bg-panel-raised);
}

.note-game-row img,
.note-game-blank {
  width: 26px;
  height: 35px;
  object-fit: cover;
  border-radius: 3px;
  flex: none;
  background: var(--bg-panel-raised);
}

.note-game-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.note-popover input,
.note-popover textarea {
  font-family: inherit;
  /* 0.95rem, up from 0.85, with a taller field: the search at the top of
     the popover read as a small box in a big panel. */
  font-size: 0.95rem;
  width: 100%;
  padding: 0.6rem 0.75rem;
  background: var(--bg);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  color: var(--text);
}

.note-popover textarea {
  /* The popover resizes now, and the textarea fills it -- a second resize
     handle inside the first would fight it. */
  resize: none;
  min-height: 8rem;
  line-height: 1.45;
}

.note-back {
  align-self: flex-start;
  background: none;
  border: 0;
  padding: 0;
  color: var(--text-faint);
  font: inherit;
  font-size: 0.8rem;
  cursor: pointer;
}

.note-back:hover {
  color: var(--text-muted);
}

/* What you already wrote, above the box you are writing in. Capped and
   scrollable for the same reason the results list is. */
.note-existing {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  max-height: 140px;
  overflow-y: auto;
}
/* THE LIST KEEPS ITS OWN SCROLL. Without this, reaching the top or bottom
   of your earlier notes handed the rest of the swipe to the page behind
   the editor -- "when I try to scroll previous notes it scrolls the
   background instead" (Luke, 23 Sep, on a phone). `contain` stops the
   chain at the list; the editor itself gets the same below. */
.note-existing { overscroll-behavior: contain; }
.note-popover { overscroll-behavior: contain; }

/* In the writing window the list is what you are reading while you
   write, and 140px showed three notes of a 480px window. A third of the
   window's height, so the box you type in keeps the rest. */
.note-popover.is-writing .note-existing { max-height: 33%; min-height: 140px; }

.note-existing-item {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  margin: 0;
  font-size: 0.82rem;
  line-height: 1.45;
  color: var(--text-muted);
}

.note-existing-text {
  margin: 0;
  flex: 1;
  min-width: 0;
}

/* ALWAYS VISIBLE, not revealed on hover. A hover affordance inside a
   popover is unreachable on a touch screen, and this is a header control
   people will use on a phone mid-session. Kept quiet instead: they sit at
   --text-faint until you point at them. */
.note-item-actions {
  display: flex;
  gap: 0.4rem;
  flex: none;
}

.note-item-btn {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  font-size: 0.72rem;
  color: var(--text-faint);
  cursor: pointer;
}

.note-item-btn:hover,
.note-item-btn:focus-visible {
  color: var(--text-muted);
}

/* The confirm step only. The Delete that merely ASKS is a plain control
   like any other -- colouring both the same would make the reversible one
   look as final as the one that is not. */
.note-item-btn.is-danger {
  color: #e2707a;
}

.note-item-btn.is-danger:hover {
  color: #f08a92;
}

.note-existing-item.is-editing,
.note-existing-item.is-confirming {
  align-items: center;
}

.note-edit-box {
  flex: 1;
  min-width: 0;
  font-family: inherit;
  font-size: 0.82rem;
  line-height: 1.45;
  padding: 0.35rem 0.5rem;
  background: var(--bg);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  color: var(--text);
  resize: vertical;
}

.note-actions {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}
/* "View all notes" beside Save (#169): the picker's link, without the rule
   and the margin that set it off as the picker's last line. */
.note-actions .note-viewall {
  border-top: 0;
  margin-top: 0;
  padding: 0.3rem 0.1rem;
}
/* The sheets' tabs, the line a game with no notes shows under Notes, and
   the review sheet's own notes list: phone-only, below. */
.sheet-tabs,
.note-existing-empty,
.rate-notes { display: none; }

/* A matching note in the picker: the game it belongs to, and enough of the
   note to recognise it by. Two lines, so the row is taller than a plain
   game row -- which is the point, since they are different kinds of
   answer and should not look interchangeable. */
.note-match-row {
  align-items: flex-start;
}

.note-match-text {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  min-width: 0;
}

.note-match-body {
  font-size: 0.78rem;
  line-height: 1.35;
  color: var(--text-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The matched phrase. Underlined rather than highlighted with a block of
   colour -- a filled swatch inside a popover full of small text reads as
   an error state. */
.note-match-body mark {
  background: none;
  color: var(--text);
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* The Notes page's search matches (highlightAll). A tint, not the
   popover's underline: here the note is full-size prose you scan for the
   word, and a soft amber wash finds the eye across a paragraph where an
   underline would read as a link. Translucent, so the text keeps its own
   colour and stays readable. */
.notes-entry-body mark {
  background: rgba(var(--va-rgb, var(--act-rgb)), 0.28);
  color: var(--text);
  border-radius: 3px;
  padding: 0 0.1em;
}

/* Ownership, one control per platform. #99 step 2.
   A game can be wishlisted on Steam and owned on PlayStation, so the
   editor asks once per place rather than once per game. */
.owned-per-platform {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

.owned-platform-row {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

/* Fixed, so the selects line up in a column rather than stepping in and
   out with the length of each platform's name. */
.owned-platform-name {
  flex: none;
  width: 6.5rem;
  color: var(--text-muted);
  font-size: 0.85rem;
}

/* ---------------------------------------------------------------------------
   First run -- #87

   Two surfaces for one problem, split by whether there is a library yet:
   the takeover (no games, nothing else worth showing) and the strip
   (games arrived, enrichment still running behind a working app).

   Both are deliberately quiet. This is the moment a new user has least
   reason to trust the product, and a loud "SETTING UP" bar reads as a
   warning rather than as reassurance.
   --------------------------------------------------------------------------- */

.onboarding-section {
  display: flex;
  justify-content: center;
  /* Vertical breathing room rather than centring in the viewport. A
     screen with three cards on it does not want to be pinned to the
     middle -- it jumps when the mode changes and the block resizes. */
  padding: 3.5rem 0 5rem;
}

.onboarding-inner {
  width: 100%;
  /* Wide enough for three platform cards in a row. The title and lede
     below stay narrow on their own -- the prose does not want this
     measure, but three equal choices side by side do, and 2+1 left Xbox
     orphaned on a row of its own looking like an afterthought. */
  max-width: 860px;
  text-align: center;
}

.onboarding-title {
  font-family: var(--font-display);
  font-size: 2rem;
  margin: 0 0 0.6rem;
  color: var(--text);
}

.onboarding-lede {
  margin: 0 auto 2rem;
  max-width: 44ch;
  color: var(--text-muted);
  line-height: 1.55;
}

/* The cards come from the same builders the settings popover uses, so
   they arrive already styled -- this only sets the grid they sit in. */
.onboarding-connect .connection-cards {
  /* 265px, not less. Below about that the card head runs out of room and
     "Not connected" wraps under the platform name, which reads as a
     two-line status on a card whose whole job is to be scannable.
     Dropping to two columns is the better trade. */
  grid-template-columns: repeat(auto-fit, minmax(265px, 1fr));
  text-align: left;
  /* START, NOT STRETCH. Grid's default made every card as tall as the
     tallest -- and PlayStation's is much the tallest, since it carries
     the privacy-settings instructions and an input. Steam, which is one
     button, became a card of mostly empty panel. */
  align-items: start;
}

.onboarding-connect .form-message:empty {
  display: none;
}

.onboarding-connect .form-message {
  margin: 1rem 0 0;
}

.onboarding-importing {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.9rem;
}

/* A ring rather than a bar. A bar implies a proportion, and there isn't
   one to report honestly -- see the note in app.js on why nothing here
   promises completion. */
.onboarding-spinner {
  width: 2.2rem;
  height: 2.2rem;
  border-radius: 50%;
  border: 2px solid var(--hairline);
  border-top-color: var(--gold);
  animation: onboarding-spin 0.9s linear infinite;
}

@keyframes onboarding-spin {
  to { transform: rotate(360deg); }
}

.onboarding-importing-text {
  margin: 0;
  color: var(--text);
}

.onboarding-importing-note {
  margin: 0;
  color: var(--text-faint);
  font-size: 0.85rem;
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }

  /* Motion on a decorative background is exactly what this setting is for.
   The wall is still a wall; it just stops moving. */

  .login-wall-strip { animation: none; transform: translateX(-50%); }

  /* Respecting reduced motion on both. The spinner is the only thing on
   screen during the wait, so an unwanted animation has nowhere to hide. */

  .onboarding-spinner {
    animation: none;
    border-top-color: var(--hairline);
    border-left-color: var(--gold);
  }
  .cold-start-banner-dot {
    animation: none;
  }
}

/* The strip. Sits above the tabs, full width, and is the least emphatic
   thing that can still be noticed -- no border, no panel, muted text.
   It is information, not a call to action; there is nothing to press. */
.cold-start-banner {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.55rem 0.1rem 0;
  /* ROOM UNDER IT (6 Oct). With no margin the strip's last line ended on
     the exact pixel the next thing began: on a game page that is the
     Back / Edit / Mark completed bar, which sat on the text ("this note
     is too low"). Above the tabs it only adds a breath of space. */
  margin-bottom: 0.6rem;
  /* Above the game page's hero, which the topbar's own z-index lifts
     over everything that does not claim a layer. */
  position: relative;
  z-index: 2;
  color: var(--text-faint);
  font-size: 0.85rem;
}

.cold-start-banner-dot {
  flex: none;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: var(--gold);
  animation: cold-start-pulse 1.8s ease-in-out infinite;
}

@keyframes cold-start-pulse {
  0%, 100% { opacity: 0.35; }
  50% { opacity: 1; }
}

.cold-start-banner-text {
  margin: 0;
}

/* ---------------------------------------------------------------------------
   Account settings, and the password reveal
   --------------------------------------------------------------------------- */

/* Same anchoring as the connections popover, narrower: this one is prose
   and a single control, and a 560px panel for two paragraphs reads as an
   empty dialog. */
.account-popover {
  position: absolute;
  top: calc(100% + 0.5rem);
  right: 0;
  z-index: 20;
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: 1rem;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
  width: max-content;
  max-width: min(90vw, 340px);
  text-align: left;
}

.account-email-label {
  margin: 0;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-faint);
}

.account-email {
  margin: 0.15rem 0 0;
  color: var(--text);
  /* Long addresses must not widen the popover past its max and force the
     page to scroll sideways. */
  overflow-wrap: anywhere;
}

/* Separated by a rule, not by a red panel. The action is destructive and
   the copy says so plainly; wrapping it in a warning box as well is the
   kind of alarm that gets tuned out, and this app does not shout. */
.account-danger {
  margin-top: 1rem;
  padding-top: 0.9rem;
  border-top: 1px solid var(--hairline);
}

.account-danger-title {
  margin: 0 0 0.35rem;
  font-size: 0.95rem;
  color: var(--text);
}

.account-danger-text {
  margin: 0 0 0.75rem;
  font-size: 0.85rem;
  line-height: 1.5;
  color: var(--text-muted);
}

.account-delete-confirm {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.account-delete-label {
  font-size: 0.85rem;
  color: var(--text-muted);
}

/* The address is shown in the label and has to be readable enough to
   copy by eye -- it is the thing being typed. */
.account-delete-label strong {
  color: var(--text);
  overflow-wrap: anywhere;
}

.account-delete-actions {
  display: flex;
  gap: 0.5rem;
}

/* Disabled until the typed email matches. Dimmed rather than hidden, so
   it is visible what the confirmation is FOR. */
.account-delete-actions .btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.account-popover .form-message:empty {
  display: none;
}

.account-popover .form-message {
  margin: 0.75rem 0 0;
}

/* The password field and its toggle.
   `block` on the wrap because it sits inside a <label> that stacks its
   text above the control. */
.password-wrap {
  position: relative;
  display: block;
}

/* Right padding on the input, not a narrower input: the button overlays
   the field, so without this the caret runs under the icon. */
.password-wrap input {
  width: 100%;
  padding-right: 2.5rem;
}

.password-reveal {
  position: absolute;
  top: 50%;
  right: 0.15rem;
  transform: translateY(-50%);
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.2rem;
  height: 2.2rem;
  padding: 0;
  background: none;
  border: none;
  border-radius: 6px;
  color: var(--text-faint);
  cursor: pointer;
}

.password-reveal:hover {
  color: var(--text-muted);
}

/* Focus stays visible. This control is reached by Tab from the password
   field, and removing the ring would strand a keyboard user on an
   invisible button between the field and the submit. */
.password-reveal:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  color: var(--text);
}

/* The escape hatch on the "getting ready" screen. #87.
   Quiet on purpose: it is a way out, not the thing to do. Styled as a
   text link rather than a button so it does not compete with the
   progress it sits under -- but it is a real <button>, because it does
   something rather than going somewhere. */
.onboarding-skip {
  margin-top: 0.4rem;
  padding: 0.35rem 0.6rem;
  background: none;
  border: none;
  border-radius: 6px;
  color: var(--text-faint);
  font-family: inherit;
  font-size: 0.85rem;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}

.onboarding-skip:hover {
  color: var(--text-muted);
}

.onboarding-skip:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  color: var(--text);
}

/* Ready, with someone playing (showLibraryReady in app.js): the way out
   becomes the thing to do, so it stops being a quiet link. */
.onboarding-skip.onboarding-skip--ready {
  margin-top: 0.6rem;
  padding: 0.55rem 1.2rem;
  border-radius: 999px;
  background: var(--gold);
  color: var(--bg);
  font-weight: 600;
  font-size: 0.95rem;
  text-decoration: none;
}

.onboarding-skip.onboarding-skip--ready:hover {
  color: var(--bg);
  filter: brightness(1.08);
}

/* The waiting game (frontend/waitingGame.js). Full width of the column up
   to the canvas's own cap; the canvas sizes itself inside. touch-action
   stops a tap-to-jump from also double-tap-zooming on phones. */
.waiting-game {
  width: 100%;
  max-width: 560px;
  display: flex;
  justify-content: center;
  margin: 0.4rem 0 0.2rem;
}

.waiting-game-canvas {
  display: block;
  border-radius: 8px;
  cursor: pointer;
  touch-action: manipulation;
}

/* The Game Rankings gate. #87.
   Deliberately not a warning panel -- no border, no colour, no icon. It
   is an invitation on a tab you just opened, and the app does not shout
   at people for not having done something yet. */
.top100-gate {
  margin-bottom: 1.25rem;
}

.top100-gate-title {
  font-family: var(--font-display);
  font-size: 1.6rem;
  margin: 0 0 0.35rem;
  color: var(--text);
}

/* THE SITE'S NOTE STYLE (Luke, 25 Sep). This was body-size text in the
   muted lavender, wrapping to two lines -- a paragraph where every other
   heading on the site has a small faint note under it (.section-note). */
.top100-gate-text {
  margin: 0;
  max-width: 62ch;
  font-size: 0.85rem;
  color: var(--text-faint);
  line-height: 1.5;
}

/* The Top 5 title link. #87.
   The title became a link so clicking a game opens it, and picked up the
   global anchor style with it -- purple and underlined, against a heading
   that is neither. A link inside a heading should look like the heading;
   it is the whole row's affordance, not a citation. */
.top5-name a {
  color: inherit;
  text-decoration: none;
}

.top5-name a:hover {
  text-decoration: underline;
  text-underline-offset: 4px;
}

.top5-name a:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 4px;
}

/* The two actions sit together and share a weight. */
.top5-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.6rem;
}

.top5-actions .btn {
  text-decoration: none;
}

/* The gate sits under the controls now, so it needs the space a content
   block would have had rather than the tight margin of a page header. */
.top100-gate {
  margin-top: 1.25rem;
}

/* THE EMPTY SLOTS SIT UNDER THEIR HEADING (Luke, 25 Sep). With the gate
   showing, the gate's margin, the (now empty) note row's and the strip's
   own 2rem stacked to ~66px between "Fill in your top 5 below" and the
   slots it points at. The strip's margin is for a filled Top 5 under the
   Top 5 / Top 20 toggle; under the gate it only needs a small gap. */
.top100-gate:not([hidden]) ~ .top5 .top5-strip { margin-top: 0.5rem; }

/* The rate-to-unlock list. Shares `.top100-list`'s grid, so the cards
   match the candidates under Top 5 rather than inventing a second shape
   for the same idea. */
.tier-rate-list {
  margin-top: 1rem;
}

/* The score box sits where the rank badge does on a Top 5 candidate --
   same position, same affordance, different question. Wider, because
   "8.5" needs more room than "3". */
/* The + on a pick card sits where the rate list's score box sits, so the
   two gates read as the same control in the same place. A button rather
   than an input: there is nothing to type, and a number box with no
   number in it invites one. */
.top5-pick-btn {
  border: 1px solid var(--hairline);
  background: rgba(0, 0, 0, 0.45);
  color: var(--text);
  cursor: pointer;
  font-size: 1rem;
  line-height: 1;
  padding: 0.1rem 0.4rem;
}
.top5-pick-btn:hover:not(:disabled) {
  border-color: var(--accent, var(--text));
  color: var(--accent, var(--text));
}
.top5-pick-btn:disabled { opacity: 0.4; cursor: default; }

.tier-rate-input {
  width: 3.1rem;
}

/* ---------- Info tip ---------- */

/* A small "i" beside a heading, with a note on hover.

   Generic on purpose -- every chart on the analysis page has a caveat
   that is worth one read and not worth a permanent line of grey text
   under it, which is what the section note had become. */
.info-tip {
  position: relative;
  display: inline-block;
  /* Aligns the dot with the cap height of a display-face heading rather
     than its baseline, which sits low. */
  vertical-align: 0.1em;
  margin-left: 0.4rem;
}

.info-tip-dot {
  width: 1.15em;
  height: 1.15em;
  padding: 0;
  border: 1px solid var(--text-faint);
  border-radius: 50%;
  background: none;
  color: var(--text-faint);
  /* Not the display face. The heading is condensed caps and a single
     lowercase "i" in it reads as a stray mark rather than as a letter. */
  font-family: var(--font-body, inherit);
  font-size: 0.72em;
  font-style: italic;
  line-height: 1;
  cursor: help;
  transition: color 0.12s ease, border-color 0.12s ease;
}

.info-tip-dot:hover,
.info-tip-dot:focus-visible {
  color: var(--text);
  border-color: var(--text);
}

.info-tip-bubble {
  position: absolute;
  top: calc(100% + 0.5rem);
  left: 0;
  z-index: 40;
  width: max-content;
  /* Wide enough to read as a sentence, narrow enough not to span the
     panel. Both bounds needed: `max-content` alone puts it on one line. */
  max-width: min(30ch * 1.4, 80vw);
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--hairline);
  border-radius: 0.5rem;
  background: var(--bg);
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);

  /* The heading is uppercase and display-face; the note is neither. */
  font-family: var(--font-body, inherit);
  font-size: 0.8rem;
  font-weight: 400;
  font-style: normal;
  letter-spacing: normal;
  text-transform: none;
  line-height: 1.5;
  color: var(--text-muted);

  /* Wraps, wherever it is anchored. The second tip went in a `<th>`, which
     sets `white-space: nowrap` -- the bubble inherited it, rendered as one
     700px line and ran off the side of the table. */
  white-space: normal;

  /* Hidden by OPACITY rather than display, so it can fade and so the
     transition has something to animate from. `visibility` is what keeps
     it out of the pointer's way and out of the accessibility tree while
     hidden -- opacity alone would leave an invisible box swallowing
     clicks over the chart. */
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.12s ease, visibility 0.12s;
}

/* Hover anywhere on the wrapper, or focus the button, or -- on a touch
   screen, where there is no hover -- tap it, which sets aria-expanded. */
.info-tip:hover .info-tip-bubble,
.info-tip-dot:focus-visible + .info-tip-bubble,
.info-tip-dot[aria-expanded="true"] + .info-tip-bubble {
  opacity: 1;
  visibility: visible;
}

/* Near the right edge the bubble would run off the panel, so anchor it to
   the right instead. Opt-in rather than automatic: a tip in the middle of
   a heading wants its left edge under the dot. */
.info-tip.is-right .info-tip-bubble {
  left: auto;
  right: 0;
}

/* ---------- Floating notes panel ---------- */

/* Beside the review you are writing, and movable. `position: fixed` so
   the modal and this page's overflow containers cannot clip it -- the
   trap that caught the tooltips twice.
   Z-index 120, above `.cleanup-overlay`'s 100. It was 60, under a note
   claiming the overlays were 30, so the rating panel's backdrop was
   drawn over this and the notes read as greyed out. */
/* RESIZABLE (Luke, 29 Sep: "a window you can drag to make bigger"): the
   corner handle is the browser's own (resize: both, which needs overflow
   other than visible). It opens at 320px x up to 460px (placeNotesFloat)
   and keeps what you drag it to for the session. */
.notes-float {
  position: fixed;
  z-index: 120;
  width: 320px;
  min-width: 240px;
  min-height: 160px;
  max-width: min(90vw, 760px);
  max-height: 85vh;
  resize: both;
  overflow: hidden;
  display: flex;
  flex-direction: column;

  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}

.notes-float[hidden] { display: none; }

/* The drag handle. `cursor: move` is the only thing telling anyone it
   can be dragged, and `user-select: none` stops a drag selecting the
   title text instead of moving the panel. */
.notes-float-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  padding: 0.6rem 0.75rem;
  border-bottom: 1px solid var(--hairline);
  cursor: move;
  user-select: none;
  /* Stops a touch drag scrolling the page underneath instead of moving
     the panel. */
  touch-action: none;
}

.notes-float-title {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.notes-float-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 0.5rem 0.75rem 0.75rem;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.notes-float-item {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
}

.notes-float-text {
  margin: 0;
  font-size: 0.82rem;
  line-height: 1.5;
  color: var(--text);
  /* A note is free text and can contain a line somebody typed as a list.
     Preserving the breaks costs nothing and reading it as one paragraph
     loses what they meant. */
  white-space: pre-wrap;
}

/* The button that opens it, on both surfaces. Quiet: it is a reference,
   not the thing you came to do. */
/* (The Notes button now lives in the writing toolbars; its styles are with
   .review-toolbar.) */



/* ---------- Notes: the tab menu ---------- */

/* The wrap is a nav item now, so it positions its own popovers and nothing
   else. It used to be `position: fixed` -- see the note where those rules
   were. */
.note-wrap { position: relative; display: flex; }

/* The way through to the notes page, from inside the editor.
   A link at the bottom rather than a second button in front of the editor:
   the tab used to open a menu of two, which charged the frequent errand
   (jotting a note) a click to make room for the rare one (reading them). */
.note-viewall {
  font-family: var(--font-body);
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--text-faint);
  background: none;
  border: none;
  border-top: 1px solid var(--hairline);
  border-radius: 0;
  padding: 0.55rem 0.1rem 0.1rem;
  margin-top: 0.2rem;
  text-align: left;
  cursor: pointer;
}
.note-viewall:hover,
.note-viewall:focus-visible { color: var(--accent); }

/* NOT ON THE NOTES PAGE. A link to where you already are is furniture, and
   the editor opens over the list it would send you to.
   `:has()` rather than a flag set in openNoteEditor(), so it is also right
   when the view changes WHILE the editor is open -- there is no moment where
   the link is stale. The popover is portalled to <body>, so the selector has
   to start there and not at the view. */
body:has(#view-notes:not([hidden])) .note-viewall { display: none; }

/* Same reason as the menu above: it has to escape the nav's overflow, and
   it has to follow an anchor that moves when the nav docks. JS sets the
   coordinates; `right: auto` undoes the `right: 0` from the original rule,
   which was written for a button in the page's right-hand gutter. */
.note-popover { position: fixed; right: auto; }

/* ---------- Notes: the page ---------- */

.notes-title { margin-bottom: 0.9rem; }

/* Search and the one action, on one line. `+ New note` used to sit in the
   heading row at the far right, a page width from the field it belongs
   next to. */
.notes-toolbar {
  display: flex;
  align-items: stretch;
  gap: 0.6rem;
  margin-bottom: 0.9rem;
}
/* The button sizes to its text; the field takes the rest. `min-width: 0` so
   the input can shrink below its intrinsic size -- a search input carries
   one, and without this it refuses to give the button room on a phone. */
.notes-toolbar .notes-search { flex: 1; min-width: 0; margin-bottom: 0; }
.notes-toolbar .notes-new { flex: none; white-space: nowrap; }
.notes-empty {
  font-family: var(--font-body);
  font-size: 0.92rem;
  color: var(--text-muted);
  max-width: 34rem;
}
.notes-games { display: grid; gap: 0.6rem; }

/* <details> rather than a click handler and an is-open class. The open
   state, the keyboard behaviour and the ARIA all come free, and there is no
   second source of truth about which game is expanded. */
.notes-game {
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  overflow: hidden;
}
.notes-game-head {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  padding: 0.7rem 0.9rem;
  cursor: pointer;
  list-style: none;
}
/* Safari draws its own triangle through ::-webkit-details-marker, which
   `list-style: none` alone does not remove. */
.notes-game-head::-webkit-details-marker { display: none; }
.notes-game-head:hover { background: var(--bg-panel-raised); }

.notes-game-cover {
  width: 34px;
  height: 51px;
  object-fit: cover;
  border-radius: 4px;
  flex: none;
}
.notes-game-cover-blank { background: var(--bg-panel-raised); display: block; }

.notes-game-name {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 0.98rem;
  color: var(--text);
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.notes-game-count {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
  flex: none;
}
/* A quiet turn rather than a chevron: the row already reads as expandable
   because the whole thing is the summary. */
.notes-game[open] .notes-game-head { border-bottom: 1px solid var(--hairline); }

.notes-game-body { padding: 0.35rem 0.9rem 0.8rem; display: grid; gap: 0.1rem; }

/* AN OPEN GAME SCROLLS INSIDE ITSELF (Luke, 28 Sep: "pretty big but not
   huge and scrollable"). Opening Resonance's eight notes pushed every other
   game a screen down, so the page stopped being a list of games and became
   one game's notes. Capped at 60% of the screen and never past 34rem, so a
   long run of notes reads comfortably in place and the next game's row
   stays in reach below. `contain` stops a scroll that reaches the end of
   the notes from carrying on and scrolling the page.

   The day headings stick to the top while their day's notes scroll under
   them, so a date is never scrolled away from the notes it dates. The
   negative top matches the body's top padding, so the heading meets the
   card's edge instead of leaving a strip the notes show through.

   Scoped to the Notes page: the same day headings are used in the note
   editor and beside the rating window, which have their own layouts. */
#view-notes .notes-game-body {
  max-height: min(34rem, 60vh);
  overflow-y: auto;
  overscroll-behavior: contain;
  /* The browser's default bar is a bright white track on this dark card
     -- the loudest thing on the page. Thin and faint instead. */
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}
#view-notes .notes-game-body .note-day-date {
  position: sticky;
  top: -0.35rem;
  z-index: 1;
  background: var(--bg-panel);
}
/* "Delete all notes", under the game's last note: right-aligned and as
   quiet as each note's own Edit / Delete, with a hairline above so it
   reads as about the game, not about the last note. */
.notes-game-foot {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 0.6rem;
  margin-top: 0.5rem;
  padding-top: 0.5rem;
  border-top: 1px solid var(--hairline);
}
.notes-foot-question { font-size: 0.78rem; color: var(--text-muted); margin-right: auto; }
.notes-entry { padding: 0.6rem 0; border-bottom: 1px solid var(--hairline); }
.notes-entry:last-child { border-bottom: 0; }
.notes-entry-body {
  font-family: var(--font-body);
  font-size: 0.92rem;
  line-height: 1.55;
  color: var(--text);
  margin: 0;
  /* Notes are typed prose and people press Enter in them. Without this the
     newlines they put in are collapsed and every note is one paragraph. */
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
/* A DAY'S HEADING, over that day's notes -- on the Notes page, in the note
   editor, and in the panel beside the rating window (noteDayHeading).

   It began as the small faint mono date that used to sit under every
   note, and read as a caption. Luke wanted it "a little more like a title,
   not too big": the body face at full text colour and bold, a step above
   the notes' size and no more. The rule under it runs the full width and
   is brighter than the hairlines BETWEEN notes, so it reads as the line
   that starts a day rather than one more divider. */
.note-day-date {
  font-family: var(--font-body);
  font-size: 0.95rem;
  font-weight: 700;
  letter-spacing: 0.01em;
  color: var(--text);
  margin: 1.1rem 0 0.35rem;
  padding-bottom: 0.4rem;
  border-bottom: 1px solid rgba(255, 255, 255, 0.16);
}
.note-day-date:first-child { margin-top: 0.4rem; }

/* ---------- The timeline on a phone: one bar per month ----------
   renderGanttMonths. A row per month: the month, a bar of one block per
   game played in it (platform colour), and the count. Tapping the row
   opens its games underneath -- it is a <details>. */
.gantt-months:not([hidden]) { display: block; }
.gantt-bar-row { border-bottom: 1px solid rgba(255, 255, 255, 0.05); }
.gantt-bar-row:last-child { border-bottom: 0; }
.gantt-bar-head {
  display: grid;
  grid-template-columns: 2.6rem minmax(0, 1fr) 1.6rem;
  align-items: center;
  gap: 0.6rem;
  padding: 0.5rem 0;
  cursor: pointer;
  list-style: none;
}
.gantt-bar-head::-webkit-details-marker { display: none; }
.gantt-bar-label {
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--text);
}
/* The bar. Blocks sit edge to edge with a hairline between them, so a
   month of six games reads as six things rather than one long stripe. */
.gantt-bar-track {
  display: flex;
  gap: 2px;
  height: 14px;
}
.gantt-bar-seg {
  flex: none;
  height: 100%;
  border-radius: 3px;
}
.gantt-bar-count {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--text-muted);
  text-align: right;
}
/* An empty month is a row with nothing to open; quieter, and not a
   button. */
.gantt-bar-row[data-empty] .gantt-bar-head { cursor: default; }
.gantt-bar-row[data-empty] .gantt-bar-label,
.gantt-bar-row[data-empty] .gantt-bar-count { color: var(--text-faint); }
.gantt-bar-row[open] .gantt-bar-label { color: var(--va, var(--gold)); }

/* The games of an opened month. */
.gantt-bar-games { padding: 0 0 0.5rem 3.2rem; }
.gantt-month-game {
  display: grid;
  grid-template-columns: 8px minmax(0, 1fr) auto;
  gap: 0.5rem;
  align-items: center;
  padding: 0.35rem 0;
  text-decoration: none;
  color: inherit;
}
.gantt-month-dot { width: 8px; height: 8px; border-radius: 50%; }
.gantt-month-name {
  font-size: 0.82rem;
  font-weight: 600;
  color: var(--text);
  line-height: 1.3;
}
.gantt-month-range {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  color: var(--text-muted);
  white-space: nowrap;
}
/* On the Notes page every heading is the first child of its day's
   section, so the gap between days comes from the section instead. */
.notes-day + .notes-day { margin-top: 0.9rem; }
/* The editor and the panel are compact surfaces with 0.82rem notes; the
   heading keeps its step above them rather than shouting. */
.note-existing .note-day-date,
.notes-float-body .note-day-date {
  font-size: 0.85rem;
  margin: 0.5rem 0 0.1rem;
  padding-bottom: 0.3rem;
}
.note-existing .note-day-date:first-child,
.notes-float-body .note-day-date:first-child { margin-top: 0; }

/* ---------- The nav docks when you scroll ---------- */

/* THE ARITHMETIC IS THE WHOLE STORY, and it is the same arithmetic that
   killed the old sidebar (see the note above the bottom-bar rules).
   `main` is capped at 1240px and centred, so a rail of width W needs
   1240 + W*2 of window before there is a gutter to put it in. A rail with
   labels is ~210px and needs 1660px -- which is a big monitor and nothing
   else. Icons only is 64px and needs 1368px, which most laptops clear.
   So the docked rail was ICONS ONLY, and only appeared where it fitted;
   below 1368px the nav pinned to the top of the window instead.

   THE RAIL IS GONE (Luke, 23 Sep): the top bar is the one shape at every
   desktop width. A floating pill of four icons had to be decoded, sat
   apart from the page it navigated, and made the nav behave differently
   depending on window width -- which is why it surprised him. The pinned
   bar keeps the words and the promise (the tabs come with you). Its rules
   live in the one (min-width: 641px) block now; this block keeps only the
   side menu that shares the left margin.
   `body.nav-docked` is set by a scroll listener once the banner's nav has
   gone past; see dockNavOnScroll(). */

@media (min-width: 1368px) {

  /* The side menu's placement lives in the (min-width: 641px) block now:
     "THE MENU IS ANCHORED TO THE SCREEN'S LEFT EDGE". It was here, first
     hanging in the margin, then (25 Sep, for one deploy) centring the menu
     and content together. */

  /* Nothing to say about the menu's position here any more: it is fixed and
     positioned from the anchor's rect, so it follows the rail without a rule
     per shape. */
}


/* Edit and delete on the notes page. The buttons reuse `.note-item-btn` from
   the popover, so only the row layout is new here. */
.notes-entry { position: relative; }
.notes-entry .note-item-actions {
  display: flex;
  gap: 0.4rem;
  margin-top: 0.4rem;
}
/* The textarea startNoteEdit() swaps in. `rows="2"` is right in a popover;
   here there is room, so it gets a floor instead of being two lines tall in
   a full-width panel. */
.notes-entry .note-edit-box {
  width: 100%;
  min-height: 5.5rem;
  font-family: var(--font-body);
  font-size: 0.92rem;
  line-height: 1.55;
  color: var(--text);
  background: var(--bg);
  border: 1px solid var(--hairline);
  border-radius: 8px;
  padding: 0.6rem;
  resize: vertical;
}
.notes-state {
  font-family: var(--font-body);
  font-size: 0.85rem;
  color: var(--gold);
  margin: 0 0 0.6rem;
  min-height: 1.2rem;
}

/* Search on the notes page. Full width, because it filters the whole list
   under it and a narrow box beside a heading reads like it searches games. */
.notes-search {
  width: 100%;
  font-family: var(--font-body);
  font-size: 0.92rem;
  color: var(--text);
  background: var(--bg-panel);
  border: 1px solid var(--hairline);
  /* --radius, like every other rounded surface on the page. It was an
     ad-hoc 8px, which is close enough to look like a mistake rather than a
     decision next to the 10px cards below it. */
  border-radius: var(--radius);
  /* Room on the left for the magnifier drawn below. */
  padding: 0.6rem 0.8rem 0.6rem 2.2rem;
  margin-bottom: 0.7rem;

  /* A MAGNIFIER, so the field reads as a search before it is read at all.
     Inline SVG in a data URI rather than an <svg> sibling: this is one
     element in a flex row and adding a wrapper to hold an icon would mean
     re-doing the row's sizing for decoration.
     `stroke='%239384a8'` is --text-faint by value, because a data URI
     cannot see custom properties. If that token changes, change it here --
     there is no way to make this follow it. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239384a8' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='M20 20l-4-4'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: 0.65rem center;
  background-size: 16px 16px;
}
/* Safari draws its own clear button and magnifier inside a search input,
   which would sit next to ours. */
.notes-search::-webkit-search-decoration,
.notes-search::-webkit-search-results-button { -webkit-appearance: none; }
.notes-search:focus {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.notes-search::placeholder { color: var(--text-faint); }

/* The pinned timeline axis. Sits directly under the scrolling chart and
   carries the same horizontal scroll, so the ticks stay over the bars they
   describe when the timeline is scrolled sideways on a phone. */
.gantt-axis {
  overflow-x: auto;
  /* No scrollbar of its own -- it is driven from the chart above, and two
     bars for one axis reads as a fault. */
  scrollbar-width: none;
  border-top: 1px solid var(--hairline);
  margin-top: 0.1rem;
  padding-top: 0.2rem;
}
.gantt-axis::-webkit-scrollbar { display: none; }
.gantt-axis canvas { display: block; }

/* ---------- Gameplay Analysis: stop every block being the same box ---------- */

/* SEVEN IDENTICAL TILES, seven identical uppercase muted headings. The page
   asks "what kind of player am I" and answers it in a form that says every
   answer weighs the same.
   The fix is not decoration for its own sake: each block gets an accent of
   its own, and the accent is a HAIRLINE plus a mark, never a filled tile --
   seven blocks of coloured background would be louder and just as uniform. */

.overview-section {
  /* One rotation, declared once. Each block picks the next by position, so
     adding a block never means choosing a colour. */
  --acc-1: var(--gold);
  --acc-2: var(--steam);
  --acc-3: var(--magenta);
  --acc-4: var(--ok);
  --acc-5: var(--epic);
  --acc-6: #6aa0c8;
  --acc-7: #e0a030;
  --acc-8: #d1615d;
}

/* EACH TILE IS TINTED WITH ITS OWN ACCENT.
   One shared background is the problem however it is coloured: eight tiles
   on one ground read as one object stamped eight times, and that is true of
   plum, of near-black and of --bg-panel alike. The accent already tells
   these sections apart at the rule and the dot -- the ground may as well
   agree with it.
   A DARK BASE PLUS A LITTLE ACCENT, not the accent itself. 8%, down from 14
   -- at 14 the grounds were loud enough to compete with the charts on them,
   which is the opposite of what a ground is for. 8 still reads as "the green
   one" beside "the gold one" and is far short of a coloured panel; the text still sits on something essentially dark, which is what
   keeps eight different grounds from becoming eight different legibility
   problems.
   The fallback matters: `--block-accent` is unset on any block that has not
   been given one, and `color-mix` with an invalid colour drops the whole
   declaration -- so the base is repeated as a plain `background` first.
   Scoped to this section rather than changed at the token, because
   --bg-panel is the ground for the whole product and this is one tab. */
.overview-chart-block {
  position: relative;
  overflow: hidden;
  background: #221a2c;
  background: color-mix(in oklab, var(--block-accent, transparent) 8%, #1e1628);
  border: 1px solid;
  border-color: color-mix(in oklab, var(--block-accent, var(--hairline)) 20%, #2e2340);
  border-top: 2px solid var(--block-accent, var(--hairline));
  transition: border-color .15s ease, transform .15s ease;
}

/* A wash of the accent in the top-left corner, so the colour reads as
   belonging to the block rather than sitting on it. Very low alpha: this is
   a tint you notice second, after the heading. */
.overview-chart-block::before {
  content: "";
  position: absolute;
  inset: 0 auto auto 0;
  width: 220px; height: 120px;
  /* Stronger than it was: the same wash over a near-black ground reads as
     half of what it did over --bg-panel. */
  background: radial-gradient(150px 100px at 0 0,
              color-mix(in oklab, var(--block-accent, transparent) 16%, transparent), transparent 70%);
  pointer-events: none;
}

/* NAMED, NOT COUNTED. `:nth-of-type` counts every div in the section --
   section-head, chart-wrap, gantt-axis, overview-grid -- so the three
   top-level blocks matched positions 1-3 of a list they are not in and got
   no accent at all. Measured: all three came out --hairline.
   The blocks that already have identities use them; only the grid, whose
   children are all the same kind, is positional. */
.gantt-block   { --block-accent: var(--acc-1); }
.donut-block   { --block-accent: var(--acc-2); }
#critic-block  { --block-accent: var(--acc-3); }
#rare-block    { --block-accent: var(--acc-4); }
.overview-grid > .overview-chart-block:nth-child(1) { --block-accent: var(--acc-5); }
.overview-grid > .overview-chart-block:nth-child(2) { --block-accent: var(--acc-6); }
.overview-grid > .overview-chart-block:nth-child(3) { --block-accent: var(--acc-7); }
.overview-grid > .overview-chart-block:nth-child(4) { --block-accent: var(--acc-8); }

/* The timeline block holds a chart that scrolls itself, so the corner wash
   must not sit over it -- and the block's own `overflow: hidden` would clip
   nothing else here anyway. */
.gantt-block { padding-bottom: 1rem; }
.gantt-block .section-head { margin-bottom: 0.9rem; }

/* THE HEADING STOPS SHOUTING IN CAPS AT 1.05rem. Uppercase display type at
   one size for seven different questions is what made them read as a list of
   the same thing. Sentence case, bigger, in full-strength ink -- these are
   the page's questions, not field labels. */
.overview-chart-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.45rem;
  text-transform: none;
  letter-spacing: 0;
  color: var(--text);
  display: flex;
  align-items: center;
  gap: 0.6rem;
}
/* A dot in the block's own colour, so the accent is named at the heading
   rather than only along the edge -- and so the colour is never the only
   thing distinguishing two blocks. */
.overview-chart-title::before {
  content: "";
  width: 9px; height: 9px;
  border-radius: 50%;
  background: var(--block-accent, var(--text-faint));
  flex: none;
}

.overview-chart-note,
.overview-chart-block .section-note {
  font-size: 0.88rem;
  color: var(--text-muted);
}


/* ---------- Surfaces INSIDE a tinted tile ---------- */

/* Anything with its own solid background inside these blocks was painted
   with --bg-panel, which is the one colour the tiles no longer are. The KPI
   strip in "Where you play Steam games" is the obvious one: an off-purple
   slab sitting on a red-tinted ground.

   TRANSLUCENT, NOT A SECOND FIXED COLOUR. A tint of black or white composites
   over whatever ground it lands on, so one rule covers all eight accents --
   and covers the ninth nobody has added yet. Naming a colour per tile would
   be eight more places to update the next time the ground changes.

   Inset surfaces darken, raised ones lighten, which is the same logic the
   rest of the app gets from --bg-panel and --bg-panel-raised -- just
   expressed as a relationship instead of a pair of constants. */

/* Raised: a control you press. */
/* `:not(.is-active)`: at equal specificity and later in the file, the
   bare version of this beat the shared glass on the selected button too. */
.overview-chart-block .view-toggle-btn:not(.is-active) {
  background: rgba(255, 255, 255, 0.05);
}
/* The selected state is the shared glass one (.view-toggle-btn.is-active).
   This was gold-soft, which made Analysis the one tab whose toggles still
   lit up gold after every other toggle went to glass. The base rule's
   gradient already reads over any tile colour, which is what this block
   is about. */

/* A row lighting up under the cursor, not a panel. */
.overview-chart-block .multiplayer-list-item:hover {
  background: rgba(255, 255, 255, 0.06);
}

/* ==========================================================
   THE PHONE LAYER. Everything for `max-width: 640px` lives here,
   and it lives LAST on purpose: at equal specificity the later
   rule wins, so a phone override can no longer be beaten by an
   ordinary rule written below it. That was the failure mode --
   `.filter-input { min-width: 0 }` sat dead at this breakpoint
   because the shared control styling came 350 lines later.
   Add phone rules HERE. tools/media-dead-decl-check.mjs fails
   the build if a second block appears, or if anything in this
   one is overridden and can never render.
   ========================================================== */
@media (max-width: 640px) {
  /* ---------- Responsive ---------- */

  /* 560px fitted the old cut-down Library (three columns of eight). With
     every column shown it clipped them, so the table sets its own
     min-width and this is just a floor for any other table. */
  table { min-width: 560px; }

  /* A FRAME AND A PLATE (#159, 1 Oct). The phone's scrim was flat and
     dark -- 82% at the top and 96% from 45% down -- so that the form,
     which fills the screen, never sat on bright artwork. It worked for
     the form and cost the wall: the covers were barely there, on the
     one page whose point is the covers.
     Chosen on the real stylesheet from that, a lighter flat scrim, and
     three with the form on a plate of its own: this one darkens only
     the top and bottom edges (the desktop's frame) and leaves the middle
     at 30%, and the wordmark, the claim and the form sit on a blurred
     plate, the topbar's treatment. The form's backing now belongs to the
     form, so the scrim no longer has to be dark enough for it. */

  .login-screen { padding: 1.5rem 1.25rem; align-items: flex-start; }
  .login-scrim {
    background: linear-gradient(180deg,
      rgba(var(--bg-rgb), 0.7) 0%, rgba(var(--bg-rgb), 0.3) 24%,
      rgba(var(--bg-rgb), 0.3) 76%, rgba(var(--bg-rgb), 0.75) 100%);
  }
  .login-plate {
    padding: 1.25rem 1.1rem 1rem;
    border-radius: 16px;
    border: 1px solid var(--hairline);
    background: rgba(var(--bg-rgb), 0.72);
    -webkit-backdrop-filter: blur(12px);
    backdrop-filter: blur(12px);
    box-shadow: 0 18px 44px rgba(0, 0, 0, 0.45);
  }
  /* Narrower covers on a phone, so a row is still several games wide rather
     than two and a half. Overriding the custom property rather than a
     `width` keeps buildLoginWall()'s arithmetic correct -- it reads this
     back off the element. */
  .login-wall { --cover-w: 96px; }

  /* NO BOTTOM BAR WHEN YOU ARE LOGGED OUT, so no room reserved for one.
     `body { padding-bottom: 4.25rem }` further down this file clears the
     fixed phone nav, which lives in #app-screen -- but it is on `body`, so
     it applied to the login screen too. 68px of dead scroll under a page
     that is meant to be exactly one viewport: the whole document scrolled
     by that much and the bottom of the wall sat above a flat band.

     It was invisible until now only because the explainer used to fill the
     space below. Measured at 375x812: scrollHeight 880 against an 812
     viewport, and 812 after this.

     Specificity carries it rather than source order -- `body:has(...)` beats
     a bare `body` wherever the two sit -- so this stays with the login
     rules instead of being stranded next to the nav's. */
  body:has(#login-screen:not([hidden])) { padding-bottom: 0; }

  /* ---- Shape four, on a phone (#171, 2 Oct): ONE BAR AT THE BOTTOM, THE
     SAME ON EVERY SCREEN ----

     Profile, Games, the note disc, Play Next, Search. Each tab's views
     (Library / Your Top Games / Tier Breakdown / Analysis) are a row of
     words at the top of its page (.gamelib-nav further down), and the
     header is the wordmark and the gear.

     WHAT IT REPLACES. From 29 Sep the bottom bar held the open tab's
     VIEWS, with the site tabs as pills under the wordmark ("Shape
     three"). So the bar changed from tab to tab -- four views on Games,
     two on Play Next -- and on Profile, Notes and a game page, which have
     no views, it was a full-width strip drawn around one small "+"
     (.plus-bar, #130). The thing under the thumb was never the same
     twice. Chosen on the real page from four answers (proto/bottom-bar,
     never merged): no bar where there are no views and a floating "+";
     the "+" in the header beside the search; the same bar with a bigger
     "+"; and this.

     AND WHAT IT IS NOT. Before 29 Sep the site tabs were also a bar at the
     bottom, with the views as a row of lit BUTTONS at the top of the page.
     That was dropped because the top row changed shape with every page and
     read as clutter ("all the buttons look too confusing now"). The tabs
     are back where they were; the views are not buttons this time --
     words, the open one white over a short bar (see .gamelib-nav).

     HOW. Same element, same .view-tab buttons, same data-view: the nav
     stays where it is in the markup, inside the header, and is fixed to
     the bottom of the screen from there. Two things come free with that:
     it hides when app.js hides it (onboarding takes the tabs away), and it
     steps aside with the header for the two edit sheets (#168, further
     down). The note disc is the nav's third child for the same reason --
     it goes wherever the bar goes.

     --tabbar-h is the bar's height above the home indicator's inset, in
     one place: the bar, the room the page leaves for it and the queue's
     adding bar all read it. */
  html { --tabbar-h: 3.5rem; }
  .view-tabs {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    align-items: center;
    gap: 0;
    height: calc(var(--tabbar-h) + env(safe-area-inset-bottom, 0px));
    padding: 0 0.2rem env(safe-area-inset-bottom, 0px);
    border-top: 1px solid var(--hairline);
    background: rgba(var(--bar-rgb), 0.94);
    overflow: visible;
  }
  /* ON A GAME PAGE TOO. showGameDetail (app.js) hides the tab row with the
     `hidden` attribute -- right on a desktop, where a game page has its own
     top bar and the row would only be in the way -- and the site-wide
     `[hidden] { display: none !important }` enforces it. On a phone that
     row is the bottom bar, and the bar is on every screen; so here, and
     only while a game page is showing, the attribute is overruled. Scoped
     to the game page so that the other thing that hides the tabs,
     onboarding taking over the screen, still does. The bar goes on
     lighting the tab you came from, as it did when you left it. */
  body:has(#view-game-detail:not([hidden])) .view-tabs[hidden] { display: flex !important; }
  /* A CELL: the icon over its word. Four cells share the bar equally, two
     each side of the disc. (The icons were kept in the markup for this;
     the desktop's row of pills hides them.) */
  .view-tab {
    flex: 1 1 0;
    min-width: 0;
    flex-direction: column;
    justify-content: center;
    gap: 0.15rem;
    padding: 0.3rem 0.1rem;
    font-size: 0.58rem;
    line-height: 1.2;
  }
  .view-tab-icon {
    display: block;
    width: 20px;
    height: 20px;
  }
  /* The open tab lights in the menu colour (--menu, "you are in this"), as
     the open view did while the views were this bar. No pill behind it. */
  .view-tab.is-active {
    background: none;
    color: var(--menu);
  }
  /* THE SEVENTH ITEM IS A WRAPPER, NOT A BUTTON. `.note-wrap` is what the
     row lays out -- the Notes button is inside it, so `flex: 1 1 0` on
     `.view-tab` never reached the row's own flex child. Found when the
     bar overflowed by exactly one item's width; still true of the row. */
  /* The Notes tab is hidden since #130 (its wrapper stays for the note
     sheet), so the wrapper takes no room -- it used to be the row's fourth
     cell. */
  /* display: contents, so the wrapper takes neither width nor a share of
     the row's gap (it left the glass 6px narrower than the tabs). The
     sheet it wraps is portalled to <body> and pinned by its own rules, so
     the wrapper's box is not needed on a phone. */
  .view-tabs > .note-wrap { display: contents; }
  .view-tabs > .note-wrap > .view-tab { width: 100%; }
  /* THE NOTE DISC, in the middle of the bar (#130; chosen then from sides /
     right / float and three middle shapes). In the wordmark's gradient,
     which every theme redefines, with the notepad white on it.

     IT LOOKS LIKE A BUTTON AND PRESSES LIKE ONE (#171, 2 Oct: "I want it
     to feel like a button when you press it"). It was a flat disc. Chosen
     on the real page from four: flat; the site's own lipped button (a
     darker edge it sinks onto, as Add game has) in the gradient and in the
     action colour; and this -- a dome, lit from above with a shadow under
     it, that shrinks and dims under the finger. The transition is short
     on purpose: a press that eases in over the usual 0.15s reads as lag. */
  .plus-btn {
    display: flex;
    flex: 0 0 auto;
    align-items: center;
    justify-content: center;
    width: 2.7rem;
    height: 2.7rem;
    margin: 0 0.4rem;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: var(--wm-grad-v);
    color: #fff;
    cursor: pointer;
    box-shadow: 0 6px 14px rgba(0, 0, 0, 0.6), inset 0 2px 1px rgba(255, 255, 255, 0.55), inset 0 -4px 5px rgba(0, 0, 0, 0.28);
    transition: transform 0.08s ease, box-shadow 0.08s ease, filter 0.08s ease;
  }
  .plus-btn svg { width: 23px; height: 23px; stroke-width: 2.4; filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.3)); }
  .plus-btn:active {
    transform: scale(0.92);
    filter: brightness(0.9);
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5), inset 0 3px 7px rgba(0, 0, 0, 0.38);
  }
  /* THE SEARCH IS THE LAST CELL, AND ONE OF THE BUTTONS (#171: "have the
     'search' be highlighted like the other buttons"). The same width as
     the three tabs and drawn like them: its icon over the word, dim at
     rest, and lit in the menu colour while its sheet is open -- which is
     the popover sitting in the header search's host (#165), so the
     Library's own Add game button opening the same popover does not light
     it. Until now it was gold with a glow, the one coloured thing in the
     row, so that a bare glyph read as a control; with a word under it and
     three like it beside it, it does not need to shout. The field and the
     notepad are the desktop's. */
  .hdr-tools { flex: 1 1 0; min-width: 0; margin-left: 0; justify-content: center; }
  .hdr-glass {
    width: 100%;
    height: auto;
    flex-direction: column;
    gap: 0.15rem;
    padding: 0.3rem 0.1rem;
    border-radius: 10px;
    color: var(--text-faint);
    font-family: var(--font-body);
    font-size: 0.58rem;
    font-weight: 600;
    line-height: 1.2;
  }
  .hdr-glass svg { stroke-width: 1.7; }
  .hdr-glass-label { display: block; }
  /* No gold wash left behind by the tap: a phone keeps :hover on the last
     thing touched, and the desktop's hover tint would sit on this cell
     until something else was pressed. */
  .hdr-glass:hover { background: none; }
  body:has(#add-game-host > #add-game-popover:not([hidden])) .hdr-glass { color: var(--menu); }
  .hdr-search-input,
  .hdr-note { display: none !important; }
  /* ROOM FOR THE BAR, plus the home indicator's inset, by which the bar is
     taller (viewport-fit=cover in index.html). */
  body { padding-bottom: 1rem; }
  /* ON EVERY SIGNED-IN PAGE: the bar is on all of them (since #130 by the
     + drawing one where a tab had none; since #171 because it is one bar).
     Not the public profile, which has no bar. */
  body:has(#app-screen:not([hidden])) {
    padding-bottom: calc(var(--tabbar-h) + 0.75rem + env(safe-area-inset-bottom, 0px));
  }
  /* NOTHING SITS UNDER THE PHONE'S STATUS BAR (#153, 3 Oct). viewport-fit=
     cover lets the page run to the top edge as well as the bottom, and
     only the bottom was ever given back: on CONTROL Resonant's page Back,
     Edit game details and + Mark completed sat over the clock and the
     battery. The page starts below the inset, and a strip of the page's
     own colour, the height of the inset, stays over whatever scrolls up
     behind it. Where the inset is 0 -- a desktop, a phone with no notch,
     a browser that keeps the page below its own chrome -- the padding is
     0 and the strip has no height: nothing changes.
     --safe-top is a variable so the strip and the padding cannot differ,
     and so it can be set by hand to see this on a screen with no notch. */
  html { --safe-top: env(safe-area-inset-top, 0px); }
  body:has(#app-screen:not([hidden])) { padding-top: var(--safe-top); }
  body:has(#app-screen:not([hidden]))::before {
    content: "";
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    height: var(--safe-top);
    background: var(--bg);
    z-index: 500;
    pointer-events: none;
  }

  .game-detail-layout { grid-template-columns: 1fr; }

  .calendar-day { font-size: 0.7rem; }
  .calendar-weekday { font-size: 0.6rem; }

  /* ============================================================
   Mobile-only adjustments (phones)
   ============================================================ */

  /* FAVORITES: ALL FIVE ON ONE LINE, THE RANK UNDER THE ART. Five to a
     row makes each cover about 60px wide, and the #1 badge on top of it
     covered a third of the artwork (Luke, 23 Sep). A scrolling row of
     104px covers was tried the same day and rejected -- he wants the five
     visible at once. So the covers stay five across and the badge comes
     OFF the art: on a phone the rank is a small line beneath its cover
     (see .favorite-rank below). */
  .favorites-grid {
    grid-template-columns: repeat(5, minmax(0, 1fr)) !important;
    gap: 0.35rem;
  }
  .favorites-grid .favorite-card { gap: 0.2rem; }

  /* Music + Spotlight hidden -- the embeds don't earn their space here.
     Targeting the sections directly now the wrapper is gone. Kept as a
     media query rather than folded into the toggles: this is a judgement
     about the medium, not a preference, and someone who wants them on a
     phone should not have to turn them off on desktop to get it. */
  .profile-section[data-section="music"],
  .profile-section[data-section="spotlight"] { display: none; }

  /* Currently Playing: a horizontal scroller whose children were sized
     in a way that let them overlap once the row was narrower than the
     cards. flex-shrink:0 plus an explicit width stops that. */
  .card-row {
    gap: 0.6rem;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
  }
  .activity-card-name { font-size: 0.75rem; }
  .activity-card-stat,
  .activity-card-stat-secondary { font-size: 0.65rem; }

  /* The timeline is back on mobile, but scrolls sideways rather than
     being crushed into the viewport. A month needs real width to be
     readable, so the inner element is sized well past the screen and the
     wrapper scrolls -- the same way a wide table works on a phone. */
  /* FITS the screen -- no sideways scroll.
     It was sized to 760px so each bar had room for its name beside it.
     But a horizontal scroll to read a chart means you can never see the
     whole year at once, which is the one thing this chart is for.
     The labels move ABOVE the bars instead (endLabels.stackLabels), so
     the width only has to hold the bars. Rows are taller to make room
     for the text, which is a fair trade: vertical scrolling is what a
     phone is already for. */
  .gantt-inner {
    min-width: 0;
  }
  .gantt-wrap {
    overflow-x: hidden;
    /* No mask: there is nothing off-screen to hint at now, and a faded
       right edge would suggest content that is not there. */
    -webkit-mask-image: none;
    mask-image: none;
  }

  /* Summary statistics: the grid demanded 420px minimum columns, which
     is wider than the screen -- so the whole block overflowed. */
  .overview-grid {
    grid-template-columns: 1fr !important;
    gap: 1rem;
  }
  /* min-width:0 is the essential part: a grid item's default min-width of
     auto lets its content force the track wider than the container, so
     the 420px chart blocks overflowed the screen even after the grid
     itself was set to a single 1fr column. */
  .overview-grid > * { min-width: 0; }
  .overview-chart-block {
    padding: 0.8rem;
    min-width: 0;
    max-width: 100%;
    overflow: hidden;
  }
  /* overflow handled in the mobile block further down -- charts scroll
     rather than squash. Clamping it here fought that. */
  .chart-wrap { max-width: 100%; }
  .donut-and-list { flex-direction: column; }

  /* THE LIBRARY ON A PHONE IS A LIST OF CARDS, not a table (Luke, 23 Sep).

     History, because this has swung twice:
     1. It first hid four columns and squeezed the rest into the viewport
        with `table-layout: fixed` + `overflow-wrap: anywhere`, which
        rendered "MECCHA CHAMELEON" one letter per line in a row 600px
        tall.
     2. The fix for that kept all seven columns at a 760px min-width and
        let the table scroll sideways -- "a table is a table".
     3. Sideways scrolling was right about not mangling words and wrong
        about the phone: seven columns you pan across, under three
        buttons and three dropdowns, read as crowded. Luke chose cards.

     Each row becomes: the cover on the left spanning the card, the name
     beside it, and one wrapping line under the name with platform,
     status, rating and last played. HOURS and PLAYED ON are dropped on a
     phone -- a deliberate choice, and both are on the game's page one tap
     away. Names wrap BETWEEN words (overflow-wrap: normal), which is what
     broke in (1).

     CSS only. The markup is the desktop table's, so the status select
     and rating input keep their handlers and there is one row renderer,
     not two that drift. The <a> around cover and name is display:
     contents so both can be placed on the card's grid and still be the
     link. */
  #library-table,
  #library-table tbody { display: block; width: 100%; min-width: 0; }
  #library-table thead { display: none; }
  #view-library .table-wrap { overflow: visible; }

  /* EACH GAME IS ITS OWN CARD (#142, 4 Oct). Three columns: the cover,
     the name and what is under it, and the right-hand column the status
     pill sizes.
       row 1   cover | name ....................... [★ 8]
       row 2   cover | platform pills
       row 3   cover | played Sep 30 .......... [Completed ⌄]

     WHAT IT WAS, AND WHY IT CHANGED. One bordered panel held the whole
     list, and each card in it carried four layers of chrome for one game:
     an uppercase caption row ("LAST PLAYED SEP 30"), a hairline under it,
     a separator between cards, and the panel's own border. A game with no
     date still printed the caption and a dash.

     HOW IT WAS CHOSEN (proto/library-declutter, three rounds on the real
     page). Round one took things away -- one grey line under the name,
     the status as text, no panel -- and was "too plain". Round two went
     the other way: this, a big cover, a platform-coloured edge, a grid of
     covers. Round three was this and three takes on it (two lines, the
     cover run to the card's edge, a platform wash). This one.

     So: the panel goes and each game is a raised card of its own, the
     cover is larger (56 by 78, from 44 by 62), the coloured platform
     pills and the status pill stay as they were (the one register under
     the name, chosen 1 Oct on proto/library-card), and the date is words
     on the bottom line -- cardWhenText in app.js -- with nothing printed
     for a date the game does not have. The cover spans all three rows. */
  #view-library .table-wrap {
    border: 0;
    background: none;
    box-shadow: none;
    border-radius: 0;
  }
  #library-table tbody tr {
    display: grid;
    grid-template-columns: 56px minmax(0, 1fr) auto;
    column-gap: 0.55rem;
    row-gap: 0.3rem;
    align-items: center;
    padding: 0.65rem 0.7rem;
    margin-bottom: 0.55rem;
    border: 1px solid var(--hairline);
    border-radius: 14px;
    background: var(--bg-panel);
    box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22);
    /* The whole card is the tap target since #137 (the handler by rowHtml). */
    cursor: pointer;
  }
  #library-table tbody tr:last-child { margin-bottom: 0; }
  #library-table td {
    display: block;
    padding: 0;
    border: 0;
    font-size: 0.75rem;
    overflow-wrap: normal;
    min-width: 0;
  }
  #library-table td.game-name-cell,
  #library-table .game-link-row { display: contents; }
  #library-table .row-cover-wrap {
    grid-column: 1;
    grid-row: 1 / span 3;
    align-self: start;
    width: 56px;
    height: 78px;
    border-radius: 8px;
    overflow: hidden;
  }
  /* The name takes the full width. The rating pill sits over its right
     end, and the name is padded clear of it only when there is one -- so
     a name is never narrowed by the status pill's column below it, which
     cost "The Witcher 3: Wild Hunt" a second line when it was. */
  #library-table .game-link {
    grid-column: 2 / -1;
    grid-row: 1;
    font-size: 0.98rem;
    font-weight: 600;
    line-height: 1.25;
  }
  #library-table tbody tr:has(td:nth-child(5):not(.is-quiet)) .game-link { padding-right: 3.4rem; }
  #library-table td:nth-child(2) { grid-column: 2 / -1; grid-row: 2; overflow: hidden; }   /* platform */
  #library-table td:nth-child(3) { grid-column: 3; grid-row: 3; justify-self: end; }  /* status */
  #library-table td:nth-child(5) { grid-column: 2 / -1; grid-row: 1; justify-self: end; align-self: start; }  /* rating */
  /* WHEN, in words on the bottom line, left of the status: "played Sep
     30". Quiet body type, not a caption -- it was 9px uppercase across
     the top with a rule under it. An empty cell (a game with no date)
     takes no room and prints nothing. */
  #library-table td:nth-child(7) {
    grid-column: 2;
    grid-row: 3;
    align-self: center;
    font-family: var(--font-body);
    font-size: 0.74rem;
    color: var(--text-faint);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  #library-table td:nth-child(4),
  #library-table td:nth-child(6) { display: none; }                     /* hours, played on */

  /* THE STATUS AS A PILL. Still the select -- a tap opens the picker --
     drawn in the platform pills' shape with its own chevron, sized to
     its longest option as a select is by default (the old max-width:
     100% clipped "Currently Playing"). */
  #library-table .status-select {
    appearance: none;
    -webkit-appearance: none;
    font-family: var(--font-mono);
    font-size: 0.68rem;
    /* ROOM FOR THE WORDS' TAILS (#167, 2 Oct). The line was exactly the
       font's size (line-height 1), and a select clips its words to that
       line: the tails of the y and the g in "Currently Playing" were cut
       off along the bottom -- by a pixel or none, depending on where on
       the screen the row happened to land, which is why it showed on some
       rows and not others. A line 1.3 tall holds the whole letter. The
       padding gives back what the line took, so the pill is still 20px
       and still matches the platform pills beside it. */
    line-height: 1.3;
    padding: 0.15rem 1.25rem 0.15rem 0.5rem;
    border-radius: 999px;
    border: 1px solid var(--hairline);
    background: var(--bg-panel-raised) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239384a8' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>") no-repeat right 0.45rem center / 10px 10px;
    color: var(--text-muted);
    width: auto;
    max-width: none;
    min-width: 0;
  }
  /* THE RATING AS A GOLD PILL: the star and the score. Only when there is
     one, or the game is finished and wants one -- a quiet cell (#138) is
     not drawn at all on a phone, and the card's own tap covers where it
     would be. The score box is as wide as
     its digits and right-aligned, so the star hugs the number and the
     digits end at the card's inset: one digit by default, room for "10"
     or a half point (the value attribute is what the row was drawn with,
     which is all a phone row shows). */
  #library-table td:nth-child(5).is-quiet { display: none; }
  #library-table td:nth-child(5):not(.is-quiet) {
    display: inline-flex;
    align-items: center;
    gap: 0.2rem;
    /* 0.15rem, with the score's box 1.3 lines tall below: the same trade
       as the status pill's, for the same reason (#167). */
    padding: 0.15rem 0.5rem;
    border-radius: 999px;
    border: 1px solid rgba(var(--gold-rgb), 0.4);
    background: rgba(var(--gold-rgb), 0.14);
    color: var(--gold);
    font-family: var(--font-mono);
    font-size: 0.68rem;
    line-height: 1;
  }
  #library-table td:nth-child(5)::before {
    content: "\2605";
    color: var(--gold);
    font-size: 0.62rem;
  }
  #library-table .rating-input {
    width: 0.8rem;
    /* Its line, set by hand: Chrome gives the box 4px more on its own, and
       the pill was taller than the status pill. 1.3 lines since #167 -- at
       exactly one line the box clipped the digits by a pixel on some rows,
       as the status pill clipped its words. */
    height: 0.884rem;
    padding: 0;
    font-size: 0.68rem;
    line-height: 1.3;
    font-weight: 700;
    text-align: right;
  }
  #library-table .rating-input[value="10"],
  #library-table .rating-input[value*="."] { width: 1.4rem; }
  /* THE PILL IS A BUTTON HERE, not a box (1 Oct; the handler by rowHtml
     says why and what else was tried). A tap on it opens the Rate or
     review sheet; the box inside is the desktop row's and takes no taps. */
  #library-table td.rating-cell { cursor: pointer; }
  #library-table .rating-input { pointer-events: none; }
  /* A FINISHED GAME WITH NO SCORE: a hollow pill, a hollow star and the
     word. The filled pill around "rate" read as a rating; and the box was
     one digit wide, which cut the word to "ra". */
  #library-table td.rating-cell:has(.rating-input:placeholder-shown) {
    background: transparent;
    border-color: rgba(var(--gold-rgb), 0.45);
  }
  #library-table td.rating-cell:has(.rating-input:placeholder-shown)::before { content: "\2606"; }
  #library-table .rating-input:placeholder-shown { width: 1.5rem; text-align: left; }
  #library-table .rating-input::placeholder { color: rgba(var(--gold-rgb), 0.85); font-weight: 600; }
  /* EACH PILL OWNS A MARGIN, so a finger a few pixels off a 20px pill
     does not open the game (the whole card is the link, #137). An
     invisible box on the cell: out to the card's right edge (the row's
     11px padding), 14px to the left, the rating's up to the date line,
     the status's down to the card's edge, and the 18px between the two
     split down the middle. The select sits above its cell's margin so a
     tap on the pill itself is still a tap on the select. */
  #library-table td.rating-cell,
  #library-table td.status-cell { position: relative; }
  #library-table td.rating-cell::after,
  #library-table td.status-cell::after { content: ""; position: absolute; }
  #library-table td.rating-cell::after { inset: -4px -11px -9px -14px; }
  #library-table td.status-cell::after { inset: -9px -11px -14px -14px; }
  #library-table .status-select { position: relative; z-index: 1; }
  /* THE PLATFORM LINE IS THE PILLS, no words (1 Oct; it said "Played on
     PlayStation" from 23 Sep). One line, never wrapped: a third platform
     is clipped behind the status pill rather than stacked under, and the
     game page lists them all. Without the words a game you only own on a
     platform looks the same as one you played there; the status pill
     beside it carries that (Backlog, Unreleased). */
  #library-table .platform-tag { display: none; }
  #library-table .played-on-label { display: none; }
  #library-table .played-on {
    display: flex;
    flex-wrap: nowrap;
    white-space: nowrap;
    align-items: center;
    gap: 0.25rem;
    font-size: 0.72rem;
    color: var(--text-muted);
  }
  #library-table .platform-pill { margin: 0; }
  #library-table .played-on-label { margin-right: 0.1rem; }
  /* THE CARD'S OWN DATE (#142): "played Sep 22", written by cardWhenText,
     in place of the table's full date. It has had three forms -- a bare
     short date at the end of the card (unlabelled), a captioned row
     across the top, and now words on the bottom line, where it has the
     line to itself and cannot collide with the rating as "Played Sep 22,
     2026" once did ("8.5Played"). */
  #library-table .date-long { display: none; }
  #library-table .date-card { display: inline; }
  /* Multi-platform games render several badges; unconstrained they blew
     the row width out and broke the layout. */
  .platform-tag {
    display: inline-flex;
    flex-wrap: wrap;
    gap: 0.15rem;
    max-width: 100%;
  }

  /* Filter rows scroll horizontally rather than being cut off. Wrapping
     them stacked three rows deep before the table even appeared. */
  .controls {
    flex-wrap: nowrap;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    padding-bottom: 0.3rem;
  }
  .controls > * { flex: 0 0 auto; }
  .controls select,
  .controls input[type="search"],
  .filter-input { min-width: 0; width: auto; font-size: 0.75rem; }
  .controls input[type="search"] { min-width: 140px; }
  .view-toggle { flex: 0 0 auto; }
  .view-toggle-btn { font-size: 0.72rem; padding: 0.3rem 0.5rem; }

  /* Anchored to the viewport rather than the button: right-aligned to a
     button sitting at the far right of a horizontally-scrolled filter row
     put the popover mostly off-screen. */
  .add-game-below .add-game-popover {
    position: fixed;
    /* DOCKED TO THE KEYBOARD (#132, 30 Sep), the shape the notes sheet
       has. It sat under the button, mid-screen, 1rem in from each side
       and 60vh tall at most -- and vh does not shrink for the keyboard,
       which covered the results and, with the page panned, the field.
       --sheet-kb is the keyboard's height and --sheet-vvh the visible
       height above it (trackSheetViewport, app.js); on a desktop neither
       is set and the fallbacks give a plain bottom sheet. The box stays
       put at the top and the results scroll inside it. */
    left: 0;
    right: 0;
    top: auto;
    bottom: calc(100% - var(--sheet-top, 0px) - var(--sheet-vvh, 100%));
    width: auto;
    max-height: calc(var(--sheet-vvh, 100dvh) - 8px);
    overflow: hidden;
    border-radius: 16px 16px 0 0;
    border-bottom: 0;
    /* Over the phone's bottom bar (z-index 40) and the stuck header (30). */
    z-index: 70;
    /* The second shadow is a scrim: a spread wider than any screen dims
       the page behind, without an element of its own. A tap on it lands
       on the page, which is the click-away that closes the sheet. */
    box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.55), 0 0 0 100vmax rgba(10, 6, 15, 0.55);
  }
  .add-game-popover .add-game-results {
    flex: 1 1 auto;
    min-height: 0;
    max-height: none;
  }
  /* The way out (30 Sep): with the page held still behind the sheet there
     was nothing to scroll or tap away to once the results filled it. Top
     right, over the hint, which is padded clear of it. */
  .add-game-popover .add-game-close {
    display: block;
    position: absolute;
    top: 0.35rem;
    right: 0.5rem;
    padding: 0.2rem 0.5rem;
    background: none;
    border: 0;
    color: var(--text-muted);
    font-size: 1.7rem;
    line-height: 1;
  }
  .add-game-popover .field-hint { padding-right: 2.2rem; }
  .calendar-day-number { font-size: 0.62rem; }
  /* THE MONTH'S LIST (Luke, 29 Sep: "a bit out of whack"). Four things on
     one flex line -- badge, name, date, status -- and at 351px the name and
     the date both wrapped, at a different point on every row, so nothing
     lined up down the list. A grid instead: the badge spanning both lines,
     the name taking what is left, the date in its own column at the right
     and never wrapping; the status drops to a second line under the name,
     where a label about the game belongs. */
  .calendar-day-detail li {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    column-gap: 0.6rem;
    row-gap: 0.1rem;
    align-items: center;
    font-size: 0.86rem;
  }
  .calendar-day-detail li > :first-child { grid-column: 1; grid-row: 1 / span 2; }
  .calendar-day-detail li .game-link { grid-column: 2; grid-row: 1; min-width: 0; }
  .calendar-day-detail li .mono-cell { grid-column: 3; grid-row: 1; white-space: nowrap; font-size: 0.78rem; }
  .calendar-day-detail li .calendar-game-status { grid-column: 2; grid-row: 2; font-size: 0.62rem; }
  /* ONE LINE ON A PHONE (Luke, 23 Sep). It wrapped, leaving Today alone on
     a second row. The two pickers flex and shrink; the arrows and Today
     stay their size, tightened, so all five fit a 375px screen. */
  .calendar-nav { flex-wrap: nowrap; gap: 0.3rem; }
  #view-calendar .calendar-nav select {
    flex: 1 1 0;
    min-width: 0;
    padding-left: 0.45rem;
    padding-right: 0.2rem;
    font-size: 0.8rem;
  }
  #view-calendar .calendar-nav .btn {
    flex: none;
    padding-left: 0.55rem;
    padding-right: 0.55rem;
  }

  /* Hero sizing lives in the mobile block further down, alongside the
     padding it has to agree with. Two rules setting the same property in
     two places is how this file became hard to reason about. */

  .achievement-icon { width: 38px; height: 38px; }
  .achievements-summary { margin-left: 0; width: 100%; }

  .recent-achievements-row {
    grid-template-columns: repeat(5, minmax(0, 1fr));
    max-width: 100%;
  }

  .award-label { font-size: 0.66rem; }
  .wrapped-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .wrapped-title { font-size: 1.15rem; }

  .game-detail-fields { grid-template-columns: 1fr; }

  /* The desktop overhang this undid is gone, so there is nothing to
     reset here any more. Left as a note rather than a rule that restates
     the default. */
  .row-cover-wrap { width: 30px; height: 42px; }

  .game-detail-backdrop.has-artwork { height: 260px; }

  /* An odd last tile filling the row only makes sense when the row is two
   wide. At four across it would drop a single cell onto a line of its
   own, which is the gap this was written to prevent. */

  .game-detail-stat-row > :last-child:nth-child(odd) {
    grid-column: 1 / -1;
  }

  /* FOUR, not three.
     Three tiles on a 390px screen are ~115px each -- larger than they
     render on desktop, which is backwards for something meant to be
     glanced at. Four fits a set on one screen, which is the whole point
     of a tile set being shareable.
     The `!important` is inherited from the rule this replaces: there are
     TWO `max-width: 640px` blocks setting `.awards-grid`, and this is the
     later one. Removing the flag would hand it back to the other. */
  .awards-grid { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; gap: 0.3rem; }
  .tiles-title, .tiles-title-input { font-size: 1.05rem; }

  .review-block { gap: 0.6rem; }
  .review-rating { padding-top: 0; }

  .card-row .activity-card,
  #upnext-unreleased-row .activity-card {
    width: 88px;
    flex: 0 0 88px;
  }
  .card-row .activity-card-cover-wrap,
  #upnext-unreleased-row .activity-card-cover-wrap {
    width: 88px;
  }

  /* HIGHEST RATED THE SIZE OF FAVOURITES (Luke, 29 Sep: "highest rated
     cards are bigger than favorites and it looks weird"). The two rows sit
     one under the other; favourites were a five-across grid of ~66px
     covers, this a sideways-scrolling row of 88px ones with titles. Now
     the same grid, so the covers match. At 66px a title would wrap to
     three lines, so on a phone it goes -- the art names the game, as it
     does in Favourites -- and the score stays, centred under its cover.
     (Taken out of the 88px card rule above, which other rows still use.) */
  #top-rated-year-row {
    display: grid;
    grid-template-columns: repeat(5, minmax(0, 1fr));
    gap: 0.35rem;
    overflow: visible;
  }
  #top-rated-year-row .activity-card { width: auto; min-width: 0; }
  #top-rated-year-row .activity-card-cover-wrap { width: 100%; }
  #top-rated-year-row .activity-card-name { display: none; }
  #top-rated-year-row .activity-card-stat { text-align: center; }
  /* THE EMPTY LINE TAKES THE WHOLE ROW (#164, 2 Oct). With nothing rated
     this year the row holds one sentence instead of covers, and as a grid
     item it was given ONE of the five columns: "Rate a game you finished
     this year and it shows up here." in a 66px column, sixteen lines, a
     word on each. It spans all five, and sits as the empty lines of the
     other rows do: at the left, under its heading, without the two
     lines of air a centred empty state carries above and below. */
  #top-rated-year-row > .empty-state { grid-column: 1 / -1; text-align: left; padding: 0.4rem 0 0.6rem; }

  /* THE AVATAR'S RING CLEARS THE HEADER, WITH NO BANNER (#164, 2 Oct).
     With a banner the avatar hangs off the banner's foot, well clear of
     the site header. Without one it is the first thing on the page, flush
     with the top -- and its ring is drawn OUTSIDE its box (3px solid, 6px
     soft), so the top of the ring was under the header's edge. Room for
     the ring above it. A new account has no banner, so this is the first
     thing a new user's profile showed. */
  #view-profile .profile-content:not(.has-banner) .profile-header { padding-top: 0.6rem; }

  /* Shorter cells. 64px min-height on a 52px-wide column made every cell
     taller than it was wide, so a month stretched down the page and the
     grid stopped reading as a calendar. Cover art is the content; the
     cell should be about the shape of a cover. */
  .calendar-day {
    min-height: 0;
    /* Square, not cover-shaped.
       3/4 gave 49x65 cells, so six rows ran to ~390px -- nearly half a
       phone screen for a month that's mostly empty. Square is the
       conventional calendar cell and saves about 100px.
       The trade-off is that cover art in a 49px square shows the middle
       of the cover rather than the whole thing. Most cells have no game
       at all, and the ones that do are a tap away from the full list
       below, so the height is worth more than the crop. */
    aspect-ratio: 1 / 1;
    padding: 0.1rem;
  }
  .calendar-game-label { font-size: 0; padding: 0; }

  /* NO PAGER ON TOUCH.
     Two attempts already: hidden (unreachable), then forced visible at
     15x24px (a third of the 44px iOS minimum, sitting on top of the
     cover). Both were solving the wrong problem.
     A tap on the cover now scrolls to that day in the month list below --
     which already existed and already listed every game. The pager was a
     workaround for a tap that never reached the cell. */
  .calendar-nav-arrow,
  .calendar-slide-count { display: none; }

  /* The count, which also hints the covers move.
     "1/2" implied a control you could press; "1 / 2" with a chevron says
     there is more to swipe to. The gesture has no visible affordance of
     its own, so this is the only thing telling anybody it exists. */
  .calendar-day-count::after {
    content: " \203a";
    opacity: 0.7;
  }
  .calendar-day-count {
    position: absolute;
    right: 2px;
    bottom: 2px;
    min-width: 13px;
    padding: 0 3px;
    border-radius: 7px;
    background: rgba(0, 0, 0, 0.75);
    color: var(--text);
    font-size: 0.55rem;
    line-height: 14px;
    text-align: center;
  }

  /* The cover fills the cell rather than sitting in it. At 52px wide
     there's no room for padding around the art AND the art. */
  .calendar-game {
    padding: 0;
    border-radius: 3px;
    overflow: hidden;
  }
  .calendar-game img { width: 100%; height: 100%; object-fit: cover; }

  /* FOUR, matching the tiers and tile grids.
     At three, covers render ~119px on a 390px screen -- bigger than they
     are on desktop, which is backwards for a list you scan. Four fits
     twelve games on a screen instead of six.

     This said three for a while and nobody could see why: the four was
     written twice, in two other `max-width: 640px` blocks, and this rule
     came later in the file and quietly beat both of them. The reasoning
     above is theirs, moved here so it sits on the rule that actually
     renders. That is the whole of #4 in one example. */
  .top100-list,
  .top100-year-list {
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: 0.5rem;
    row-gap: 0.75rem;
  }
  /* Touch has no hover, so the stats stay visible on mobile. */
  .top100-grid-stat-bar { opacity: 1; }

  /* THE RANKINGS HEADER, TWO FULL-WIDTH ROWS (#145, 1 Oct). It was the
     desktop toolbar wrapped: the toggle at its own width with "Show top
     100" beside it, a search two thirds of the row wide, then "Drag and
     drop to rank your games across views." and the star legend, a line
     each -- 151px from the top of the pane to the first cover. Chosen on
     the real page from that, this, and the count beside the search:
       row 1   the view's buttons, equal, filling the row; on the Top N
               list "Show top N" at its right
       row 2   the search, filling the row; the legend's star at its end
     104px to the first cover. Tier Breakdown is the same two rows with
     three equal buttons and no count. */
  .top100-section .section-head {
    flex-direction: column;
    flex-wrap: nowrap;
    align-items: stretch;
    gap: 0.5rem;
  }
  .top100-section .sub-nav-row,
  .top100-section .section-head .controls {
    display: flex;
    flex-wrap: nowrap;
    align-items: center;
    gap: 0.5rem;
    width: 100%;
  }
  #view-top100 #top100-filter-toggle:not([hidden]) { display: flex; flex: 1 1 auto; min-width: 0; }
  #view-top100 #top100-filter-toggle .view-toggle-btn {
    flex: 1 1 0;
    min-width: 0;
    justify-content: center;
    text-align: center;
    /* "By year released" is the longest label: a third of the row is
       113px, and it fits at this size with these sides. */
    padding-left: 0.2rem;
    padding-right: 0.2rem;
    font-size: 0.72rem;
    white-space: nowrap;
  }
  /* The search's flex child is its WRAPPER (the box and its clear x, made
     in app.js), and the wrapper carries order: 3 from the desktop
     toolbar -- so it is the wrapper that grows, and the orders are said
     here: the search, then the star. */
  #view-top100 .controls .search-wrap { flex: 1 1 auto; min-width: 0; order: 1; }
  #view-top100 #top100-search { flex: 1 1 auto; min-width: 0; width: 100%; max-width: none; }
  #view-top100 .topx-count-control { flex: 0 0 auto; }
  /* A DESKTOP INSTRUCTION. Every text this line takes is about dragging
     ("Drag and drop to rank your games across views.", "Drag to reorder,
     or type a rank."), written for a pointer; on a phone it was a row of
     help for a gesture you find by holding a cover. */
  #view-top100 #top100-note { display: none; }
  /* THE STARS AND THEIR LEGEND ARE BEHIND ONE BUTTON. The button shows
     where the line would: only on a grid that has stars (the same three
     :has the line uses). While the pane is .legend-open the line shows
     and so does the star on every cover missing a rating or a review;
     at rest neither does. The (hover: none) block is what drew the stars
     permanently on a touch screen, because there is no hover to reveal
     them with -- this is the reveal. Hidden with visibility as well as
     opacity, as that block's own rule is, so an invisible star does not
     take the tap meant for the cover.
     !important on the line because the rule that shows it has two ids
     inside its :has, and matching that from here would be a selector
     nobody could read. */
  #view-top100:not(.legend-open) .top100-rate-btn { opacity: 0; visibility: hidden; }
  #view-top100:has(#top100-list:not([hidden]) .top100-rate-btn) .legend-btn,
  #view-top100:has(#tier-list:not([hidden]) .top100-rate-btn) .legend-btn,
  #view-top100:has(#topx-list:not([hidden]) .top100-rate-btn) .legend-btn {
    display: grid;
    place-items: center;
    flex: 0 0 auto;
    order: 3;
    width: 2rem;
    height: 2rem;
    padding: 0;
    border-radius: 8px;
    border: 1px solid var(--hairline);
    background: rgba(10, 7, 14, 0.82);
    color: var(--gold);
    font-size: 0.85rem;
    line-height: 1;
  }
  #view-top100.legend-open .legend-btn { border-color: rgba(var(--gold-rgb), 0.6); }
  #view-top100:not(.legend-open) .top100-star-legend { display: none !important; }

  #upnext-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }

  /* The per-tile spans are gone with the single grid. A tile takes one
   track of its own band now, and the band's track size is what makes a
   named tile wider than a counter -- so the difference is still declared
   per stat, just expressed once per band rather than once per tile.
   The inline-span hook that packStatRows used to write is gone with it. */

  .profile-stat-band-named { grid-template-columns: 1fr; }

  .hero-tagline {
    margin-bottom: 0.5rem;
  }

  /* The banner is a header, not a page.
     At 2rem padding plus a 9vw title plus tagline plus button it took
     roughly a third of a phone screen -- on EVERY tab, before any content
     appeared. The desktop proportions don't survive a 390px viewport.
     Everything is still here, just at a size that leaves room for the app
     underneath it. */
  /* THE BAND ENDS UNDER THE WORDMARK, on every page (Luke, 29 Sep: "on
     mobile, both the profile and notes page still have too much space
     between the nowplayed and the bottom of the banner"). It was
     tightened on Games and Play Next first, and only there; the header's
     bottom padding 0.9rem -> 0.3rem everywhere now. */
  .hero {
    /* THE HEADER IS THE WORDMARK AND THE GEAR, AND SCROLLS AWAY WITH THE
       PAGE (#171, 2 Oct). From 30 Sep (#134) the band was sticky at a
       negative top, so that the wordmark row rode off and the tab row
       under it stayed (before that, 29 Sep, the whole band stuck, and 97px
       of every phone screen was chrome). The tabs are the bottom bar now
       ("Shape four" above), so nothing in the band needs to stay: it is an
       ordinary block at the top of the page. The offset arithmetic that
       went with the stick (--hero-scroll-off) went too, here and in the
       sheets' lock rule further down.

       STILL POSITIONED, at z-index 30. The bottom bar is fixed from INSIDE
       this element, so this is what puts the bar over the page -- and what
       lets the header's visibility take the bar with it for the edit
       sheets (#168). Near-solid, as it was while it stuck. */
    padding: 1.1rem 0.75rem 0.3rem;
    position: relative;
    z-index: 30;
    background: rgba(var(--bar-rgb), 0.96);
  }
  /* The wordmark row's height. The title sits at its bottom, so the row's
     extra room is above it, under the padding. */
  .hero-brand {
    height: 2.4rem;
    display: flex;
    align-items: flex-end;
  }
  /* The wordmark keeps its 0.22em of padding below the baseline -- the
     gradient only paints inside the box, and without it the y's tail
     loses its colour -- and a matching negative margin gives the space
     back. (0.2rem here used to ADD to that padding instead.) */
  .hero-title {
    font-size: clamp(1.7rem, 8.5vw, 2.2rem);
    margin-bottom: -0.22em;
  }
  .hero-tagline {
    font-size: 0.8rem;
  }
  .hero-wrapped-btn {
    margin-bottom: 0;
    margin-top: 0.5rem;
  }

  /* Summary stats sit tighter on a phone. The gap between them was
     0.9rem, which at three stats is 43px spent on whitespace in a 366px
     line -- enough on its own to push the last one onto a second row. */
  .summary-stat {
    margin-right: 0.6rem;
    /* Scaled with the desktop bump above rather than left at the old
       figure, which would have made the phone version smaller than it
       used to be relative to everything around it. */
    font-size: 0.82rem;
  }

  /* The scroll fade that was here is gone with the strip it belonged to.
     It told you six tabs continued past the right edge; the bottom bar
     fits all six at once, so there is nothing to scroll and nothing to
     hint at. Deleted rather than left: it came after the bottom-bar rules
     and its `gap: 0` and padding were winning on source order. */

  /* Charts get room to breathe rather than being squeezed.
     `overflow: hidden` on a narrow screen means a squashed chart with no
     way to see it properly. A minimum width plus a scrollable wrapper
     keeps bars readable -- and the padding drop gives back 32px, which is
     often enough that no scrolling is needed at all. */
  .chart-wrap {
    padding: 1rem 0.85rem;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
  }

  /* The timeline is the exception, and it has to be stated HERE.
     `.gantt-wrap` sets `overflow-x: hidden` around line 2255, but this
     rule is later in the file at the same specificity, so it won. The
     timeline kept scrolling sideways on a phone while its labels were
     already stacked above the bars for exactly the opposite reason.
     The comment below still described the OLD design -- min-width plus a
     scrolling wrapper -- which the stacked labels replaced. */
  .chart-wrap.gantt-wrap {
    overflow-x: hidden;
  }
  /* NO min-width on the canvas.
     An earlier attempt set 420px here to stop bars being squeezed. It
     backfired: Chart.js maintains an aspect ratio, so forcing the canvas
     wider than its box stretched every chart VERTICALLY -- the donut went
     oval and the year labels on the bar charts came out elongated.
     Charts are responsive and should stay that way. The timeline used to
     be the exception -- min-width plus a scrolling wrapper -- but it now
     stacks each label ABOVE its bar on a phone, so it fits like every
     other chart and scrolls vertically instead. */

  /* Edit mode, laid out for a phone.
     `.profile-background-controls` carries `margin-left: auto` (and the
     Edit Profile button did, before it moved to the gear menu on 28 Sep),
     which on a WRAPPED flex row shoves each to the right of its own line --
     so the controls arrived at different widths on staggered rows. Right-aligning works on one wide row and
     falls apart the moment it wraps.
     Here every edit control is a full-width row instead, so they're
     uniform and comfortably tappable. */
  .profile-content.is-editing .profile-avatar-controls,
  .profile-content.is-editing .profile-background-controls {
    margin-left: 0;
    width: 100%;
  }
  .profile-content.is-editing .profile-avatar-controls > .btn,
  .profile-content.is-editing .profile-background-upload-label,
  .profile-content.is-editing #background-remove-btn {
    width: 100%;
    justify-content: center;
  }

  /* A GRID on mobile, not wrapped flex.
     Flex-wrap sized each tile to its own content, so a row could be
     "337" beside "119" and the next "43%" beside "16" at different
     widths, with one tile stranded alone. Nothing lined up, and a column
     of ragged boxes is harder to read than the numbers deserve.
     Two equal columns means every figure sits on the same grid. */
  /* On the BANDS now, not on .profile-stats -- that is a flex column of
     bands and has no columns of its own to set. */
  .profile-stat-band-counters {
    grid-template-columns: 1fr 1fr;
    gap: 0.5rem;
  }
  .profile-stat-band-named,
  .profile-stat-band-wide { gap: 0.5rem; }
  .profile-stat {
    min-width: 0;          /* or content forces the column wider than 50% */
    padding: 0.6rem 0.75rem;
  }
  .profile-stat-value { font-size: 1.6rem; }

  /* The named-game tile takes the full width -- a game title in half a
     phone wraps to three lines. */
  .profile-stat-named,
  .profile-stat-wide {
    grid-column: 1 / -1;
    max-width: none;
  }

  /* Less side padding on a narrow screen.
     2.5rem/1.5rem is right at desktop and costs 48px of a 390px phone,
     which is most of a cover tile. The vertical padding stays. */
  main {
    /* 0.75rem, was 1.5rem -- the same tightening as desktop (28 Sep).
       0.45rem since 29 Sep, with the header band above it. */
    --main-pad-top: 0.45rem;
    padding: var(--main-pad-top) 0.75rem 1rem;
  }

  /* Follows `main` above, for the reason on .hero: the wordmark aligns
     with the Profile tab only while these two padding values agree. */
  .hero {
    padding-left: 0.75rem;
    padding-right: 0.75rem;
  }

  /* The rank badge sat at desktop size over a much smaller cover, so #10
     covered a third of the artwork. Shrinking it (0.58rem) still left it
     covering too much of a 60px cover, so on a phone it leaves the art
     entirely: static, ordered after the cover, no chip -- a small gold
     number under each game. The profile is meant to let the art carry it. */
  .favorite-rank {
    position: static;
    order: 2;
    background: none;
    padding: 0;
    font-size: 0.62rem;
    line-height: 1.2;
    text-align: center;
  }

  .public-hero { padding: 1.5rem 0.75rem 1.25rem; }
  .public-main { padding: 1.25rem 0.75rem 2rem; }
  .public-avatar-wrap { width: 56px; height: 56px; flex-basis: 56px; }
  .public-stats { gap: 1.25rem; }
  .public-stat-value { font-size: 1.6rem; }
  .public-cover-grid { grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); gap: 0.45rem; }
  /* Four here too. This is the view other people SEE -- the one that most
     needs to fit a screen without scrolling. */
  .public-tile-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.3rem; }
  .public-tile-label { font-size: 0.74rem; }
  .public-tile-game { font-size: 0.66rem; }

  .upnext-headline {
    flex-direction: column;
    align-items: stretch;
    gap: 0.75rem;
  }
  .upnext-headline .btn { width: 100%; }

  /* Full width on a phone: a 380px drawer on a 390px screen is a drawer
     pretending to be a page.
     `html body #view-profile` ON EVERY RULE IN THIS GROUP (#132, 30 Sep).
     The desktop panel is styled as `#view-profile .profile-edit-panel`
     (the makeover block at the end of the file), and an id outranks any
     class selector however many elements are in front of it -- so from
     28 Sep none of these phone rules applied: the panel was the desktop
     card inset 1rem, and the Done bar below, fixed with the desktop
     head's `top: -1rem` AND this block's `bottom: 0`, stretched over the
     whole screen. That is #135, "a full-height sheet with nothing in it
     but Done". The id in the selector is what makes these win. */
  html body #view-profile .profile-edit-panel {
    width: 100vw;
    /* Room for the Done bar below. */
    padding-bottom: 4.5rem;
  }

  /* DONE MOVES TO A FIXED BAR AT THE BOTTOM.
     The sticky head made this worse, not better: the panel is `top: 0`,
     so its head sticks underneath the app header and the tab strip and
     is never seen. Anchoring to the bottom puts it where a thumb already
     is, and nothing overlaps it. */
  html body #view-profile .profile-edit-panel-head {
    position: fixed;
    /* `top: auto` (28 Sep): the desktop rule makes the head sticky at
       `top: 0`, and that top survived into this fixed bar -- with top AND
       bottom both 0 the "Done" bar stretched over the whole screen and
       covered every control in the panel. */
    top: auto;
    /* On the keyboard when there is one (#132), like the panel it closes. */
    bottom: calc(100% - var(--sheet-top, 0px) - var(--sheet-vvh, 100%));
    left: 0;
    /* And the bottom tab bar covered it anyway: .profile-content is its own
       stacking context at z-index 1, so this bar's z-index cannot lift it
       over the tabs (z-index 40, in the root context). While the editor is
       open (body.panel-locked) the tabs go instead -- the panel is the
       whole screen, and Done is the way out of it. */
    right: 0;
    z-index: 2;
    margin: 0;
    padding: 0.75rem 1.25rem;
    padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
    background: var(--bg-panel);
    border-top: 1px solid var(--hairline);
  }
  /* The title is redundant next to a full-screen panel that obviously is
     the editor -- and it would compete with the button for the bar. */
  .profile-edit-panel-head h3 { display: none; }
  .profile-edit-panel-head .btn {
    width: 100%;
    padding: 0.7rem;
    font-size: 1rem;
  }
  body.panel-locked .view-tabs { display: none; }
  /* AND THE SITE HEADER, FOR BOTH EDIT SHEETS (#168, 2 Oct). The profile
     editor and the game's edit panel are the whole screen on a phone, but
     each lives inside its page's own stacking context (z-index 1), and
     the site header is z-index 30 in the root one -- so no z-index on a
     sheet can lift it over the header, the same wall the Done bar hit
     against the tab bar (above). The header's 62px sat on top of them:
     "About" and the Name label in Edit profile, and the edit panel's own
     title and its x in Edit game, were under the wordmark and could not
     be scrolled to. The header steps aside instead. visibility, not
     display, so nothing behind the sheet moves while it is open.
     AND THE BOTTOM BAR GOES WITH IT (#171): the bar and its note disc are
     inside the header now, so this one rule is what clears them for both
     sheets. (The "+" and the strip it drew, .plus-bar, each had a rule of
     their own for this; the strip's lost on specificity to the rule that
     drew it and sat frosted over the editor's Done, which is what made
     Done look disabled -- the other half of #168.) */
  body.panel-locked .hero,
  body:has(.game-detail-fields.is-editing) .hero { visibility: hidden; }
  /* `body.panel-locked { overflow: hidden }` was here. The scroll lock is
     html.page-locked now (#132, further down this block): the editor is
     one of the sheets applyPageLock watches, and overflow: hidden was not
     holding the page under a finger anyway. */

  /* The edit panel is the whole screen here: not the floating card it is
     on desktop (PROFILE MAKEOVER, end of file). */
  html body #view-profile .profile-edit-panel {
    top: 0;
    right: 0;
    /* Ends where the keyboard begins (#132): --sheet-kb is the keyboard's
       height, from trackSheetViewport (app.js), 0 without one. The panel
       scrolls inside itself, so a field the keyboard would have covered
       can be scrolled up to. */
    bottom: calc(100% - var(--sheet-top, 0px) - var(--sheet-vvh, 100%));
    width: 100vw;
    border-radius: 0;
    border: 0;
  }

  .overview-metric-head {
    flex-direction: column;
    align-items: flex-start;
  }

  /* ---------- Theme background on a phone ---------- */

  /* LESS SIDE PADDING ON A PHONE (Luke, 23 Sep). The page's 12px margin
     plus the panel's 1.5rem left 36px unused on each side of a 375px
     screen -- a fifth of the width -- and squeezed the five favourites to
     60px. 0.75rem inside the panel keeps the content off its rounded
     edge and gives 24px back to the art. */
  /* No top padding on a phone (29 Sep): with the header band tightened
     above it, these 21px were the whole of the gap left between the
     wordmark and the Profile banner. */
  .profile-content { padding-top: 0; padding-left: 0.75rem; padding-right: 0.75rem; }
  /* On a phone the sidebar's sections are part of the one stack
     (display: contents), so their headings take the main headings' size
     -- "STATS" at a caption size under "ACHIEVEMENT SHOWCASE" read as a
     footnote. */
  .profile-side .card-row-title {
    font-size: 1.4rem;
    letter-spacing: normal;
    margin-bottom: 0.75rem;
  }

  /* STATS AS TILES ON A PHONE (Luke, 23 Sep: "clean up stats to look
     better on mobile"). Five counter rows -- a label on the left, a number
     at the far right edge -- made the eye cross the whole screen for each
     figure, and the list ran a screen long. On a phone the counters are a
     2-column grid of tiles, the number big on top and the label under it;
     a fifth tile spans the row rather than sitting alone in a half. The
     named stats (Most hours, Longest-running) and the rating histogram
     keep their rows: they carry a game's name, which needs the width. */
  #profile-stats .profile-stat-band-counters {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 0.4rem;
  }
  #profile-stats .profile-stat-band-counters .profile-stat {
    flex-direction: column-reverse;
    align-items: flex-start;
    justify-content: flex-end;
    gap: 0.2rem;
    padding: 0.65rem 0.75rem;
    margin: 0;
    border: 1px solid rgba(255, 255, 255, 0.06);
    border-radius: 10px;
    background: rgba(255, 255, 255, 0.035);
  }
  #profile-stats .profile-stat-band-counters .profile-stat:last-child:nth-child(odd) {
    grid-column: 1 / -1;
  }
  #profile-stats .profile-stat-band-counters .profile-stat-value {
    font-size: 1.7rem;
    line-height: 1;
  }
  #profile-stats .profile-stat-band-counters .profile-stat-label {
    font-size: 0.72rem;
    color: var(--text-muted);
  }

  /* EDGE TO EDGE (Luke, 23 Sep: "still looks like there is padding"). The
     profile is a rounded card, and on a phone it sat inside the page's own
     12px margin -- so the screen showed page, card edge, card padding,
     then content: two gutters. The card now runs to the screen's edges
     with square corners, and its 0.75rem padding is the only gutter left.
     Its backdrop art runs edge to edge with it. */
  #view-profile {
    margin-left: -12px;
    margin-right: -12px;
    border-radius: 0;
  }

  /* Achievements fill the row rather than sitting in a column.
     Medium reads well on a phone, but on ONE column it meant a narrow
     strip of icons with dead space beside it. */
  .profile-body > [data-section="achievements"] .recent-achievements-row {
    grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
  }

  /* The scatter needs height on a phone, not width.
   At the default aspect ratio a 390px-wide chart is ~150px tall and every
   dot lands in a band a thumb cannot separate. Taller gives the y axis
   room to actually spread the ratings out. */

  #dev-scatter-chart { min-height: 320px; }

  /* Taller on a phone: the same chart in a third of the width needs the
     vertical room to stay readable. */
  #overview-metric-chart { height: 260px; }
  #dev-scatter-chart     { height: 320px; }
  #deck-split-chart      { height: 240px; }

  /* The cover was stretching to the full column width -- a portrait
     image at 390px wide renders ~585px tall, most of a screen, before
     you reach a single fact about the game.
     Capped so the title, stats and store links are reachable without
     scrolling past a poster. */
  .game-detail-cover img,
  .game-detail-cover .cover-placeholder {
    max-width: 190px;
  }
  /* Centred, since it no longer fills the column. */
  .game-detail-cover {
    align-items: center;
    text-align: center;
  }
  .game-detail-cover .detail-ownership,
  .game-detail-cover .look-it-up,
  .game-detail-cover .igdb-match-row {
    align-items: center;
    justify-content: center;
  }

  /* THE GAME PAGE ON A PHONE: COVER, THEN THE TITLE (Luke, 23 Sep).
     The cover column carries "Owned on" and seven stacked store buttons
     under the art. Beside the page on desktop that is a sidebar; stacked
     on a phone it was 350px of buttons between the cover and the game's
     name -- the first screen was a poster and a shop.

     `display: contents` drops the two column wrappers out of the layout,
     so their children become items of one flex column and can be
     REORDERED without moving markup the desktop layout depends on: the
     cover first, everything about the game next, and where you own it
     and where to find it after the stats and dates, where it is still
     on the page but no longer in the way. */
  /* gap: 0 -- the grid's column gap was written for TWO items, cover and
     info. With their children as the flex items it landed between every
     one of them, 28px under the title, the byline, the tags. The cover
     keeps that space below it as a margin instead. */
  .game-detail-layout { display: flex; flex-direction: column; gap: 0; }
  .game-detail-cover,
  .game-detail-info { display: contents; }
  #detail-cover-img,
  #detail-cover-placeholder { align-self: center; order: 0; margin-bottom: 1.25rem; }
  #detail-not-owned { order: 1; }
  .game-detail-info > * { order: 2; }
  .game-detail-cover .detail-where,
  .game-detail-cover .look-it-up,
  .game-detail-cover .igdb-match-row { order: 3; }
  /* Right after the dates, not at the foot of the page: the info column
     also holds the review, achievements, playthroughs and About, so
     "after the info" was 2,800px down. Everything after the summary
     goes behind the store block. */
  .game-detail-info > #detail-visual-summary ~ * { order: 4; }
  /* FLEX ITEMS' MARGINS DO NOT COLLAPSE. In the block layout the gap
     between two sections was the larger of one's bottom margin and the
     next one's top; as flex items they ADD (stats to dates went from
     24px to 40px, achievements to playthroughs from 24 to 48). Dropping
     the top margins leaves each gap at the bottom margin, which is what
     the collapsed layout mostly showed. */
  .game-detail-info > * { margin-top: 0; }

  /* (The ownership line used to step aside here when the store pills
     marked every platform it named -- see renderStoreLinks for why it no
     longer does, 29 Sep.) */

  /* UNDER THE DATES: TWO COLUMNS, THEN A GRID (Luke, 29 Sep, picked on the
     real page from now / grid / list / strip / twocol / cards: "there is
     just so much wasted space still"). Played on and Subscription service
     sit side by side, the stores in an even three-column grid below,
     titles left and bold. From the dates to "Rating and review" was 213px
     of centred rows each on its own line; this is 166px.
     A game without a subscription gives Played on the full width
     (auto-fit counts only the shown columns); more platforms wrap inside
     their own half -- three, the most in Luke's library, is two lines. */
  .game-detail-cover .detail-where {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
    gap: 0.5rem;
    margin: 0 0 0.75rem;
  }
  .game-detail-cover .detail-ownership {
    flex-direction: column;
    align-items: flex-start;
    justify-content: flex-start;
    gap: 0.35rem;
    margin: 0;
    text-align: left;
  }
  .game-detail-cover .ownership-pills { justify-content: flex-start; }
  .game-detail-cover :is(.platform-pill, .subscription-pill) {
    font-size: 0.7rem;
    padding: 0.2rem 0.55rem;
    line-height: 1.25;
  }
  /* Bold, one step brighter: at this size the faint regular titles were
     hard to read (Luke, 29 Sep). */
  .game-detail-cover :is(.byline-where, .look-it-up-label) {
    font-weight: 700;
    color: var(--text-muted);
  }
  .game-detail-cover .look-it-up { margin: 0 0 1.1rem; text-align: left; align-items: stretch; }
  .game-detail-cover .look-it-up-label { margin-bottom: 0.35rem; }
  .game-detail-cover .store-links {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 0.3rem;
    margin: 0;
    /* The desktop column's width cap does not apply to the phone's grid. */
    max-width: none;
  }
  .game-detail-cover .store-link {
    justify-content: center;
    align-items: center;
    padding: 0.34rem 0.15rem;
    border-radius: 8px;
    font-size: 0.7rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  /* "PlayStation $14.99" is the longest cell; this fits it whole. */
  .game-detail-cover .store-link-price { margin-left: 0.2rem; font-size: 0.66rem; }
  /* The gap under the dates was two margins stacked -- the timeline's and
     the summary block's -- 43px before anything started. */
  #detail-visual-summary { margin-bottom: 0.9rem; }
  #detail-visual-summary .summary-timeline { margin-bottom: 0; }
  /* A RULE BETWEEN THE DATES AND WHERE YOU HAVE IT (Luke, 29 Sep: "more
     separation ... maybe a line"). The dates are the game's history;
     below the rule is about your copy of it. The rule goes on the first
     section that is actually showing: Played on / Subscription when
     either is, otherwise Available on -- never a rule over nothing. */
  .game-detail-cover .detail-where:has(> :not([hidden])),
  .game-detail-cover .detail-where:not(:has(> :not([hidden]))) + .look-it-up {
    border-top: 1px solid var(--hairline);
    padding-top: 0.9rem;
  }
  .game-detail-cover .detail-price { font-size: 0.7rem; margin-top: 0.4rem; }

  /* STATS AS AN EVEN 2x2, filling the width. The desktop rule is
     repeat(auto-fit, minmax(150px, 230px)), and auto-fit counts columns
     using the 230px MAXIMUM -- two of those do not fit 351px, so a phone
     got ONE column of 230px tiles hugging the left edge (Luke, 23 Sep:
     "needs to be more centered"). Two equal columns; the odd-last-tile
     rule above still spans a lone fifth. */
  .game-detail-stat-row {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    justify-content: stretch;
    padding-left: 0;
    padding-right: 0;
    /* The dates sit closer under the tiles (Luke, 29 Sep): 24px -> 12px. */
    margin-bottom: 0.75rem;
  }

  /* THE DATES ON ONE LINE. They wrapped, leaving "Finished" alone on a
     second line under "Released". With arrows between them (29 Sep) there
     is no rule to stretch, so the row keeps its own spacing, a notch
     tighter, and centres like the rest of the phone page. */
  .summary-timeline { flex-wrap: nowrap; justify-content: center; gap: 0.6rem; }
  .summary-timeline .timeline-point { flex: none; }
  .summary-timeline .timeline-date { font-size: 0.76rem; white-space: nowrap; }

  /* The rating CENTRED.
     It sat left in a column that is now the full screen width, so a
     7.5 the height of a thumb hung off one edge with a screenful of
     nothing beside it. Centred it reads as the headline it is. */
  .review-block {
    flex-direction: column;
    align-items: center;
  }
  .review-rating {
    align-items: center;
    text-align: center;
    /* ITS OWN HEIGHT, NOT 200px (Luke, 29 Sep: "move the review a little
       up, closer to the rating"). The desktop rule's `flex: 0 0 200px`
       is a 200px-wide column beside the review; stacked in a column here
       the same basis became a 200px HEIGHT around ~108px of number and
       pips -- 60px of empty band between the pips and the review. */
    flex: none;
    /* Clear of the "Rating and review" title: the number is stretched
       upward (scaleY(1.25), from its foot), so ~20px of it draws above
       its own box -- into the title, once the box stopped being 200px. */
    padding-top: 1.3rem;
  }
  .review-rating .rating-pips { justify-content: center; }
  /* A SECTION LINE between "Available on" and "Rating and review" (Luke,
     29 Sep) -- the store grid ran straight into the next heading. */
  .review-head {
    border-top: 1px solid var(--hairline);
    padding-top: 1.1rem;
  }
  /* PROFILE'S SECTIONS, CLOSER (Luke, 29 Sep: "too much space between
     these ... tighten it up between sections"). Between a row of tiles and
     the next heading sat the row's 8px bottom padding, the column's 16px
     gap and, in a mouse-driven window, the row's reserved scrollbar.
     Each is trimmed: the gap to 0.6rem, the padding to 0.2rem, the
     heading's own margin under it to 0.5rem, and no scrollbar (the rows
     swipe, and on a real phone never showed one). */
  html body #view-profile .profile-body { gap: 0.6rem; }
  html body #view-profile .profile-main .card-row-title { margin-bottom: 0.5rem; }
  html body #view-profile .profile-tiles { padding-bottom: 0.2rem; scrollbar-width: none; }

  /* No hover on a phone, so the pencil is always there. */
  .review-edit-btn { opacity: 1; }
  /* THE TOOLBAR ON ONE LINE (Luke, 29 Sep). At desktop size its seven
     pieces need ~390px; the Top 5 editor gives them 303px on a phone, so it
     wrapped twice and Notes landed alone under a stray divider. Smaller
     type, tighter padding, and the list/quote glyphs dropped (the words
     stay) fit it in one row. */
  .review-toolbar { flex-wrap: nowrap; gap: 0.2rem; }
  .review-toolbar .btn { font-size: 0.72rem; padding: 0.3rem 0.45rem; min-width: 1.7rem; }
  .review-toolbar .fmt-glyph { display: none; }
  .review-toolbar .toolbar-sep { margin: 0.15rem 0.1rem; }
  /* The footer stacked and centred: two columns do not fit 375px. */
  .site-footer {
    grid-template-columns: 1fr;
    grid-template-areas: "meta" "fine";
    justify-items: center;
    text-align: center;
  }
  .footer-fine { text-align: center; }
  .review-body { width: 100%; }
  /* CENTRED, AT ITS OWN SIZE (Luke, 29 Sep: "the add review is way too
     wide"). It was full width so it would not sit left-aligned under a
     centred score; auto side margins centre it without stretching it, so
     "+ Add review" matches "+ Add rating" above it.
     `display: block` + auto margins, NOT `align-self` -- `.review-body`
     is a plain block, so an align-self on its child does nothing. */
  .review-add-btn {
    display: block;
    width: auto;
    /* Room below as well as above (1 Oct: "add review button now too
       low"): the block has no bottom padding, so with nothing under the
       button it sat on the rule of the section after it. */
    margin: 1rem auto 1rem;
  }

  /* THE GAME PAGE ON A PHONE, FOUR THINGS (#153, 1 Oct).

     1. A LONE DATE IS ONE ROW, WHERE THE DATES ARE (reversed 3 Oct). A
        game with no start and no finish had "Released / Sep 24, 2026"
        stacked alone under the stat tiles, like a fifth tile without its
        box. It was moved to the byline for two days, which gave a
        one-date game a different page shape from every other. It stays
        under the tiles, in the timeline's own type, as label THEN date
        on one line. */
  .summary-timeline.is-solo .timeline-point {
    flex-direction: row;
    align-items: baseline;
    gap: 0.5rem;
  }

  /* 2. THE EMPTY STATES, TIGHTENED. With no rating and no review the two
        invitations were stacked 103px apart with nothing between them;
        side by side they are one row of two things you can do. And "No
        playthrough dates recorded." stood in 102px of padding under its
        heading: it is one line, left with the heading it belongs to. */
  .review-block:has(#rating-add-btn:not([hidden])):has(#review-add-btn:not([hidden])) {
    flex-direction: row;
    flex-wrap: wrap;
    justify-content: center;
    align-items: center;
    gap: 0.6rem;
  }
  .review-block:has(#rating-add-btn:not([hidden])):has(#review-add-btn:not([hidden])) :is(.review-rating-col, .review-body) {
    width: auto;
    flex: 0 0 auto;
  }
  .review-block:has(#rating-add-btn:not([hidden])):has(#review-add-btn:not([hidden])) .review-add-btn {
    margin: 0.5rem 0;
  }
  /* The rating's readout is an empty box with a line's height while there
     is no rating; under "+ Add rating" it made that column taller than
     the one beside it, and the two buttons sat 10px out of line. */
  .review-block:has(#rating-add-btn:not([hidden])):has(#review-add-btn:not([hidden])) .review-rating:empty {
    display: none;
  }
  #playthroughs-list .empty-state {
    padding: 0.1rem 0 0.2rem;
    margin: 0;
    text-align: left;
  }

  /* 3. THE REVIEW CARD'S FOOTER. "Edit" on the row with the share switch,
        not a pencil over the card's first line; and "Read the full
        review" on the card's bottom edge, half over the fade, where it
        sat a line and a half up over words you were reading. */
  #review-edit-btn { display: none; }
  /* "Edit" at the far right of the RATING AND REVIEW heading (3 Oct),
     only while there is a review: the pencil's own hidden state says so,
     read from the page since the two are no longer in one box. */
  #view-game-detail:has(#review-edit-btn:not([hidden])) .review-head .review-edit-text {
    display: inline-flex;
    margin-left: auto;
  }
  .review-actions { align-items: stretch; max-width: none; }
  .review-more-btn { align-self: center; margin-top: -1rem; }
  .review-footer-row {
    display: flex;
    align-items: center;
    gap: 0.75rem;
  }
  /* The gap above the row only while there is a review to have a row
     for. On the row itself it was 10px of nothing under "+ Add review",
     which put that button 5px out of line with "+ Add rating". */
  .review-body:has(#review-edit-btn:not([hidden])) .review-footer-row { margin-top: 0.6rem; }
  .review-footer-row .review-share-toggle { margin-left: auto; }
  .review-actions .review-more-btn:not([hidden]) + .review-footer-row .review-share-toggle { margin-top: 0; }

  /* 4. A PLAYTHROUGH IS ITS LABEL OVER ITS DATES. Side by side the label
        took 9rem of a 351px row and "Mar 18, 2019 - Apr 28, 2023" broke
        in the middle of the range. */
  .playthrough-line { flex-direction: column; align-items: flex-start; gap: 0.15rem; }
  .playthrough-label { min-width: 0; }

  /* The review reads in slightly smaller type on a phone, with the quote
     mark's margin narrowed to suit (Luke, 29 Sep: "a little easier to
     read"). 16px at 1.7 line height, with a 50px left margin, set about
     eight words to a line. */
  .review-read {
    font-size: 0.9rem;
    line-height: 1.6;
    padding: 0.9rem 1rem 0.95rem 2.5rem;
  }
  /* The quote mark a size smaller, to fit the narrower margin: at full
     size it reached into the text, and into a heading on the first line
     ran straight through it (Luke's "SECTION", 29 Sep). */
  .review-read::before { left: 0.5rem; font-size: 2.1rem; top: 0.5rem; }

  /* Still two rows, still a shape: five across on a phone is a 60px
     icon, which is small but legible, and breaking to 3+3+3+1 would
     lose the arrangement the ten were chosen in. */
  .achievement-showcase { gap: 0.35rem; }

  /* On a phone it docks to the bottom instead of floating. A 320px panel
   dragged around a 390px screen has nowhere to go, and it would cover
   the box you are typing in. */

  .notes-float {
    left: 0.5rem !important;
    right: 0.5rem;
    top: auto !important;
    bottom: 0.5rem;
    width: auto;
    max-height: 45vh;
    resize: none;
  }
  .notes-float-head { cursor: default; }
  /* IN THE PAGE (29 Sep): opened from a writing toolbar, the panel moves
     under it (openNotesFloat) and sits in the flow -- the keyboard cannot
     cover it, and it scrolls with what you are writing. The inline
     sizes beat the desktop ones the drag and the first open set. */
  .notes-float.is-inline {
    position: static;
    left: auto !important;
    right: auto;
    top: auto !important;
    bottom: auto;
    width: auto !important;
    height: auto !important;
    max-height: 40vh;
    margin: 0 0 0.5rem;
    box-shadow: none;
  }

  /* The tab sub-navs, tighter. Four labels at desktop spacing run past a
     375px screen; this fits "Library  Your Top Games  Tiers  Gaming
     Tiles" on one line, and the row scrolls if a narrower one cannot. */
  /* (The row's gap and type size are set with the lit buttons below.) */

  /* A TAB'S VIEWS: A ROW OF WORDS AT THE TOP OF ITS PAGE (#171, 2 Oct; the
     decision is under "Shape four" above). From 29 Sep they were the
     bottom bar. They are in the flow now, first thing in the tab's panel,
     which is where the markup always had them -- so they still hide with
     the panel and nothing has to know.

     THE LOOK, chosen on the real page from thirteen (pills, icons over
     words, a menu, four segmented controls and three other underlines
     went). The open view is white over a short bar in the menu colour, the
     others muted, and there is NO RULE ACROSS THE ROW ("I just like how
     there is no super long line covering all the tabs at once") -- the one
     thing taken off the desktop's shape, whose base rule draws that
     hairline and lights a piece of it. Each button is only as wide as its
     word, so the bar under it is the word's width.

     AUTO MARGINS, not justify-content: space-around. They share out the
     spare room the same way while there is some, and collapse to nothing
     when there is none -- so on a screen too narrow for the four words the
     row starts at its left edge and scrolls (the base rule's overflow-x),
     where space-around would centre it and put the first word out of
     reach. */
  .gamelib-nav,
  .playnext-nav {
    gap: 0;
    border-bottom: none;
    /* STUCK TO THE TOP OF THE SCREEN AS YOU SCROLL (2 Oct, the same day it
       moved up here: "the new bars at the top need to be sticky like the
       old pane was"). The tab row it replaced stuck alone once the
       wordmark had scrolled off (#134), so the views were always one tap
       away; this row is in the page's flow and would have scrolled off
       with everything else. Sticky at 0: the header above it is not
       sticky any more (#171), so nothing is left to clear.
       FULL BLEED: pulled out by main's 0.75rem side padding and padded
       back, so the fill covers the whole width and nothing shows at the
       edges as the page passes under it. Under the header (30) and every
       sheet.

       OPAQUE, AND OVER EVERYTHING IT PASSES (#180, 5 Oct; first noticed on
       Games in #129, 3 Oct). Card names and stats scrolled under the bar
       could be read through it ("~24h", "19 weeks ago"). Two faults:
       - The fill was the header's NEAR-solid one, rgba(--bar-rgb, 0.96).
         4% of white 12px text through a dark bar is legible, and the
         header can afford it because nothing scrolls under the header.
         The same token at full strength now: same colour, no glass.
       - It sat at z 2 on the claim that "the covers and cards it passes
         are z 1 at most". They are not: the Top Games rank badge and the
         calendar's owned pill are z 2, and coming later in the page they
         paint OVER an equal z; the star on a ranked card and the
         calendar's arrows and slide count are z 3. z 4 clears all of
         them and is still under the header and every sheet. */
    position: sticky;
    top: 0;
    z-index: 4;
    margin: 0 -0.75rem 0.6rem;
    padding: 0 0.75rem;
    background: rgb(var(--bar-rgb));
  }
  .gamelib-nav .view-toggle-btn,
  .playnext-nav .view-toggle-btn {
    flex: 0 0 auto;
    position: relative;
    margin: 0 auto;
    padding: 0.5rem 0 0.6rem;
    border: none;
    border-radius: 0;
    font-size: 0.72rem;
    font-weight: 600;
  }
  .gamelib-nav .view-toggle-btn.is-active,
  .playnext-nav .view-toggle-btn.is-active { color: var(--text); }
  .gamelib-nav .view-toggle-btn.is-active::after,
  .playnext-nav .view-toggle-btn.is-active::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    bottom: 1px;
    height: 3px;
    border-radius: 3px;
    background: var(--menu);
  }

  /* The queue's adding bar (#150) clears the bottom bar. */
  .queue-add-bar { bottom: calc(var(--tabbar-h) + 0.6rem + env(safe-area-inset-bottom, 0px)); }

  /* (The header band's bottom used to be tightened here for Games and Play
     Next only. It is tightened on every page now -- see .hero and
     .hero-title above.) */

  /* No seam on a phone: the site tabs are the bottom bar, so there is no
     tab above the tray for it to light up under. And the tray scrolls
     sideways if the views do not fit -- safe here, with no seam to clip. */
  .banner-subnav-row .banner-subnav {
    overflow-x: auto;
    scrollbar-width: none;
  }
  .banner-subnav-row .banner-subnav::before,
  .banner-subnav-row .banner-subnav::after { display: none; }
  /* Sized so Games' FIVE views fit a 375px screen without scrolling. Four
     fitted at 0.78rem / 0.6rem; Analysis joining pushed "Gaming Tiles"
     off the edge, so both came down a notch. (These rules were lost for
     one commit on 22 Sep, cut along with a search rule they sat inside,
     which is how the overflow was noticed.) */
  .banner-subnav-row .banner-subnav { padding: 0.2rem; gap: 0; }
  .banner-subnav-row .banner-subnav .view-toggle-btn {
    padding: 0.4rem 0.36rem;
    font-size: 0.72rem;
  }

  /* The library actions on a phone: a little tighter, and each one
     stretches to fill its line. At desktop size Add game and "Games to
     rate or review" needed 363px of a 351px row, so all three wrapped
     onto a line each -- three stacked rows of mismatched widths. Now it
     is two even rows: Add game and rate/review, then clean up. */
  /* ALL THREE ON ONE ROW (Luke, 23 Sep). They were two rows -- Add game
     and rate/review, then Clean up on its own -- because the full labels
     need more than a phone's width. Shorter labels on a phone ("Rate &
     review", "Clean up"; the long ones stay in the markup for desktop)
     let the three share a line. nowrap so a narrow phone shrinks the
     buttons rather than dropping one onto a second row. */
  /* THE SAME THREE COLUMNS AS THE FILTER ROW UNDER IT (#140, 30 Sep). The
     buttons were a flex row sized to their labels -- Add game 107px, Rate
     & review 130, Clean up 101 -- over three equal dropdowns, so the two
     rows' edges never met. A grid of equal thirds with the filters' gap,
     and the buttons trimmed to fit a third at 375px. */
  .library-actions {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 0.4rem;
  }
  .library-actions .lib-action {
    height: 2.25rem;
    padding: 0 0.3rem;
    gap: 0.3rem;
    font-size: 0.74rem;
    justify-content: center;
    white-space: nowrap;
    min-width: 0;
  }
  .library-actions .lib-action-icon { width: 14px; height: 14px; }
  /* Three buttons on one line in the game page's top bar at 375px: Back,
     Edit game details and + Mark completed. The full label at a smaller
     size (1 Oct: "mark completed at a smaller font", over a short label),
     and the height held to its neighbours'. */
  .game-detail-topbar,
  .game-detail-topbar-right { flex-wrap: nowrap; }
  .game-detail-topbar .btn { white-space: nowrap; }
  .game-detail-topbar .mark-completed-btn {
    font-size: 0.7rem;
    padding: 0.45rem 0.45rem;
    min-height: 2rem;
    box-sizing: border-box;
    margin-right: 0.2rem;
  }
  .lib-label-long { display: none; }
  .lib-label-short { display: inline; }
  .library-actions > .add-game-wrap { min-width: 0; }
  .library-actions > .add-game-wrap > .lib-action { width: 100%; }


  /* THE NOTE EDITOR AS A SHEET ON THE KEYBOARD. See trackSheetViewport in
     app.js for the viewport arithmetic. Full width, pinned to the bottom
     of the VISIBLE screen -- which is the top of the keyboard when it is
     open -- and never taller than what is visible, so the box you are
     typing in and the Save button stay on screen.

     !important because the desktop window is placed and sized by inline
     styles (positionNoteLayer, placeNoteFloat, restoreNoteSize, and the
     browser's resize handle); on a phone none of those apply, and an
     inline left/top/width would drag the sheet off its dock. */
  .note-popover {
    left: 0 !important;
    right: 0 !important;
    top: auto !important;
    bottom: calc(100% - var(--sheet-top, 0px) - var(--sheet-vvh, 100%)) !important;
    width: 100% !important;
    min-width: 0;
    max-width: none;
    max-height: calc(var(--sheet-vvh, 100dvh) - 8px);
    /* ALWAYS THE WHOLE VISIBLE AREA, THE PICKER TOO (#169, 2 Oct). Only the
       writing step had a height; the picker took its natural one, docked
       to the keyboard from below -- so a short list left the sheet's top
       edge mid-screen, and where that was depended on how many games were
       in it and on whether the keyboard had arrived yet ("it sometimes
       sits mid-screen ... other times it's at the top"). With a height the
       top edge is the top of what is visible, every time, and the list
       takes the room (below). */
    height: calc(var(--sheet-vvh, 100dvh) - 8px) !important;
    border-radius: 16px 16px 0 0;
    border-bottom: 0;
    resize: none;
    /* Above the phone's bottom tab bar, which it covers while open. */
    z-index: 70;
    box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.55);
  }
  /* (The desktop window's floor, 220px, would hold the sheet taller than a
     short visible area.) */
  .note-popover.is-writing { min-height: 0; }
  /* The picker fills the sheet: the list takes what the search and the
     link leave, instead of stopping at the desktop's 340px. */
  #note-pick-step:not([hidden]) {
    flex: 1 1 auto;
    display: flex;
    flex-direction: column;
    min-height: 0;
  }
  .note-popover .note-scroll {
    flex: 1 1 auto;
    max-height: none;
    min-height: 0;
  }

  /* THE SHEETS' TABS (#169): two words, the open one white over a short
     bar in the menu colour -- the look chosen for a tab's views the same
     day (.gamelib-nav above), so a row that switches what you are looking
     at reads the same wherever it is. */
  .sheet-tabs {
    display: flex;
    gap: 1.3rem;
  }
  .sheet-tab {
    position: relative;
    padding: 0.3rem 0 0.55rem;
    border: 0;
    background: none;
    font-family: var(--font-body);
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-muted);
    cursor: pointer;
  }
  .sheet-tab.is-active { color: var(--text); }
  .sheet-tab.is-active::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    bottom: 1px;
    height: 3px;
    border-radius: 3px;
    background: var(--menu);
  }

  /* TAKE NOTE: THE BOX ALONE, IN THE TOP HALF (#169). It was one line under
     your earlier notes until you tapped it (23 Sep), and then took what
     the notes left -- about a third of the room above the keyboard on a
     game with eleven notes, and on a game with none it sat at the very
     bottom of an empty screen. Now the notes are the other tab and the
     box has the sheet. Capped rather than filling it: with the keyboard
     down the sheet is the whole screen, and a box that tall would put
     Save at the bottom edge, to jump up when the keyboard arrives. At
     36dvh the box and Save end about where the keyboard's top will be;
     with the keyboard up the box takes what is left above it. */
  .note-popover[data-pane="write"] .note-existing { display: none; }
  #note-popover #note-body {
    flex: 1 1 auto;
    max-height: 36dvh;
    min-height: 4.5rem;
    resize: none;
  }
  /* NOTES: what you wrote, with Edit and Delete, and no box. Save goes
     with the box (there is nothing to save here); what was typed stays in
     it for when Take note is pressed again. */
  .note-popover[data-pane="read"] #note-body,
  .note-popover[data-pane="read"] #note-save { display: none; }
  .note-popover[data-pane="read"] .note-existing {
    flex: 1 1 auto;
    max-height: none;
    min-height: 0;
  }
  .note-existing-empty {
    display: block;
    margin: 0.4rem 0;
    font-size: 0.85rem;
    color: var(--text-faint);
  }
  /* THE PAGE HOLDS STILL WHILE THE SHEET IS OPEN. overscroll-behavior
     only governs a scroll container that has somewhere to scroll; a swipe
     on the sheet's title, the game name or the Save row -- none of them
     scrollable -- went straight to the page underneath. With the editor
     open on a phone the page cannot scroll at all, so there is nothing
     for a stray swipe to move.
     Keyed on the editor being visible, not on a class set by the open
     and close paths: there are several of each, and a lock one of them
     forgot to release would freeze the whole app. That was a `:has()`
     rule here setting `overflow: hidden`; it is applyPageLock (app.js)
     now, which watches `hidden` on this and every other sheet and sets
     html.page-locked, below -- because overflow: hidden never held the
     page under a finger on iOS (#132). */

  /* ===== #132: THE PAGE UNDER A SHEET, AND THE FIELDS IN ONE (30 Sep) ===== */

  /* THE LOCK. `overflow: hidden` on <body> stops a wheel, not a finger:
     iOS Safari scrolls the page under an open sheet regardless (seen on
     the phone, 29 Sep). What holds is taking the page out of the scroll
     altogether: <body> fixed to the viewport, pulled up by the scroll
     position the page had (--page-lock-top, set by applyPageLock, is
     minus that number) so the screen shows exactly what it showed.
     Everything laid out against the document -- the profile's bleed, the
     game page's backdrop -- moves with the body and stays put on screen.
     Unlocking puts the scroll position back (app.js).
     (The header was the one thing that needed help while it was sticky:
     stuck to the top of a scroll that no longer existed, it sat at the
     top of the body, off screen, and a rule here made it relative and
     pushed it back down for the duration. It scrolls with the page since
     #171, so it moves with the body like everything else. The bottom bar
     is fixed to the screen and is not affected either way.) */
  html.page-locked,
  html.page-locked body { overflow: hidden; }
  html.page-locked body {
    position: fixed;
    top: var(--page-lock-top, 0px);
    left: 0;
    right: 0;
    /* And no rubber-band on the page that can no longer scroll. */
    overscroll-behavior: none;
  }

  /* THE KEYBOARD'S HEIGHT IS NOT A VARIABLE ANY MORE (3 Oct). Every sheet
     used `bottom: var(--sheet-kb)`, which app.js worked out as
     window.innerHeight minus the visible height and offset -- and on a
     real phone the note sheet sat about 115px too low: the right height,
     the wrong bottom, with Save under the keyboard (Luke's screenshot,
     2 Oct, after the re-measure of !382 had not helped). A fixed
     element's bottom is measured from its containing block, the layout
     viewport, and whether that is what innerHeight reports while an iOS
     keyboard is up is exactly the kind of thing that changes between
     versions. So the sheets now say it in CSS: bottom is 100% (the
     containing block's own height) less the visible top and the visible
     height -- the two numbers the visual viewport reports directly --
     and no JavaScript idea of the screen's height is involved. Without
     the variables (a desktop) it is 100% - 0 - 100% = 0, as before.
     --sheet-kb is still set, for the ?dev=1 readout. */
  /* ABOVE THE KEYBOARD. Each sheet reads --sheet-kb (keyboard height),
     --sheet-top (how much of the top iOS panned away) and --sheet-vvh
     (visible height) off itself; trackSheetViewport (app.js) sets them,
     and the fallbacks are the whole screen. The notes and Add game sheets
     dock to the keyboard (their own rules); these three fit inside what
     is visible and scroll within it, and the field you tapped is
     scrolled to the middle (the focusin listener in app.js). */
  .cleanup-overlay {
    top: var(--sheet-top, 0px);
    bottom: calc(100% - var(--sheet-top, 0px) - var(--sheet-vvh, 100%));
  }
  .cleanup-modal {
    max-height: 100%;
    overflow-y: auto;
  }
  /* THE REVIEW SHEET HOLDS STILL (#169, 2 Oct). It was taller than the room
     above the keyboard -- two labels, the tools, your notes opened as a
     panel, a nine-line box -- so the rule above made the whole sheet
     scroll, and it slid under the thumb while you typed. Now it is laid
     out to fit: the labels are gone (the placeholders say it), the notes
     are a tab instead of a panel, and the box takes what is left and is
     the only thing that scrolls. From the top of the visible area rather
     than centred in it, so it does not jump when the keyboard comes or
     goes. */
  #rate-overlay { align-items: flex-start; }
  /* CANCEL AND SAVE SIT BESIDE THE SCORE, NOT THE NAME (#169, 2 Oct). In the
     head row (#141) they took half of it on a phone, and the game's name
     was cut to "The Witcher 3: Wild..." -- the one thing on the sheet that
     says which game you are scoring. The score box is 6rem wide in a row
     of its own, so the room to its right was empty; the buttons go there
     and cost nothing. Chosen on the real page over the end of the tabs'
     row and a row of their own under the box. The name has the head to
     itself and wraps to a second line if it must. A desktop keeps the
     head row, where there is room for both.

     A GRID, so the markup does not have to move: the head stops being a
     box (display: contents) and its two children are placed with the
     rest -- the name across the top, the score and the buttons sharing
     the second row, the review field and the message under them. The
     review's row is the one that gives when the keyboard comes up
     (minmax(0, 1fr)), which is what the flex column did with flex-shrink
     before. */
  #rate-overlay .cleanup-modal {
    overflow: hidden;
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-rows: auto auto minmax(0, 1fr) auto;
  }
  #rate-overlay .cleanup-head { display: contents; }
  #rate-overlay #rate-title {
    grid-column: 1 / -1;
    grid-row: 1;
    white-space: normal;
    overflow: visible;
  }
  #rate-overlay .rate-field:not(.rate-field-review) {
    grid-column: 1;
    grid-row: 2;
  }
  #rate-overlay .rate-actions {
    grid-column: 2;
    grid-row: 2;
    justify-self: end;
    align-self: center;
  }
  #rate-overlay #rate-message {
    grid-column: 1 / -1;
    grid-row: 4;
  }
  .rate-field-label { display: none; }
  .rate-field-review {
    grid-column: 1 / -1;
    grid-row: 3;
    min-height: 0;
  }
  /* THE COMPLETION LINE HAS A ROW UNDER THE NAME, ONLY WHILE IT SHOWS
     (#174, 4 Oct). Left to place itself it took a fifth row at the foot
     of the sheet, under the review box -- after the things it should be
     read before: it says the game is already completed, and (for a game
     with no play record) asks the one question that is not about your
     opinion. The grid gains the row only when the line is there, so a
     plain rate-or-review keeps its four rows and no empty gap. */
  #rate-overlay .cleanup-modal:has(#rate-completion:not([hidden])) {
    grid-template-rows: auto auto auto minmax(0, 1fr) auto;
  }
  #rate-overlay .rate-completion {
    grid-column: 1 / -1;
    grid-row: 2;
  }
  #rate-overlay .cleanup-modal:has(#rate-completion:not([hidden])) .rate-field:not(.rate-field-review),
  #rate-overlay .cleanup-modal:has(#rate-completion:not([hidden])) .rate-actions { grid-row: 3; }
  #rate-overlay .cleanup-modal:has(#rate-completion:not([hidden])) .rate-field-review { grid-row: 4; }
  #rate-overlay .cleanup-modal:has(#rate-completion:not([hidden])) #rate-message { grid-row: 5; }
  .rate-field .review-toolbar .toolbar-sep,
  .rate-field .review-toolbar .notes-open-btn { display: none; }
  /* The box: 40dvh when there is room, less when the keyboard is up, and
     never under five lines. The notes list is the same height plus the
     tools' row, so switching tabs does not resize the sheet. */
  #rate-review {
    flex: 0 1 auto;
    height: 40dvh;
    min-height: 5rem;
    resize: none;
  }
  .rate-field-review[data-pane="read"] .review-toolbar,
  .rate-field-review[data-pane="read"] #rate-review { display: none; }
  .rate-field-review[data-pane="read"] .rate-notes {
    display: block;
    flex: 0 1 auto;
    height: calc(40dvh + 2.4rem);
    min-height: 5rem;
    overflow-y: auto;
    overscroll-behavior: contain;
  }
  /* Centred in the visible band rather than the screen: 50% of the
     layout viewport is under the keyboard once it is up. */
  .game-detail-fields.is-editing {
    top: calc(var(--sheet-top, 0px) + var(--sheet-vvh, 100dvh) / 2);
    max-height: calc(var(--sheet-vvh, 100dvh) - 2rem);
  }

  /* NO ZOOM ON FOCUS. iOS zooms the page into any field whose text is
     under 16px, and that zoom is what threw a sheet off the keyboard it
     was docked to. A floor on the text, not `maximum-scale` in the
     viewport tag, which would take pinch-zoom away from everyone.
     !important because some thirty rules size fields for their rows,
     several by id, and the floor has to beat every one of them. Left out
     on purpose: the rank badge on a Top 100 cover and an award tile's
     label, both sized to the art they sit on, and the tier title, which
     is over 16px already. The notes sheet had this rule first (below).
     AND THE LIBRARY ROW'S TWO CONTROLS, the status select and the rating
     box (Luke, 30 Sep, at 16px: "look kinda bad now"). They are sized to a
     row that the audit's Games tasks are going to redraw; until then they
     keep their size and iOS will still zoom on them. */
  input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"], [type="button"], [type="submit"], .top100-grid-rank-badge, .award-label-input, .tiles-title-input, #library-table *),
  select:not(#library-table select),
  textarea {
    font-size: 16px !important;
  }

  /* The viewport numbers behind ?dev=1 (showSheetReadout, app.js): one
     line at the very top, over every sheet, so a phone screenshot with
     the keyboard up carries them. */
  .sheet-dev-readout {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 1000;
    padding: 2px 6px;
    font: 600 11px/1.3 var(--font-mono);
    color: #fff;
    background: rgba(200, 40, 120, 0.85);
    pointer-events: none;
  }
  /* The title bar is a handle on desktop; here it is just a title. */
  .note-popover .note-float-head { cursor: default; touch-action: auto; }
  /* 16px so iOS does not zoom the page in when the box takes focus -- it
     zooms any field under 16px, which would throw the sheet off the
     keyboard it is docked to. */
  .note-popover textarea,
  .note-popover input { font-size: 16px; }

  /* RANK NUMBERS AND STARS, SIZED FOR A PHONE COVER. At desktop size the
     rank badge was 21x23px and the star 20px on an 82px cover -- the two
     together took the top quarter of the art (Luke, 23 Sep, Top 25 and
     Tiers). About half that is still legible and leaves the art alone.
     The input form of the badge (editable ranks) keeps room for three
     digits: every Top 100 has a rank 100. */
  #view-top100 .top100-grid-rank-badge {
    top: 3px;
    left: 3px;
    padding: 0 0.22rem;
    font-size: 0.6rem;
    line-height: 1.35;
    border-radius: 3px;
  }
  #view-top100 input.top100-grid-rank-badge { width: 1.9rem; }
  #view-top100 .top100-rate-btn {
    top: 3px;
    right: 3px;
    width: 0.95rem;
    height: 0.95rem;
    font-size: 0.55rem;
  }

  /* THE FILTERS FIT, AND SEARCH GOES UNDER THEM. The three dropdowns were
     a row that scrolled sideways, with the search off the end of it. Now
     they split the width three ways and the search takes a full line
     below -- it is the control you type into, so it gets the room.
     Scoped to the Library: other views' .controls keep the scroll. */
  #view-library .controls {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 0.4rem;
    overflow: visible;
    padding-bottom: 0;
  }
  /* Right padding kept small: the native select draws its own arrow in
     addition to the padding, so 1.4rem of it cost "All statuses" its last
     letters at 375px. */
  #view-library .controls select {
    min-width: 0;
    width: 100%;
    font-size: 0.72rem;
    padding-left: 0.45rem;
    padding-right: 0.2rem;
    text-overflow: ellipsis;
  }
  /* SMALLER TO THE EYE, 16px TO iOS (30 Sep: "the letters are huge"). The
     16px floor (#132) is what stops iOS zooming into a field on focus, and
     it made these three and the search below them 16px, which on a 113px
     column is a wall of type. The font is still 16px -- iOS reads the
     unscaled size -- and the control is DRAWN at 13/16 of it, laid out
     16/13 wide so the scaled box fills its column exactly. Transforms do
     not move focus, the picker or the hit-test. Origin left-centre: the
     left edge stays on the column's, and the lost height splits above
     and below. */
  #view-library .controls select,
  #view-library .controls #search {
    transform: scale(0.8125);
    transform-origin: left center;
    width: 123.08%;
  }
  /* The search is wrapped (.search-wrap, for its icon), so the WRAPPER is
     the grid item that has to span the row. */
  #view-library .controls .search-wrap {
    grid-column: 1 / -1;
    min-width: 0;
    width: 100%;
  }
  #view-library .controls #search {
    min-width: 0;
    /* 16/13 of the wrapper, scaled back to it above -- not 100%, which
       left the drawn box 13/16 of the row. flex: none, because the wrapper
       is a flex row and a flex item shrinks to it whatever width it is
       given -- which is how 123.08% measured as 100%. */
    width: 123.08%;
    flex: none;
  }

  /* NOTES ROWS ON A PHONE. With the Playing tag, the count and "Add note"
     all on one line, the name was squeezed to "R..." -- 27px of a 349px
     row. The name may wrap to two lines, and Add note shrinks to its
     pencil (its aria-label still says "Add a note to <game>"). */
  #view-notes .notes-game-head { gap: 0.55rem; padding: 0.6rem 0.7rem; }
  #view-notes .notes-game-name {
    flex: 1 1 auto;
    white-space: normal;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    line-height: 1.25;
  }
  #view-notes .notes-game-count { margin-left: 0; }
  #view-notes .notes-add-label { display: none; }
  #view-notes .notes-add-btn { padding: 0 0.55rem; }

  /* SOLID, NOT FROSTED, ON A PHONE (29 Sep). The tiles, the side panel and
     the spotlight were glass -- a backdrop blur over the banner wash, one
     per tile. Every blurred element is re-blurred as the page scrolls, and
     on a phone that is the cost of scrolling Profile. A denser fill does
     the blur's legibility job without it: 78% panel against the glass's
     40%, and the spotlight's fill raised to match. Desktop keeps the
     glass (the min-width: 641px block). Chosen with the bottom bar's (see
     .view-tabs above) from three options on the real page,
     proto/mobile-glass; Luke: whichever performs best.
     Inside the side panel the spotlight keeps no fill of its own -- the
     later `.profile-side .spotlight-block` rule still sees to that. */
  #view-profile .profile-tile,
  #view-profile #profile-side {
    background: color-mix(in srgb, var(--bg-panel) 78%, transparent);
  }
  .spotlight-block {
    background: rgba(32, 26, 40, 0.7);
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}

/* ======================================================================
   THEMES (26 Sep)

   Nine, chosen by Luke from palettes compared side by side on the real
   page; all nine ship as a choice in the settings menu (profiles.theme,
   0167), Sunset the default. SUNSET IS :root -- the values at the top of
   this file -- so it needs no block here, and a signed-out page (the
   login screen) is always Sunset.

   Each block sets EVERY theme token; tools/theme-tokens-check.mjs fails
   if one is missing, because a missing token would silently show that
   one colour in Sunset's. Adding a theme: a block here, its name in the
   0167 check constraint (a new migration), and in THEMES in app.js.

   Each theme has the same three roles as Sunset: an "act" colour
   (buttons, tabs, amber tints -- --act and --gold), a "you are in this"
   colour (the side menu -- --menu), and a wordmark gradient.
   ====================================================================== */

/* ARCADE: deep navy, cyan to act, magenta for where you are. */
html[data-theme="arcade"] {
  --bg: #0c0f1d; --bg-panel: #151a2e; --bg-panel-raised: #1d2340; --hairline: #2c3458;
  --text: #eef3ff; --text-muted: #a9b6dc; --text-faint: #7d89b0;
  --sun-gold: #a8f0f8; --sun-amber: #4fd6e8; --sun-pink: #f56bb0; --sun-violet: #b18ce0; --sun-violet-rgb: 177, 140, 224;
  --gold: #4fd6e8; --gold-rgb: 79, 214, 232;
  --act: #4fd6e8; --act-rgb: 79, 214, 232; --act-light: #7ce3f0; --act-deep: #2a8fa0; --act-ink: #041a1f;
  --menu: #f56bb0; --menu-rgb: 245, 107, 176; --menu-light: #f88cc3; --menu-deep: #b33d7c; --menu-ink: #2a0616;
  --bg-rgb: 12, 15, 29; --bar-rgb: 12, 15, 29;
  --wm-grad-v: linear-gradient(180deg, #a8f0f8 0%, #4fd6e8 35%, #b18ce0 70%, #f56bb0 100%);
  --wm-grad-h: linear-gradient(90deg, #4fd6e8, #b18ce0 55%, #f56bb0);
  --btn-primary-end: #7ce3f0; --btn-primary-ink: #041a1f;
  --glow-warm-rgb: 245, 107, 176; --glow-cool-rgb: 177, 140, 224;
}

/* MOSS: green-charcoal, a warm lime-gold to act, teal for where you are. */
html[data-theme="moss"] {
  --bg: #0f1511; --bg-panel: #18211b; --bg-panel-raised: #202c24; --hairline: #2f3f33;
  --text: #f2f5ec; --text-muted: #b8c7b0; --text-faint: #8a9b85;
  --sun-gold: #efe3a0; --sun-amber: #d8c257; --sun-pink: #4fc3b5; --sun-violet: #8fd18c; --sun-violet-rgb: 143, 209, 140;
  --gold: #d8c257; --gold-rgb: 216, 194, 87;
  --act: #d8c257; --act-rgb: 216, 194, 87; --act-light: #e4d27a; --act-deep: #9a8a30; --act-ink: #221c04;
  --menu: #4fc3b5; --menu-rgb: 79, 195, 181; --menu-light: #74d1c6; --menu-deep: #2e8a7f; --menu-ink: #04201c;
  --bg-rgb: 15, 21, 17; --bar-rgb: 15, 21, 17;
  --wm-grad-v: linear-gradient(180deg, #efe3a0 0%, #d8c257 35%, #8fd18c 70%, #4fc3b5 100%);
  --wm-grad-h: linear-gradient(90deg, #d8c257, #8fd18c 55%, #4fc3b5);
  --btn-primary-end: #e4d27a; --btn-primary-ink: #221c04;
  --glow-warm-rgb: 79, 195, 181; --glow-cool-rgb: 143, 209, 140;
}

/* OCEAN: blue night, coral to act, sky for where you are. The wordmark
   runs through five stops -- with four it read as one colour (Luke). */
html[data-theme="ocean"] {
  --bg: #0e1320; --bg-panel: #172033; --bg-panel-raised: #1f2a42; --hairline: #2e3b58;
  --text: #f3f6fc; --text-muted: #aebbd6; --text-faint: #8190ad;
  --sun-gold: #ffe7b5; --sun-amber: #f09a6e; --sun-pink: #6fa8f0; --sun-violet: #a274d8; --sun-violet-rgb: 162, 116, 216;
  --gold: #f09a6e; --gold-rgb: 240, 154, 110;
  --act: #f09a6e; --act-rgb: 240, 154, 110; --act-light: #f4b38f; --act-deep: #b0633f; --act-ink: #2a1206;
  --menu: #6fa8f0; --menu-rgb: 111, 168, 240; --menu-light: #8fbdf4; --menu-deep: #3f73b3; --menu-ink: #06152a;
  --bg-rgb: 14, 19, 32; --bar-rgb: 14, 19, 32;
  --wm-grad-v: linear-gradient(180deg, #ffe7b5 0%, #f7a072 28%, #e8729a 52%, #a274d8 76%, #5aa9f5 100%);
  --wm-grad-h: linear-gradient(90deg, #ffd29a 0%, #f7a072 25%, #e8729a 50%, #a274d8 75%, #5aa9f5 100%);
  --btn-primary-end: #f4b38f; --btn-primary-ink: #2a1206;
  --glow-warm-rgb: 111, 168, 240; --glow-cool-rgb: 162, 116, 216;
}

/* PAPER & INK: neutral charcoal, sand to act, a signal red for where you are. */
html[data-theme="ink"] {
  --bg: #111113; --bg-panel: #1b1b1f; --bg-panel-raised: #242429; --hairline: #34343b;
  --text: #f4f1ea; --text-muted: #bdb8ad; --text-faint: #8d897f;
  --sun-gold: #ffffff; --sun-amber: #e8d7b0; --sun-pink: #e4574c; --sun-violet: #bdb8ad; --sun-violet-rgb: 189, 184, 173;
  --gold: #e8d7b0; --gold-rgb: 232, 215, 176;
  --act: #e8d7b0; --act-rgb: 232, 215, 176; --act-light: #f0e4c8; --act-deep: #a8976f; --act-ink: #1c170c;
  --menu: #e4574c; --menu-rgb: 228, 87, 76; --menu-light: #ea7a71; --menu-deep: #a3362d; --menu-ink: #2a0806;
  --bg-rgb: 17, 17, 19; --bar-rgb: 17, 17, 19;
  --wm-grad-v: linear-gradient(180deg, #ffffff 0%, #f4f1ea 45%, #e8d7b0 100%);
  --wm-grad-h: linear-gradient(90deg, #f4f1ea, #e8d7b0);
  --btn-primary-end: #f0e4c8; --btn-primary-ink: #1c170c;
  --glow-warm-rgb: 228, 87, 76; --glow-cool-rgb: 189, 184, 173;
}

/* EMBER: a warm near-black, red-orange to act, gold for where you are. */
html[data-theme="ember"] {
  --bg: #15100d; --bg-panel: #201813; --bg-panel-raised: #2a1f18; --hairline: #3d2e24;
  --text: #fbf3ea; --text-muted: #d4bfae; --text-faint: #a08b7b;
  --sun-gold: #ffe08a; --sun-amber: #f26b3a; --sun-pink: #f2b84b; --sun-violet: #c93a2a; --sun-violet-rgb: 201, 58, 42;
  --gold: #f26b3a; --gold-rgb: 242, 107, 58;
  --act: #f26b3a; --act-rgb: 242, 107, 58; --act-light: #f58a62; --act-deep: #a8431f; --act-ink: #2a0e04;
  --menu: #f2b84b; --menu-rgb: 242, 184, 75; --menu-light: #f5c872; --menu-deep: #a87a22; --menu-ink: #2a1a04;
  --bg-rgb: 21, 16, 13; --bar-rgb: 21, 16, 13;
  --wm-grad-v: linear-gradient(180deg, #ffe08a 0%, #f2b84b 35%, #f26b3a 70%, #c93a2a 100%);
  --wm-grad-h: linear-gradient(90deg, #f2b84b, #f26b3a 55%, #c93a2a);
  --btn-primary-end: #f58a62; --btn-primary-ink: #2a0e04;
  --glow-warm-rgb: 242, 184, 75; --glow-cool-rgb: 201, 58, 42;
}

/* GLACIER: cold slate, ice blue to act, mint for where you are. */
html[data-theme="glacier"] {
  --bg: #0e141a; --bg-panel: #162029; --bg-panel-raised: #1e2a35; --hairline: #2d3c4a;
  --text: #f1f7fb; --text-muted: #aec3d2; --text-faint: #7f95a6;
  --sun-gold: #ffffff; --sun-amber: #8fd3ff; --sun-pink: #7ee0c3; --sun-violet: #b3e1ff; --sun-violet-rgb: 179, 225, 255;
  --gold: #8fd3ff; --gold-rgb: 143, 211, 255;
  --act: #8fd3ff; --act-rgb: 143, 211, 255; --act-light: #b3e1ff; --act-deep: #4c93bf; --act-ink: #06192a;
  --menu: #7ee0c3; --menu-rgb: 126, 224, 195; --menu-light: #9fe8d2; --menu-deep: #3f9f83; --menu-ink: #04201a;
  --bg-rgb: 14, 20, 26; --bar-rgb: 14, 20, 26;
  --wm-grad-v: linear-gradient(180deg, #ffffff 0%, #d6efff 35%, #8fd3ff 70%, #7ee0c3 100%);
  --wm-grad-h: linear-gradient(90deg, #e6f5ff, #8fd3ff 55%, #7ee0c3);
  --btn-primary-end: #b3e1ff; --btn-primary-ink: #06192a;
  --glow-warm-rgb: 126, 224, 195; --glow-cool-rgb: 179, 225, 255;
}

/* NEBULA (was "Grape soda" in the prototype): deep purple, tangerine to
   act, lilac for where you are. */
html[data-theme="nebula"] {
  --bg: #150f22; --bg-panel: #1f1732; --bg-panel-raised: #281e40; --hairline: #3a2d58;
  --text: #f8f3ff; --text-muted: #c4b5e2; --text-faint: #9384b3;
  --sun-gold: #ffd08a; --sun-amber: #ff9a3c; --sun-pink: #b99cff; --sun-violet: #f56bb0; --sun-violet-rgb: 245, 107, 176;
  --gold: #ff9a3c; --gold-rgb: 255, 154, 60;
  --act: #ff9a3c; --act-rgb: 255, 154, 60; --act-light: #ffb46c; --act-deep: #b8641c; --act-ink: #2a1204;
  --menu: #b99cff; --menu-rgb: 185, 156, 255; --menu-light: #cbb4ff; --menu-deep: #7a5cc4; --menu-ink: #160c2a;
  --bg-rgb: 21, 15, 34; --bar-rgb: 21, 15, 34;
  --wm-grad-v: linear-gradient(180deg, #ffd08a 0%, #ff9a3c 35%, #f56bb0 70%, #b99cff 100%);
  --wm-grad-h: linear-gradient(90deg, #ff9a3c, #f56bb0 55%, #b99cff);
  --btn-primary-end: #ffb46c; --btn-primary-ink: #2a1204;
  --glow-warm-rgb: 185, 156, 255; --glow-cool-rgb: 245, 107, 176;
}

/* PHOSPHOR: an old terminal -- green-black, phosphor green to act, amber
   for where you are. */
html[data-theme="phosphor"] {
  --bg: #0a0f0b; --bg-panel: #111a13; --bg-panel-raised: #17231a; --hairline: #243528;
  --text: #e8fbe9; --text-muted: #a3cfa8; --text-faint: #6f9876;
  --sun-gold: #c8ffd9; --sun-amber: #39e07f; --sun-pink: #ffb000; --sun-violet: #6cea9f; --sun-violet-rgb: 108, 234, 159;
  --gold: #39e07f; --gold-rgb: 57, 224, 127;
  --act: #39e07f; --act-rgb: 57, 224, 127; --act-light: #6cea9f; --act-deep: #1f9a52; --act-ink: #032010;
  --menu: #ffb000; --menu-rgb: 255, 176, 0; --menu-light: #ffc440; --menu-deep: #b37b00; --menu-ink: #2a1d00;
  --bg-rgb: 10, 15, 11; --bar-rgb: 10, 15, 11;
  --wm-grad-v: linear-gradient(180deg, #c8ffd9 0%, #6cea9f 40%, #39e07f 75%, #1fb863 100%);
  --wm-grad-h: linear-gradient(90deg, #6cea9f, #39e07f 60%, #1fb863);
  --btn-primary-end: #6cea9f; --btn-primary-ink: #032010;
  --glow-warm-rgb: 255, 176, 0; --glow-cool-rgb: 108, 234, 159;
}

/* =====================================================================
   PROFILE MAKEOVER (28 Sep 2026)

   Chosen by Luke on the real page from options behind a switcher
   (proto/profile-makeover, not merged): a banner across the top, the
   banner's image bleeding into the page, the sidebar as one translucent
   panel with Music first, the two tile rows as frosted cards three
   across, and the site's colour rather than a sampled accent.

   One block, at the end, on purpose: most of it restyles rules written
   for the old profile further up, and it is written to win against them
   at equal or higher specificity rather than editing each in place --
   the older rules still carry the reasoning for the parts that stayed.
   Superseded rules that would only have been dead (the blurred-cover
   backdrop, the theme layer) were deleted where they stood.
   ===================================================================== */

/* ---- The bleed: the banner's image behind the top of the page ----
   Blurred to colour and dimmed to brightness 0.2. At the first strength
   tried (0.5) a pale image -- the Witcher's mist -- lifted the whole top
   of the page to a mid grey; 0.2 lands a pale image in the page's own
   dark range and a dark one near black, which is the right way round.
   Absolute to the document, not fixed: it scrolls away with the header,
   so the lower page is the plain site background. Only while the profile
   is the open view. */
.profile-bleed {
  display: none;
  position: absolute;
  inset: 0 0 auto 0;
  height: 115vh;
  overflow: hidden;
  z-index: -1;
  pointer-events: none;
}
body:has(#view-profile:not([hidden])) .profile-bleed { display: block; }
.profile-bleed::before {
  content: "";
  position: absolute;
  inset: -60px;
  background-image: var(--profile-banner, none);
  background-size: cover;
  background-position: var(--profile-banner-pos, center 40%);
  filter: blur(48px) brightness(0.2) saturate(1.45);
  -webkit-mask-image: linear-gradient(180deg, #000 0%, rgba(0, 0, 0, 0.7) 35%, transparent 85%);
  mask-image: linear-gradient(180deg, #000 0%, rgba(0, 0, 0, 0.7) 35%, transparent 85%);
}

/* ---- The header: a grid so the edit badge can sit on the avatar ---- */
#view-profile .profile-header {
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  grid-template-areas: "av name";
  align-items: center;
  column-gap: 0.9rem;
}
#view-profile .profile-avatar-wrap { grid-area: av; }
#view-profile .profile-header-name {
  grid-area: name;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.15rem;
  min-width: 0;
}
#view-profile .profile-header-name h2 {
  font-family: var(--font-display);
  font-weight: 700;
  line-height: 1.05;
}

/* The quote: one short line of your own, under the name. */
.profile-quote {
  margin: 0;
  max-width: 48ch;
  font-size: 0.92rem;
  font-style: italic;
  color: var(--text-muted);
  overflow-wrap: anywhere;
}

/* ---- The banner ---- */
#view-profile .profile-content.has-banner .profile-header {
  isolation: isolate;
  overflow: hidden;
  /* A SHAPE, not a height (Luke, 28 Sep: good with the edit panel open,
     bad with it closed). At a fixed 190px, a full-width banner is twice
     as wide for the same height, so a wide image is scaled up and loses
     its top and bottom -- Sifu's figure lost his head -- while the
     narrower banner beside the panel showed all of it. 4.5 : 1 keeps the
     crop about the same at any width, close to the Steam banner's own
     3.1 : 1, which also means a dragged framing holds when the width
     changes. The min-height takes over when narrow (the panel open, a
     phone), where the ratio alone would make it a strip. */
  aspect-ratio: 4.5 / 1;
  /* 150, was 190 -- see the ceiling below. */
  min-height: 150px;
  /* A CEILING (Luke, 28 Sep: "when it's at its biggest it's a little too
     tall"). The ratio alone made it 261px on a 1440 screen and taller
     beyond. Below ~970px wide the ratio still decides, so the framing
     is unchanged there; above it the height stops and a little more of
     the image's top and bottom is cropped instead of the banner pushing
     the page down.
     170, WAS 215 (Luke, 29 Sep: "let's make this banner vertically
     smaller"), and the floor above with it, 190 -> 150, so a narrow
     banner (the edit panel open) shrinks in step rather than staying
     taller than a wide one. A little more of the image is cropped; the
     framing you drag still decides which part. */
  max-height: 170px;
  /* AND A DEFINITE WIDTH, or the ceiling narrows it (Luke, 28 Sep: "banner
     is not long enough"). With `aspect-ratio` and an AUTO width, a capped
     height is carried back through the ratio as a max-width -- 215 x 4.5
     = 967px -- so above ~970px the banner stopped short of the sidebar's
     right edge (1101 vs 1288 on a 1440 screen). A width of 100% is not
     auto, so the ratio only ever sizes the height, and the cap trims the
     image instead of the box. */
  width: 100%;
  align-content: end;
  padding: 0 1.25rem 1.1rem;
  margin-bottom: 1.75rem;
  border-radius: 14px;
  border: 1px solid rgba(255, 255, 255, 0.06);
}
#view-profile .profile-content.has-banner .profile-header::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -2;
  background-image: var(--profile-banner);
  background-size: cover;
  /* The middle of the image by default: on a portrait cover the top is
     usually the title lettering, which read as a giant word across the
     banner. --profile-banner-pos is the framing dragged in edit mode. */
  background-position: var(--profile-banner-pos, center 55%);
}
/* A TALL image -- a cover, when a game has no wide art -- cannot be
   cropped into a banner without looking broken, so it becomes colour and
   mood: blurred, a little brighter to make up for the blur. */
#view-profile .profile-content.has-banner[data-banner-shape="tall"] .profile-header::before {
  inset: -30px;
  background-position: center 40%;
  filter: blur(22px) brightness(0.9) saturate(1.3);
}
/* The scrim: dark at the bottom, where the name sits. */
#view-profile .profile-content.has-banner .profile-header::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(180deg, rgba(0, 0, 0, 0.05) 0%, rgba(0, 0, 0, 0.35) 45%, rgba(17, 17, 19, 0.92) 100%);
}
#view-profile .profile-content.has-banner .profile-avatar-wrap,
#view-profile .profile-content.has-banner .profile-avatar-placeholder {
  /* 72, was 84 (Luke, 28 Sep: "a little too big") -- sized to the shorter
     banner, so the ring does not crowd its top edge. */
  width: 72px;
  height: 72px;
}

/* ---- The banner on a phone: the image at its own shape ----
   (Luke, 28 Sep: "on mobile it is not fitted at all".) Under 600px the
   4.5 : 1 shape is ~76px tall, so the 150px floor in the 900px block took
   over and made the box about 2.3 : 1 -- and `cover` into that cut the
   sides off a 3 : 1 Steam banner, which is most of them. Now the image is
   a strip at 3.1 : 1 (a Steam library hero's own shape), so it shows
   whole, and the avatar overlaps its lower edge with the name beside it
   on the page itself -- the profile layout people know from everywhere
   else. Not up to 900: at tablet width a 3.1 : 1 strip would be ~290px
   tall, the problem this change is fixing on desktop.

   `padding-top` in % resolves against the WIDTH, which is what lets the
   name start exactly where the strip ends; only the avatar is pulled
   back up into it (margin-top), so the name never sits on the image.
   `clip-path` rather than overflow: the blurred "tall" banner spills past
   its box, and the header can no longer clip, since the avatar has to
   poke out of the strip. */
@media (max-width: 600px) {
  html body #view-profile .profile-content.has-banner .profile-header {
    aspect-ratio: auto;
    min-height: 0;
    max-height: none;
    overflow: visible;
    border: 0;
    border-radius: 0;
    /* THE NAME AND QUOTE SIT BESIDE THE AVATAR, NOT BELOW IT (#170, 2 Oct).
       The avatar used to be pulled 38px up into the strip, which put its
       centre on the strip's bottom edge -- while the name and quote
       started under that edge, so the text's centre was 30px lower than
       the avatar's and the two read as unrelated. Now only about a quarter
       of the avatar overlaps the image (14px of its 64), and the text
       starts 0.25rem under the strip: the part of the avatar below the
       banner is 50px and the two lines of text are 47, so they share a
       top and a bottom. Chosen on the real page over the avatar staying
       with the name beside its lower half and the quote on a line below,
       and over no overlap at all. Without a banner none of this applies:
       the row centres the two, and always did.

       The two numbers go together: the avatar's pull is the overlap PLUS
       the gap under the strip, because its margin is measured from where
       the text starts. */
    padding: calc(100% / 3.1 + 0.25rem) 0.35rem 0;
    align-items: start;
  }
  html body #view-profile .profile-content.has-banner .profile-avatar-wrap {
    margin-top: calc(-14px - 0.25rem);
  }
  html body #view-profile .profile-content.has-banner .profile-header::before,
  html body #view-profile .profile-content.has-banner[data-banner-shape="tall"] .profile-header::before,
  html body #view-profile .profile-content.has-banner .profile-header::after {
    inset: 0 0 auto 0;
    aspect-ratio: 3.1 / 1;
    border-radius: 12px;
    clip-path: inset(0 round 12px);
  }
  /* Only a soft foot now: the name is below the image, not on it. */
  html body #view-profile .profile-content.has-banner .profile-header::after {
    background: linear-gradient(180deg, transparent 55%, rgba(0, 0, 0, 0.35) 100%);
  }
  html body #view-profile .profile-content.has-banner .profile-header-name h2 { font-size: 1.45rem; }
}
#view-profile .profile-content.has-banner .profile-header-name h2 {
  font-size: 2rem;
  text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
}
#view-profile .profile-content.has-banner .profile-quote {
  text-shadow: 0 1px 8px rgba(0, 0, 0, 0.6);
}

/* Reposition mode (edit panel > Banner image > Reposition). */
#view-profile .profile-content.is-repositioning .profile-header {
  cursor: grab;
  outline: 2px dashed rgba(var(--act-rgb), 0.7);
  outline-offset: -2px;
  user-select: none;
  touch-action: none;
}
#view-profile .profile-content.is-repositioning .profile-header:active { cursor: grabbing; }
/* The way out, pinned to the banner's top-right while repositioning. */
.banner-reposition-done { display: none; }
#view-profile .profile-content.is-repositioning .banner-reposition-done {
  display: inline-flex;
  position: absolute;
  top: 0.8rem;
  right: 0.8rem;
  z-index: 3;
  /* Grid placement would pin it inside a cell (see the header grid); auto
     makes the header's padding box its containing block. */
  grid-area: auto;
  cursor: pointer;
}

/* ---- The avatar ring, always, in the accent ----
   Was drawn only with a theme game set (.has-frame). The platinum ring
   (.has-platinum, further up) still takes over its colour. The wrap's
   old 2px gold-soft border would sit inside the ring as a second edge. */
#view-profile .profile-avatar-wrap {
  position: relative;
  overflow: visible;
  border: 0;
  --avatar-ring: var(--accent);
  box-shadow:
    0 0 0 3px var(--avatar-ring),
    0 0 0 6px color-mix(in srgb, var(--avatar-ring) 18%, transparent),
    0 0 28px color-mix(in srgb, var(--avatar-ring) 35%, transparent);
}
#view-profile .profile-avatar-wrap.has-platinum { --avatar-ring: #bcd0e6; }
#view-profile .profile-avatar,
#view-profile .profile-avatar-placeholder { border-radius: 50%; }

/* ---- Section headings: sized so the sidebar can line up ---- */
#view-profile .profile-main .card-row-title {
  line-height: 1.25;
  margin: 0 0 0.75rem;
}
#view-profile .profile-side .card-row-title {
  font-size: 1.05rem;
  margin-bottom: 0.6rem;
}

/* ---- The sidebar: ONE panel, the sections divided by rules ----
   It was three glass cards; beside open rows of covers they read as a
   different page. 40% of the theme's panel colour, so the bleed shows
   through, with a blur to keep text legible over it. It starts level
   with the tops of the covers, not the headings (the offset is exactly
   the main headings' line box plus their margin). */
#view-profile #profile-side {
  margin-top: calc(1.4rem * 1.25 + 0.75rem);
  gap: 1.1rem;
  padding: 1.1rem 1.1rem 1rem;
  border-radius: 14px;
  border: 1px solid rgba(255, 255, 255, 0.046);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.016), 0 12px 32px rgba(0, 0, 0, 0.11);
  /* The fill and blur are per width: glass on desktop, solid on a phone.
     Both live in their media blocks (min-width: 641px, max-width: 640px)
     -- see "SOLID, NOT FROSTED, ON A PHONE" there. */
}
#view-profile .profile-side .spotlight-block {
  margin: 0;
  padding: 0;
  background: none;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}
#view-profile .profile-side > [data-section]:not(.section-off):not([hidden]) ~ [data-section]:not(.section-off):not([hidden]) {
  border-top: 1px solid rgba(255, 255, 255, 0.07);
  padding-top: 1.1rem;
}

/* ---- Stats ---- */
#view-profile .profile-side .profile-stat-label {
  font-weight: 500;
  font-size: 0.88rem;
  color: var(--text-muted);
}
/* The counts as figures: a six-column grid where each spans two, and the
   last row widens to fill -- the stats can be switched on and off, so the
   count varies, and three-across left holes (five = 3 + an empty slot).
   2 or 4 go two across, 5 goes 3 + 2, 1 takes the row. */
#view-profile .profile-side .profile-stat-band-counters {
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: 1rem 0.75rem;
  margin-bottom: 1rem;
  padding-bottom: 1rem;
  border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
#view-profile .profile-side .profile-stat-band-counters > .profile-stat {
  grid-column: span 2;
  display: flex;
  flex-direction: column-reverse;
  align-items: flex-start;
  justify-content: flex-end;
  gap: 0.3rem;
  padding: 0;
  border: 0;
  background: none;
}
#view-profile .profile-side .profile-stat-band-counters:has(> :nth-child(2):last-child) > .profile-stat,
#view-profile .profile-side .profile-stat-band-counters:has(> :nth-child(4):last-child) > .profile-stat,
#view-profile .profile-side .profile-stat-band-counters:has(> :nth-child(5):last-child) > .profile-stat:nth-child(n + 4) {
  grid-column: span 3;
}
#view-profile .profile-side .profile-stat-band-counters > .profile-stat:only-child { grid-column: span 6; }
#view-profile .profile-side .profile-stat-band-counters .profile-stat-value {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.9rem;
  line-height: 1;
  color: var(--text);
}
#view-profile .profile-side .profile-stat-band-counters .profile-stat-label {
  font-size: 0.7rem;
  line-height: 1.25;
  color: var(--text-faint);
}
#view-profile .profile-side .profile-stat-band-counters .profile-stat-icon { display: none; }
/* Named stats: the game in near-white, its label in the accent. The game
   names in the accent read badly on some themes (Paper & ink); the label
   carries the colour instead and the name only takes it on hover. */
#view-profile .profile-stat-named .profile-stat-label { color: var(--accent); }
#view-profile .profile-stat-named .profile-stat-game { color: var(--text); }
#view-profile .profile-stat-named .profile-stat-game:hover { color: var(--accent); }
/* The rating chart in the accent, one colour (app.js dropped the inline
   per-rating colours). */
#view-profile .histogram-bar {
  background: linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 45%, transparent));
}

/* ---- The two tile rows: frosted cards, exactly three across ----
   The sidebar's material -- 40% panel, faint edge, blur -- so the cards
   and the sidebar read as one family. Sized so three fit the column
   exactly: at a fixed 17rem the third was cut off mid-card, which looked
   like a mistake rather than "more this way". More than three scroll,
   snapping to a card. */
#view-profile .profile-tiles { gap: 0.6rem; }
#view-profile .profile-tile {
  /* Never narrower than 14rem: with the edit panel open the column
     shrinks, and a third of it cut every name to "MEC...". Below that
     width the row scrolls instead. */
  flex: 0 0 max(14rem, calc((100% - 1.2rem) / 3));
  min-width: 0;
  align-items: center;
  border-radius: 14px;
  border: 1px solid rgba(255, 255, 255, 0.07);
  /* Fill and blur per width -- see #profile-side above. */
}
#view-profile .profile-tile:hover { border-color: var(--accent); }
/* The total and its context on one line (2 Oct; they were two rows), the
   context a beat after the total. flex-wrap, so a narrow card puts the
   context under the total rather than clipping it. */
.profile-tile-figures {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  column-gap: 0.4rem;
}
/* A middle dot between them ("8.7h · +0.8h recent"; chosen over a bare
   gap, 2 Oct). Drawn by the context, so a card with no context has no
   stray dot. */
.profile-tile-figures .profile-tile-sub::before {
  content: "·";
  margin-right: 0.4rem;
}
.profile-tile-hours {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--accent);
}
.profile-tile-sub {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  color: var(--text-faint);
}
.profile-tile-trophy {
  width: 0.85rem;
  height: 0.85rem;
  flex: none;
  fill: none;
  stroke: var(--text-muted);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* Platinum, like the full bar beside it. */
.profile-tile-progress.is-complete .profile-tile-trophy { stroke: #d7e0ea; }

/* ---- Covers lift and take the accent when pointed at ----
   The ring is an OUTLINE drawn inside the cover (offset -2px), not a
   box-shadow outside it. Outside, the first cover's ring stood 2px proud
   of the column's left edge -- past the headings and every row below it
   (Luke: "the highlight sticks out the left side"). Outlines paint over
   the element's own content, so an inset one sits on the art. The soft
   glow stays a shadow: it has no edge to line up.
   The achievement showcase tiles take it too; they had no hover at all. */
#view-profile .favorite-cover-wrap,
#view-profile #top-rated-year-row .activity-card-cover-wrap,
#view-profile .showcase-slot {
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}
#view-profile .favorite-card:hover .favorite-cover-wrap,
#view-profile #top-rated-year-row .activity-card:hover .activity-card-cover-wrap,
#view-profile .showcase-slot:hover {
  transform: translateY(-3px);
  outline: 2px solid var(--accent);
  outline-offset: -2px;
  box-shadow: 0 10px 28px -6px color-mix(in srgb, var(--accent) 45%, transparent);
}

/* ---- Edit panel: the banner picker ---- */
.banner-picker {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: 0.5rem;
  margin-bottom: 0.6rem;
}
.banner-option {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  padding: 0.3rem;
  border-radius: 8px;
  border: 1px solid var(--hairline);
  background: var(--bg-panel-raised);
  color: var(--text-muted);
  font: 500 0.72rem var(--font-body);
  text-align: left;
  cursor: pointer;
}
.banner-option img {
  width: 100%;
  aspect-ratio: 16 / 7;
  object-fit: cover;
  border-radius: 5px;
}
.banner-option-auto {
  justify-content: center;
  align-items: center;
  min-height: 3.2rem;
}
.banner-option:hover,
.banner-option.is-active { border-color: var(--accent); color: var(--text); }
.banner-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.profile-quote-form { display: flex; gap: 0.5rem; }
.profile-quote-form input {
  flex: 1;
  min-width: 0;
  font: inherit;
  font-size: 0.88rem;
  color: var(--text);
  background: var(--bg-panel-raised);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 0.5rem 0.7rem;
}
/* ---- The edit panel, in the site's settings format (28 Sep) ----
   It was a full-height drawer with an uppercase display-face title, mono
   uppercase labels and ghost buttons -- the old look. Now it is the gear
   menu's surface: a floating raised panel with a hairline edge and 14px
   corners, sentence-case labels in the body face, and the site's own
   quiet buttons (np-action, set in the markup). A phone keeps it full
   screen (the 640px block). */
#view-profile .profile-edit-panel {
  top: 1rem;
  right: 1rem;
  bottom: 1rem;
  width: min(380px, calc(100vw - 2rem));
  border: 1px solid var(--hairline);
  border-radius: 14px;
  box-shadow: 0 18px 44px rgba(0, 0, 0, 0.5);
  padding: 1rem 1.1rem;
}
#view-profile .profile-edit-panel-head {
  /* Pulled up over the panel's 1rem top padding and stuck there. Stuck
     at top: 0 it sat 1rem below the panel's edge, and the content
     scrolling past showed through that strip above the title ("Banner"
     peeking over "Edit profile"). */
  top: -1rem;
  margin-top: -1rem;
  padding-top: 1rem;
  padding-bottom: 0.75rem;
  margin-bottom: 0.75rem;
  border-bottom: 1px solid var(--hairline);
}
#view-profile .profile-edit-panel-head h3 {
  font-family: var(--font-body);
  font-size: 1rem;
  font-weight: 700;
  text-transform: none;
  letter-spacing: 0;
}
#view-profile .profile-edit-panel .profile-sections-label {
  font-family: var(--font-body);
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: none;
  letter-spacing: 0;
  color: var(--text-muted);
}
/* Sections: a title, then close-set fields; a rule between sections
   only. The .profile-edit-group rule above draws a rule under EVERY
   group, which is what spread name, avatar and quote as far apart as
   unrelated fields -- inside a section that rule is dropped. */
#view-profile .profile-edit-section {
  display: flex;
  flex-direction: column;
  gap: 0.85rem;
  padding-bottom: 1.1rem;
  margin-bottom: 1.1rem;
  border-bottom: 1px solid var(--hairline);
}
#view-profile .profile-edit-section:last-child { border-bottom: 0; margin-bottom: 0; }
#view-profile .profile-edit-section-title {
  margin: 0;
  font-family: var(--font-body);
  font-size: 0.95rem;
  font-weight: 700;
  color: var(--text);
}
#view-profile .profile-edit-section .profile-edit-group {
  margin: 0;
  padding: 0;
  border: 0;
}
#view-profile .profile-edit-panel .profile-sections-label { margin-bottom: 0.35rem; }
/* An empty message line held its margin open under every field. */
#view-profile .profile-edit-panel .form-message:empty,
#view-profile .profile-edit-panel .profile-share-message:empty { display: none; }

/* Sharing: the toggle, then the link as ONE field -- the fixed prefix
   and the editable handle joined, so it reads as the address it becomes
   -- with Save beside it. It was a mono prefix floating before a small
   separate box, wrapping unpredictably. */
#view-profile .profile-share-handle {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas: "label label" "field save";
  gap: 0 0.5rem;
  margin-top: 0.85rem;
}
#view-profile .profile-share-handle .profile-sections-label { grid-area: label; }
#view-profile .profile-share-field {
  grid-area: field;
  display: flex;
  align-items: center;
  min-width: 0;
  border: 1px solid var(--hairline);
  border-radius: 6px;
  background: var(--bg-panel-raised);
  overflow: hidden;
}
#view-profile .profile-share-field:focus-within { border-color: var(--accent); }
#view-profile .profile-share-prefix {
  padding: 0 0.1rem 0 0.6rem;
  font-family: var(--font-body);
  font-size: 0.82rem;
  color: var(--text-faint);
  white-space: nowrap;
}
#view-profile #profile-handle-input {
  flex: 1;
  min-width: 0;
  width: auto;
  font-family: var(--font-body);
  font-size: 0.88rem;
  padding: 0.5rem 0.6rem 0.5rem 0;
  border: 0;
  border-radius: 0;
  background: transparent;
  outline: none;
}
#view-profile #profile-handle-save { grid-area: save; }
#view-profile .profile-share-toggle { font-size: 0.88rem; }
#view-profile .profile-share-message {
  margin: 0.5rem 0 0;
  font-size: 0.78rem;
  color: var(--text-muted);
}

/* The site's gear sat on top of the panel's Done (the profile content is
   a lower stacking context than the header). While the panel is open the
   panel IS the settings, so the gear steps out of the way. */
body.panel-locked .settings-menu-wrap { visibility: hidden; }

/* ---- The footer's Privacy link, in the footer's own colours ----
   It rendered as the browser's default blue link from the day it was
   added (the privacy page, !307). */
.site-footer a { color: var(--text-muted); text-decoration: none; }
.site-footer a:hover { color: var(--text); text-decoration: underline; }

/* ---- Phone: in the one (max-width: 900px) block, near .profile-body ----
   Those rules sit earlier in the file than this block, so each is written
   one step more specific (html body ...) to win without an !important. */

/* ONE PAGE WIDTH ON EVERY TAB (6 Oct). Above 1800px, Games and Play Next
   used to reach 160px past `main` on each side, to 1560px of content
   (28 Sep: "wide, but only applied to the tabs with the shelf"). The
   header and Profile stayed at 1240px, so on those two tabs the content
   started 150px left of the wordmark and the banner looked out of place
   ("when I go from profile to games the nowplayed banner moves around").
   One width everywhere was tried on a prototype and made Profile too big;
   the widening went instead, so every tab's content starts on the
   wordmark's line. --page-w is 1240px everywhere again (its default in
   the rack's --gutter). */


/* INVITE-ONLY, AND FORGOT PASSWORD (6 Oct). The line under the claim is
   quiet on purpose -- it is a fact about now, not a pitch -- and the
   forgot-password control is a text button so it never competes with Log
   in. Both use the login screen's own colours (it is always Sunset). */
.login-invite {
  display: block;
  /* Above the wordmark now, so its space is underneath. */
  margin: 0 0 0.5rem;
  font-family: var(--font-mono);
  /* 1.15rem with wide tracking: a label, not a line of copy. Chosen on the
     page from four sizes (0.85-1.35rem) and six colours: amber, the Log in
     button's colour, so the two read as one voice. 0.78rem gold was the
     first try and read as too small over a 5rem wordmark. */
  font-size: 1.15rem;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--sun-amber);
}
.lm-close .login-invite { margin: 0 0 0.6rem; }
.login-forgot {
  display: block;
  margin: 0.6rem 0 0;
  padding: 0;
  /* A button centres its text by default; this one lines up with the form. */
  text-align: left;
  background: none;
  border: 0;
  font: inherit;
  font-size: 0.8rem;
  color: var(--text-muted);
  cursor: pointer;
}
.login-forgot:hover { color: var(--text); text-decoration: underline; }

/* CHOOSE A PASSWORD: one card over a dim page. Nothing to close it with:
   an invited account with no password can only ever be used here. */
.set-password-overlay {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: grid;
  place-items: center;
  padding: 16px;
  background: rgba(var(--bg-rgb), 0.82);
}
.set-password-overlay[hidden] { display: none; }
.set-password-card {
  width: min(24rem, 100%);
  box-sizing: border-box;
  padding: 1.4rem 1.4rem 1.1rem;
  border: 1px solid var(--hairline);
  border-radius: 14px;
  background: var(--bg-panel);
  display: grid;
  gap: 0.75rem;
}
.set-password-card h2 { margin: 0; font-family: var(--font-display); font-size: 1.4rem; }
.set-password-lead { margin: 0; color: var(--text-muted); font-size: 0.9rem; }
.set-password-card label { display: grid; gap: 0.3rem; font-size: 0.8rem; color: var(--text-muted); }
.set-password-card input { font-size: 16px; }
