/* TASK-092 — public page tokens.
 *
 * Transcribed from docs/design.md so the shared pages match the app exactly.
 * These values are the same ones lib/app/tokens.dart carries; if the two ever
 * disagree, docs/design.md is right and both are wrong.
 *
 * ⚠️ **`../trophyshelf_web/site.css` TRANSCRIBES these token values** — the
 * landing site does not load this file, it copies the `:root` block. A token
 * whose *value* changes here has to change there too. Everything below the
 * token blocks is this file's own and reaches no other repository.
 *
 * Light is the default, matching the app. `prefers-color-scheme: dark` is
 * honoured for visitors whose device asks for it — but photography and the
 * chrome overlaid on it stay dark in both, because a collectible photographed
 * against a light frame looks like a product listing rather than a shelf.
 *
 * ---------------------------------------------------------------------------
 * 2026-08-19 redesign. Four things were measured wrong on the live pages and
 * every one of them is structural rather than decorative:
 *
 *   1. **Nothing said what this is until the bottom of the page.** A stranger
 *      opened a link and met a photograph with no brand on it. `.ts-bar` is a
 *      quiet wordmark at the top — deliberately NOT a signup button, which is
 *      the affordance TASK-090 bans up there.
 *   2. **Desktop was a phone column.** 640px marooned in 1280px with the join
 *      banner stretching its two halves to opposite edges of the screen. The
 *      grids widen, the collectible page goes two-column, and every fixed bar
 *      constrains its own content.
 *   3. **The shelf plate vanished in dark mode.** `--ts-photo-bg` (#0c0e16)
 *      against `--ts-surface-app` (#0d0f14) is a 1% difference, so the whole
 *      shelf metaphor existed in light mode only. `--ts-stage` is a plate that
 *      is a *distinct object* in both themes.
 *   4. **The hero cropped the collectible.** The app settled this on
 *      2026-08-12: the detail hero caps at square and `contain`s the
 *      photograph on a card-coloured plate, precisely so nothing is cut off.
 *      The web page went on `cover`ing it, so one collectible was framed two
 *      ways on two surfaces. See CLAUDE.md § Hero height / § Hero plate.
 * ---------------------------------------------------------------------------
 */

:root {
  /* Brand — identical in both themes. */
  --ts-brand: #ff8d28;
  --ts-brand-pressed: #e97d1c;
  --ts-amber: #f5a623;
  --ts-success: #4caf50;
  --ts-danger: #e5484d;

  /* Light ramp: warm paper. */
  --ts-surface-app: #f7f3ee;
  --ts-surface-card: #ffffff;
  --ts-surface-raised: #ede8e1;
  --ts-text-heading: #1c1714;
  /* ⚠️ **#72675E, not #7A6F65 — and this file had the old one until
     2026-08-19.** TASK-127 darkened it in `tokens.dart` because #7A6F65
     measures 4.02:1 on `surface-raised` and 4.43:1 on the page, both under
     WCAG 1.4.3's 4.5:1; it never reached `docs/design.md`, so the arbiter kept
     stating the failing value and this file transcribed it faithfully. The app
     was right and both documents defining right were not.
     📌 The binding surface is `--ts-surface-raised` (chips), not the page. */
  --ts-text-body: #72675e;
  --ts-text-faint: #9a9089;
  --ts-border: rgba(28, 23, 20, 0.08);
  --ts-border-strong: rgba(28, 23, 20, 0.14);
  /* The edge of a control whose fill does not distinguish it from the page.
     ⚠️ **Not a louder `--ts-border-strong`.** That one is a *card* hairline —
     0.14 measures 1.24:1 against the page, which is the quiet look the whole
     palette is built on and is correct for a surface. A control has to be
     findable, so this clears WCAG 1.4.11's 3:1 against **both** the fill it
     sits on and the page behind it: 3.61 and 3.27 here, 3.32 and 3.66 dark. */
  --ts-border-interactive: rgba(28, 23, 20, 0.52);
  --ts-text-on-brand: #1c1714;
  /* TASK-486 — the app's `brand_ink`: brand-coloured TEXT that clears AA on
     paper (4.58:1). The brand orange itself is 2.4:1 there; in dark it passes
     as it is, so the dark ramp sets it back to the brand. */
  --ts-brand-ink: #a74f00;

  /* Photography stays dark in both themes. */
  --ts-photo-bg: #0c0e16;

  /* docs/design.md § 5 — a sub-pixel hairline. Rounding it to 1px visibly
     coarsens every card. */
  --ts-hairline: 0.7px;

  --ts-radius-sm: 8px;
  --ts-radius-md: 12px;
  --ts-radius-lg: 16px;
  --ts-radius-xl: 22px;
  --ts-radius-pill: 999px;

  --ts-gutter: 18px;
  --ts-font: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont,
    'Segoe UI', system-ui, sans-serif;

  /* ---- this file's own, below the transcription ---- */

  /* The shelf plate the grids sit on.
   *
   * ⚠️ **Not `--ts-photo-bg`, which is what it used to be.** That token is the
   * colour behind a *photograph*, where being near-black is the point. Here it
   * is the colour of a piece of furniture, and it has to be legible as an
   * object against the page — which in dark mode #0c0e16 on #0d0f14 was not.
   *
   * 🐛 **It was near-black in BOTH themes until 2026-08-20, so the one block on
   * a light page that ignored the theme switch.** `#101219` on `#f7f3ee` is a
   * slab, and the captions on it were hardcoded white, so the plate was built
   * as a dark display case rather than as a surface — `docs/design.md` § 1
   * grants exactly that exception to *photography* (feed cards, the carousel),
   * and this is furniture around photographs rather than a photograph. Each
   * tile keeps its own dark `--ts-photo-bg`, so the shelf still reads as a
   * shelf; what changed is that the plate follows the page it sits on.
   *
   * 📌 **The light value is `surface_raised`'s colour, declared separately.**
   * It is the same step off the page that every other raised thing takes, and
   * `--ts-stage` stays its own token because the dark value is *not* the dark
   * `surface_raised` — a plate has to sit above its page, and in dark mode that
   * is a lighter step than a card takes. */
  --ts-stage: #ede8e1;
  /* ⚠️ A white hairline is invisible on a light plate. The edge is ink here and
     light there, for the same reason every other border token flips. */
  --ts-stage-edge: rgba(28, 23, 20, 0.10);

  /* Reading measure for prose and detail rows, and the wide measure for a
     grid. Two values because a column of label/value rows stops being
     readable long before a grid of photographs stops being useful. */
  --ts-measure: 660px;
  --ts-measure-wide: 1120px;
}

/* ---- the dark ramp, in three states ----
 *
 * ⚠️ **A theme the reader can switch needs THREE rules, not two** (2026-08-19).
 * The page has to answer: no stored choice → follow the device; stored `dark` →
 * dark whatever the device says; stored `light` → light *even on a dark device*,
 * which is the one a plain `prefers-color-scheme` block cannot express. Written
 * as two rules, a reader on a dark handset who picks light gets a light page
 * with dark tokens still applied — the state that looks most like the toggle
 * being broken.
 *
 * ⚠️ **`:root:not([data-theme="light"])` inside the media query is the whole
 * trick.** It is what lets an explicit light choice out-rank the device.
 *
 * 📌 `[data-theme]` is set by the inline script in `pageHead` *before first
 * paint*, so a reader who chose dark never sees a white flash. */

@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    --ts-surface-app: #0d0f14;
    --ts-surface-card: #171a21;
    --ts-surface-raised: #1c1f24;
    --ts-text-heading: #f0ede8;
    --ts-text-body: #8a8f9e;
    --ts-text-faint: #676d75;
    --ts-border: rgba(255, 255, 255, 0.08);
    --ts-border-strong: rgba(255, 255, 255, 0.14);
    --ts-border-interactive: rgba(255, 255, 255, 0.36);
    --ts-brand-ink: #ff8d28;

    /* Raised above the page rather than sunk below it — the only way a dark
       plate reads as an object on a dark page. */
    --ts-stage: #191c25;
    --ts-stage-edge: rgba(255, 255, 255, 0.09);
  }
}

:root[data-theme='dark'] {
  --ts-surface-app: #0d0f14;
  --ts-surface-card: #171a21;
  --ts-surface-raised: #1c1f24;
  --ts-text-heading: #f0ede8;
  --ts-text-body: #8a8f9e;
  --ts-text-faint: #676d75;
  --ts-border: rgba(255, 255, 255, 0.08);
  --ts-border-strong: rgba(255, 255, 255, 0.14);
  --ts-brand-ink: #ff8d28;
  --ts-stage: #191c25;
  --ts-stage-edge: rgba(255, 255, 255, 0.09);
}

/* Tells the browser which way round the page is, so form controls, scrollbars
   and the address bar follow the *chosen* theme rather than the device's. */
:root {
  color-scheme: light;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    color-scheme: dark;
  }
}

:root[data-theme='dark'] {
  color-scheme: dark;
}

:root[data-theme='light'] {
  color-scheme: light;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  background: var(--ts-surface-app);
  color: var(--ts-text-heading);
  font-family: var(--ts-font);
  font-size: 14px;
  line-height: 1.5;
  /* The system font stack is the fallback on purpose: a shared link must
     render immediately, and a web font blocking first paint is worse than a
     slightly different typeface on a page a stranger opens once. */
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
}

/* ⚠️ **Every fixed bar constrains its own inner content**, rather than the
   page constraining the bar. A bar has to span the viewport to look attached
   to it; its *content* has to stay on the same measure as the page or a 1280px
   screen puts the wordmark and the button a metre apart. `.ts-bar-in` and
   `.ts-join-in` are that, and they are the reason those two elements have an
   inner wrapper at all. */
.ts-page {
  max-width: var(--ts-measure);
  margin: 0 auto;
  padding-bottom: 104px; /* clearance for the sticky join banner */
}

.ts-page--wide {
  max-width: var(--ts-measure-wide);
}

/* A page whose entire content is one state — the 404 and the private-collection
   page — 2026-08-19.

   ⚠️ **Measured, not a preference.** `.ts-empty` is top-padded and never
   centres, so on the live 404 the content ended at 245px on a 375×812 handset
   and the join banner began at 745px: **500px of void, 62% of the viewport**.
   At 1280×720 the gap was 381px. A state occupying the top fifth of a blank
   screen reads as a page that failed to finish loading rather than as an
   answer to what was asked.

   The arithmetic reserves both fixed pieces of chrome so the page is exactly
   one viewport with nothing to scroll: `.ts-bar` is **in flow** above this
   element (it is `position: sticky`) at 10px padding twice plus a 26px mark
   plus the hairline — 47px — and `.ts-page`'s own `padding-bottom: 104px`
   already reserves the fixed join banner.

   ⚠️ **`vh` first, then `svh`.** A shared link opens in whatever in-app webview
   carried it, and the older ones do not know `svh`; the second declaration is
   ignored there and honoured everywhere else, where it is what stops mobile
   browser chrome pushing the banner off-screen.

   ⚠️ **`min-height`, never `height`.** At 320px wide with text spacing
   overridden (WCAG 1.4.12) this content is taller than a short viewport, and
   the block has to grow rather than clip. */
.ts-page--fill {
  display: flex;
  flex-direction: column;
  min-height: calc(100vh - 47px);
  min-height: calc(100svh - 47px);
}

/* ⚠️ **The one page with no join banner, so it must not reserve one.**
   The front door is where that banner's own button points, so it renders none — a
   page offering a button back to itself is a loop — and `.ts-page`'s 104px of
   clearance would otherwise be a strip of nothing under the footer. The `--fill`
   arithmetic above needs no change: the reservation is padding inside the same
   box, so dropping it grows the content area and the page stays one viewport. */
.ts-page--nobanner {
  padding-bottom: 24px;
}

.ts-page--fill .ts-empty {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

/* ---- the top bar (2026-08-19) ----
 *
 * ⚠️ **A wordmark, and deliberately nothing else.** TASK-090 banned a follow
 * button and a top signup button, and both bans stand: a stranger who opened a
 * link has not asked to follow anybody, and the one offer on this page is the
 * join banner at the bottom. What was missing was not a call to action, it was
 * an *answer to "what am I looking at"* — which the page did not give until
 * the reader reached the end of it.
 *
 * ⚠️ **It is not a link.** The obvious href is the landing origin, which on
 * production is a parked placeholder whose every path 404s (CLAUDE.md
 * § `dev.trophyshelf.app` is live; the apex is not). A wordmark that goes
 * nowhere useful is worse than one that goes nowhere at all, and the footer
 * already carries the sentence explaining the product. */
.ts-bar {
  position: sticky;
  top: 0;
  z-index: 5;
  background: color-mix(in srgb, var(--ts-surface-app) 88%, transparent);
  -webkit-backdrop-filter: saturate(180%) blur(12px);
  backdrop-filter: saturate(180%) blur(12px);
  border-bottom: var(--ts-hairline) solid var(--ts-border);
}

.ts-bar-in {
  max-width: var(--ts-measure-wide);
  margin: 0 auto;
  padding: 10px var(--ts-gutter);
  display: flex;
  align-items: center;
  gap: 9px;
}

/* ⚠️ **The mark is a BACKGROUND image, not a `<picture>` — and the reason is
   the theme switch.** `<source media="(prefers-color-scheme: dark)">` resolves
   against the *device*, so once a reader could choose a theme by hand the page
   went dark and the logo stayed light-panelled. A background-image is chosen by
   the same CSS cascade as every token, so `[data-theme]` moves it too — and the
   browser still fetches only the rule that actually applies, which is the one
   thing `<picture>` was bought for.

   📌 The two files differ in the icon's inner panel, white against near-black,
   so a single asset cannot serve both.

   ⚠️ **The box is sized here rather than by `width`/`height` attributes**, so it
   still reserves its space before the image arrives and the bar cannot jump. */
.ts-bar-mark {
  display: block;
  width: 26px;
  height: 26px;
  flex-shrink: 0;
  background: url('/brand-mark-light.png') center / contain no-repeat;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .ts-bar-mark {
    background-image: url('/brand-mark-dark.png');
  }
}

:root[data-theme='dark'] .ts-bar-mark {
  background-image: url('/brand-mark-dark.png');
}

/* ⚠️ **The wordmark is `TsWordmark`'s, two-tone**: Trophy in the heading ink,
   Shelf in brand orange. It was one flat `TrophyShelf` in heading ink, which is
   a different logo from the one the app shows on Splash, Login and OTP.

   📌 **Brand orange measures ~2:1 on warm paper and that is permitted here and
   nowhere else.** WCAG 1.4.3 exempts text that is part of a logotype — which
   this is — and "Trophy" beside it carries the full contrast, so the name is
   legible on the strength of its other half. Never copy this pairing to a
   label; `TsWordmark` carries the same warning. */
.ts-bar-name {
  font-size: 14.5px;
  font-weight: 800;
  letter-spacing: -0.3px;
  color: var(--ts-text-heading);
}

.ts-bar-shelf {
  color: var(--ts-brand);
}

/* ---- the theme switch (2026-08-19) ----
 *
 * ⚠️ **A 44px target around a 20px glyph.** It is the only control in the bar,
 * it sits at the top corner where a thumb reaches, and `accessibility_test.dart`
 * holds the app to 48; the web page has no such test, so the size is written
 * down here instead of being left to the icon.
 *
 * 📌 **Only one of the two glyphs is in the DOM at a time** — the script swaps
 * them — so there is no CSS state to keep in step with the label a screen
 * reader reads. */
.ts-theme {
  margin-left: auto;
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  margin-right: -10px; /* pulls the 44px target back to the gutter edge */
  padding: 0;
  border: 0;
  background: none;
  color: var(--ts-text-body);
  cursor: pointer;
  border-radius: var(--ts-radius-pill);
}

.ts-theme:hover {
  color: var(--ts-text-heading);
  background: var(--ts-surface-raised);
}

.ts-theme:focus-visible {
  outline: 2px solid var(--ts-brand);
  outline-offset: -2px;
}

/* ⚠️ **Hidden when the page has no script**, because it cannot work without
   one: the choice has to outlive the page, and there is nowhere but
   `localStorage` to keep it. A dead control is worse than none. */
.ts-noscript .ts-theme {
  display: none;
}

/* ---- photography ---- */

.ts-photo {
  position: relative;
  width: 100%;
  aspect-ratio: 1 / 1;
  background: var(--ts-photo-bg);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

.ts-photo img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.ts-photo .ts-glyph {
  font-size: 64px;
}

/* The collectible page's hero — square, and the photograph is CONTAINED.
 *
 * ⚠️ **Both halves are one change and neither is safe alone**, which is the
 * rule the app records for the identical decision (CLAUDE.md § Hero height).
 * A square well under `cover` takes the top and bottom off a portrait
 * photograph; short and whole is the trade, short and cropped is the bug. The
 * live page was cropping a Matchbox card's own packaging out of frame.
 *
 * ⚠️ **`--ts-surface-card`, never a literal white.** The app reversed
 * docs/design.md's "photography-heavy surfaces stay dark regardless of theme"
 * for this one surface, on the grounds that a capped-and-contained hero makes
 * the plate a visible border *around* the piece rather than a thin edge behind
 * it — and a white slab in dark mode is that same mistake mirrored.
 *
 * ⚠️ **The grids are untouched and still `cover`.** They are a rhythm, and a
 * contained photograph in a fixed tile leaves ragged plate down the column. */
.ts-hero {
  background: var(--ts-surface-card);
  border-bottom: var(--ts-hairline) solid var(--ts-border);
}

.ts-hero img {
  object-fit: contain;
}

/* ⚠️ **No photograph is a different well, not the same well with a glyph in
   it.** The card plate is what stops a contained photograph reading as cropped;
   behind *nothing* it is a full square of blank paper with a small emoji adrift
   in the middle, which reads as an image that failed to load — on the one
   surface a stranger opened a link to look at. Shorter, and on a surface that
   is visibly a placeholder. */
.ts-hero--empty {
  aspect-ratio: 16 / 7;
  background: var(--ts-surface-raised);
}

.ts-hero--empty .ts-glyph {
  font-size: 42px;
  opacity: 0.7;
}

/* ── The hero carousel — TASK-282 ────────────────────────────────────────────
   🚨 **CSS only, and not by preference.** These pages ship exactly two inline
   scripts and `assertInert` reads a third `<script>` as an injection, which is
   what keeps `/<script/` a complete detector. `scroll-snap` gives the whole
   control for nothing: swipe on touch, trackpad and shift+wheel on desktop,
   arrow keys once the track has focus.

   ⚠️ **The well's geometry is unchanged.** § 7 pins the hero at square with the
   photograph CONTAINED, so slides are full-width rather than peeking: a peek is
   the stronger affordance and it narrows the well, which would quietly make the
   collectible smaller on the page it exists to show. The count chip and the
   dots carry discoverability instead. */
.ts-hero-track {
  position: absolute;
  inset: 0;
  display: flex;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  /* The bar would sit across the bottom of the photograph in every desktop
     browser that paints one. The dots and the chip are the affordance. */
  scrollbar-width: none;
}

.ts-hero-track::-webkit-scrollbar {
  display: none;
}

/* ⚠️ **`smooth` is opt-IN behind reduced-motion**, because it is what a dot tap
   animates and a full-width horizontal sweep is exactly the vestibular trigger
   2.3.3 is about. Without it the jump is instant, which is not a lost feature. */
@media (prefers-reduced-motion: no-preference) {
  .ts-hero-track {
    scroll-behavior: smooth;
  }
}

/* A scroll container is only keyboard-reachable with an explicit stop, and a
   stop with no ring is a focus somebody cannot see — WCAG 2.4.7. */
.ts-hero-track:focus-visible {
  outline: 2px solid var(--ts-brand);
  outline-offset: -2px;
}

.ts-slide {
  flex: 0 0 100%;
  scroll-snap-align: center;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* ⚠️ **`contain`, matching the single-photo hero.** `.ts-photo img` is `cover`
   for the grids and the slide images are one level deeper, so the hero's own
   override does not reach them. Dropping this crops every photograph in the
   carousel while leaving the one-photo page correct — the drift would show only
   on collectibles with more than one shot. */
.ts-slide img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* The dots. `::scroll-marker` follows a *swipe*, which is the whole reason for
   it — an anchor-link dot row cannot highlight the slide you scrolled to, and
   it writes a `#fragment` into a URL that exists to be shared. Firefox has no
   support yet and gets no dots; the count chip is what it gets instead. */
.ts-hero-track {
  scroll-marker-group: after;
}

/* ⚠️ **The group carries its own dark chip, and it is not decoration.** § 7's
   rule for chrome over photography: these sit over collector photography *and*
   over a plate that may be white, so nothing may assume what is behind them.
   Measured on the Matchbox blister — its packaging is a white strip exactly
   where the dots land, and bare white dots on it were invisible, the current one
   included. `width: max-content` is what keeps the chip hugging the dots rather
   than painting a bar across the photograph. */
.ts-hero-track::scroll-marker-group {
  position: absolute;
  bottom: 10px;
  left: 50%;
  transform: translateX(-50%);
  width: max-content;
  display: flex;
  background: rgba(0, 0, 0, 0.62);
  border-radius: 999px;
}

/* ⚠️ **24×24 of target around a 10px dot** — WCAG 2.5.8. The padding is the hit
   area and `background-clip: content-box` keeps the paint at 10px, so the dots
   read as dots and are still thumb-sized. Shrinking the padding to close the
   gaps puts this under the minimum. */
.ts-slide::scroll-marker {
  content: '';
  width: 10px;
  height: 10px;
  padding: 7px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.45);
  background-clip: content-box;
  box-sizing: content-box;
  cursor: pointer;
}

.ts-slide::scroll-marker:target-current {
  background: #fff;
  background-clip: content-box;
}

/* ── Prev / next, for a pointer that cannot swipe ────────────────────────────
   🐛 **A mouse drag across a scroll container does nothing**, and dragging the
   photograph is the first thing a laptop reader tries — measured, not assumed.
   A trackpad two-finger swipe and shift+wheel both work, but a plain mouse had
   only the dots, and a reader who never realises the photograph moves does not
   go looking for them.

   ⚠️ **`::scroll-button()` is the same CSS-carousel mechanism as
   `::scroll-marker`**, so this stays inside § 7's no-third-script rule. A
   browser without it simply renders no buttons and keeps the dots.

   📌 **Pointer devices only.** On touch the swipe *is* the gesture and the
   arrows are clutter over the photograph; `hover: hover` is what separates
   them. They are NOT hover-gated within that — a control that appears only
   under the cursor is invisible to the reader who has not moved it there yet,
   which is the whole failure this fixes. */
@media (hover: hover) {
  .ts-hero--multi .ts-hero-track::scroll-button(left),
  .ts-hero--multi .ts-hero-track::scroll-button(right) {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    width: 34px;
    height: 34px;
    display: flex;
    align-items: center;
    justify-content: center;
    border: 0;
    border-radius: 50%;
    /* ⚠️ **The dark chip alone is not enough HERE, unlike every other overlay
       on this hero.** The badge and the pills sit over the photograph; these sit
       in the letterbox plate beside it, which in dark mode is `surface_card` —
       so a 62%-black circle on it is nearly invisible and the disabled one read
       as an empty ring. The hairline gives the button an edge on the plate
       without touching how it reads over a bright photograph, where the fill is
       still doing the work. */
    box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.22);
    background-color: rgba(0, 0, 0, 0.62);
    background-repeat: no-repeat;
    background-position: center;
    background-size: 11px 11px;
    cursor: pointer;
  }

  /* ⚠️ **Drawn, not typed.** These were `\2039` and `\203a` — single angle
     quotation marks — and a quotation mark is not a symmetrical glyph: it
     carries its own side bearings and sits on the text baseline rather than in
     the middle of the em box, so it lands off-centre inside a circular button.
     Worse, § 7 sets a *system font stack*, so which font supplies the character
     is the reader's OS and the offset is different on each one. A path is
     geometrically exact and identical everywhere. The colour is baked white
     because these only ever sit on their own dark chip. */
  .ts-hero--multi .ts-hero-track::scroll-button(left) {
    content: '';
    left: 10px;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M13 4 7 10l6 6' fill='none' stroke='%23fff' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  }

  .ts-hero--multi .ts-hero-track::scroll-button(right) {
    content: '';
    right: 10px;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M7 4l6 6-6 6' fill='none' stroke='%23fff' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  }

  /* ⚠️ **Dimmed, never hidden.** A control that disappears at the first and
     last photograph moves the other one under the cursor mid-click, and its
     absence reads as the carousel having broken rather than having ended. */
  .ts-hero--multi .ts-hero-track::scroll-button(left):disabled,
  .ts-hero--multi .ts-hero-track::scroll-button(right):disabled {
    opacity: 0.45;
    cursor: default;
  }

  .ts-hero--multi .ts-hero-track::scroll-button(left):focus-visible,
  .ts-hero--multi .ts-hero-track::scroll-button(right):focus-visible {
    outline: 2px solid var(--ts-brand);
    outline-offset: 2px;
  }
}

/* ⚠️ **Past eight photographs the row stops centring, and it must not shrink
   instead.** The wizard's cap is ten; ten 24px targets is a 240px row, which
   centred at 320px runs from 40 to 280 and reaches the views pill at 264 —
   measured, not estimated. Tightening the markers to close the gap would put
   them under WCAG 2.5.8's 24px, so the group gives up the convention rather
   than the target size. Nine is the first count that collides (52→268). */
.ts-hero-track:has(.ts-slide:nth-child(9))::scroll-marker-group {
  left: 10px;
  transform: none;
}

.ts-hero-track::scroll-marker-group:focus-visible {
  outline: 2px solid var(--ts-brand);
}

/* Bottom-right is the views pill and top-left is the grade flag, so the count
   takes the one free corner. Its own dark chip, like every other overlay here:
   the plate under it may be white. */
.ts-pill--count {
  right: 10px;
  top: 10px;
}

/* Chrome over photography is light-on-dark in both themes — and now over a
   plate that may be white, which is why every one of these carries its own
   dark chip rather than relying on the surface under it. */
.ts-pill {
  position: absolute;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 4px 9px;
  border-radius: var(--ts-radius-pill);
  background: rgba(0, 0, 0, 0.62);
  color: #fff;
  font-size: 11px;
  font-weight: 600;
}

.ts-pill--views {
  right: 10px;
  bottom: 10px;
}

/* ⚠️ **A dark chip with coloured text, not coloured fill with dark text.**
   `.ts-badge--mint` was #4caf50 at 18% behind #1e7a2b, which over the bright
   orange of a Matchbox blister measured as an unreadable smudge — the flag
   sits on a *photograph*, so it cannot assume anything about what is behind
   it. The dark chip is the same answer the app uses for the page counter and
   the view pill, and it works over a white hero plate too. */
.ts-badge {
  position: absolute;
  top: 10px;
  left: 10px;
  padding: 3px 8px;
  border-radius: 5px;
  background: rgba(0, 0, 0, 0.62);
  font-size: 9px;
  font-weight: 800;
  letter-spacing: 0.4px;
  color: #fff;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
}

.ts-badge--mint {
  color: #8ee39a;
}

.ts-badge--super {
  color: #c3bdfb;
}

.ts-badge--rare {
  color: #ffcf7a;
}

/* ---- content ---- */

.ts-header {
  padding: var(--ts-gutter);
}

.ts-title {
  margin: 0;
  font-size: 22px;
  font-weight: 800;
  letter-spacing: -0.4px;
  line-height: 1.25;
  /* A collectible name is a value somebody typed and can be one unbroken
     token — a 40-character part number pushed the page sideways at 375pt. */
  overflow-wrap: anywhere;
}

.ts-meta {
  margin: 5px 0 0;
  color: var(--ts-text-body);
  font-size: 12.5px;
  font-weight: 500;
}

.ts-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 12px;
}

.ts-chip {
  padding: 4px 10px;
  border-radius: var(--ts-radius-pill);
  background: var(--ts-surface-raised);
  color: var(--ts-text-body);
  font-size: 11px;
  font-weight: 600;
}

.ts-chip--public {
  background: rgba(76, 175, 80, 0.16);
  color: #1e7a2b;
}

/* #1e7a2b is 2.4:1 on the dark chip. The light-theme value is a deliberate
   AA choice on paper and simply does not transfer.

   ⚠️ **Anything that overrides per theme needs BOTH selectors**, not just the
   media query — otherwise a reader who switches to dark by hand gets the dark
   surfaces with this chip still in its light-theme ink. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .ts-chip--public {
    background: rgba(76, 175, 80, 0.18);
    color: #79d187;
  }
}

:root[data-theme='dark'] .ts-chip--public {
  background: rgba(76, 175, 80, 0.18);
  color: #79d187;
}

.ts-section {
  padding: 0 var(--ts-gutter);
  margin-top: 26px;
}

.ts-section-head {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 10px;
}

.ts-section-head::before {
  content: '';
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--ts-brand);
  flex-shrink: 0;
}

.ts-section-head span {
  color: var(--ts-brand);
  font-size: 10px;
  font-weight: 800;
  letter-spacing: 0.7px;
  text-transform: uppercase;
}

.ts-rows {
  border: var(--ts-hairline) solid var(--ts-border);
  border-radius: var(--ts-radius-md);
  background: var(--ts-surface-card);
  padding: 0 14px;
  margin: 0;
}

.ts-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 16px;
  padding: 11px 0;
  border-bottom: var(--ts-hairline) solid var(--ts-border);
}

.ts-row:last-child {
  border-bottom: none;
}

.ts-row dt {
  margin: 0;
  color: var(--ts-text-body);
  font-size: 12px;
  font-weight: 500;
  flex-shrink: 0;
}

.ts-row dd {
  margin: 0;
  text-align: right;
  font-size: 12.5px;
  font-weight: 700;
  /* Per-type values are collector-typed and unbounded — a serial number with
     no spaces in it widened the row and pushed the label off the left. */
  overflow-wrap: anywhere;
}

.ts-row dd.is-accent {
  color: var(--ts-brand);
}

/* ---- an unfilled row ----
 *
 * ⚠️ **Every field a type declares renders, filled or not** — that is
 * `resolveFields`' rule and it is deliberate: an em dash is the app's own way
 * of saying *nobody recorded this*, which is a different claim from the field
 * not existing, and the page matching the app's detail screen is what the
 * 2026-08-12 fix was for. Reaffirmed by the product owner on 2026-08-19.
 *
 * 📌 **What was wrong was the weighting, not the presence.** A Toy Cars piece
 * declares 34 rows and a collector typically fills two, so the live page ran to
 * 27 identical dashes with `Matchbox` and `30782` buried among them — every row
 * shouting equally, so nothing was legible as the answer. These rows keep their
 * label, their dash and their place in the app's field order; they give up the
 * vertical space and the visual weight.
 *
 * ⚠️ **The label stays on `--ts-text-body`, not `--ts-text-faint`.** TASK-127
 * restricted the faint token to decorative and disabled states because it
 * measures 3.1:1 on card, and a field name is neither — a reader scanning for
 * *which* fields exist is doing something legitimate. Only the dash itself, a
 * single decorative glyph standing for absence, takes the faint ink. */

.ts-row--empty {
  padding: 7px 0;
}

.ts-row--empty dd {
  color: var(--ts-text-faint);
  font-weight: 500;
}

/* Description is prose rather than a row, so it carries the same state under
   its own class — without it the one em dash on the page that is NOT in a row
   sat at full body weight, reading as a real description of a dash. */
.ts-prose.is-empty {
  color: var(--ts-text-faint);
}

.ts-prose {
  color: var(--ts-text-body);
  font-size: 13.5px;
  line-height: 1.7;
  margin: 0;
  overflow-wrap: anywhere;
}

/* A link inside prose is the brand orange, like every other link on these
   pages. Without this it inherits the browser default — a blue underline on
   warm paper, which is the one colour nothing else here uses.

   ⚠️ **Scoped to `.ts-prose`, not a bare `a`.** Every link rule in this file is
   scoped to its context (`.ts-footer a`, `.ts-meta a`, `.ts-pager a`, …) so
   each surface picks its own weight against its own background; a global rule
   would quietly restyle all of them. */
.ts-prose a {
  color: var(--ts-brand);
  font-weight: 600;
  text-decoration: none;
}

.ts-prose a:hover,
.ts-prose a:focus-visible {
  text-decoration: underline;
}

/* ---- owner / collection header ---- */

.ts-owner {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: var(--ts-gutter);
}

.ts-avatar {
  width: 52px;
  height: 52px;
  border-radius: var(--ts-radius-lg);
  background: var(--ts-surface-raised);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 26px;
  flex-shrink: 0;
  overflow: hidden;
}

.ts-avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ---- the shelf ---- */

.ts-stage {
  margin: 0 var(--ts-gutter);
  padding: 14px;
  background: var(--ts-stage);
  border: var(--ts-hairline) solid var(--ts-stage-edge);
  border-radius: var(--ts-radius-lg);
  display: grid;
  /* minmax(0, 1fr), not 1fr: `1fr` is `minmax(auto, 1fr)` and will not shrink
     below its content's min-content width, so one long collectible name
     ("Lamborghini Aventador Coupé") widened its column and left the two tiles
     visibly different sizes. */
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
  align-items: start;
}

/* ⚠️ **A one-tile shelf, which the two-column grid drew as half a plate of
   empty black.** The commonest profile in the product has exactly one public
   collection, so this was the *default* appearance of the page a new collector
   shares. CSS cannot count, so the template sets the modifier — it knows the
   length already. Capped rather than stretched: a single tile blown up to the
   full measure reads as a hero nobody asked for. */
.ts-stage--one {
  grid-template-columns: minmax(0, 260px);
  justify-content: center;
}

.ts-frame {
  display: block;
  text-decoration: none;
  color: inherit;
}

.ts-frame .ts-photo {
  aspect-ratio: 1 / 1.2;
  border: 0.8px solid rgba(245, 166, 35, 0.35);
  border-radius: var(--ts-radius-md);
  background: var(--ts-photo-bg);
}

/* A tile is the only interactive thing on two of the three pages, and it had
   no hover, no focus ring and no press state at all — so on a laptop nothing
   on the page responded to a pointer. */
.ts-frame:hover .ts-photo {
  border-color: rgba(245, 166, 35, 0.75);
}

.ts-frame:focus-visible {
  outline: none;
}

.ts-frame:focus-visible .ts-photo {
  outline: 2px solid var(--ts-brand);
  outline-offset: 2px;
}

/* ⚠️ **Two lines, clamped — it was one line with an ellipsis**, which turned
   "'98 Subaru Impreza 22B STi" into "'98 Subaru Impreza 22B…" in a two-column
   grid with room to spare. The fixed min-height is what keeps the rows of a
   grid aligned once some names wrap and others do not. */
/* ⚠️ **Themed, not white.** These were `#fff` and `rgba(255,255,255,.55)` in
   both themes, which is what made the plate a dark display case rather than a
   surface: the captions could not survive a light plate, so the plate could
   never become one. They read against `--ts-stage`, which is `surface_raised`
   in light — 14.6:1 for the name and 4.5:1 for the count, both measured off the
   rendered element rather than assumed. */
.ts-frame-name {
  margin: 8px 0 0;
  min-height: 2.4em;
  text-align: center;
  color: var(--ts-text-heading);
  font-size: 11.5px;
  font-weight: 600;
  line-height: 1.2;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  overflow-wrap: anywhere;
}

.ts-frame-views {
  margin: 2px 0 0;
  text-align: center;
  color: var(--ts-text-body);
  font-size: 9.5px;
}

/* The collection page's tile, now shared with the profile's shelf rows
   (TASK-486): its badge sits at the foot of the photograph on this plate, and
   its glyph is a tile's size rather than a hero's. Both were inline styles on
   the one template that drew them. */
.ts-frame .ts-glyph {
  font-size: 34px;
}

.ts-stage .ts-badge {
  top: auto;
  bottom: 8px;
}

/* ---- the profile, design A — TASK-486 ----
 *
 * The app's order at every width: who they are, what they have made public,
 * then one shelf row per collection. Below 641px the header is centred and
 * each row is a `scroll-snap` strip; from 641px the six fit the width with
 * *View all* and nothing scrolls sideways; from 900px the type scales and the
 * stats sit beside the name. The page stops at `--ts-measure-wide` and
 * centres, so a desktop gets bigger tiles rather than more of them.
 *
 * 🚨 **CSS only**, like the photo carousel: these pages ship exactly two inline
 * scripts, and `assertInert` reads a third as an injection. */

.ts-profile {
  padding: 20px var(--ts-gutter) 0;
}

.ts-profile-id {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}

.ts-profile-avatar {
  width: 76px;
  height: 76px;
  flex-shrink: 0;
  border-radius: var(--ts-radius-pill);
  border: 3px solid var(--ts-surface-card);
  box-shadow: 0 6px 18px rgba(60, 40, 20, 0.1);
  /* The brand tint is translucent, so it is layered over an opaque card
     colour rather than left to show the page through it. */
  background: linear-gradient(rgba(245, 166, 35, 0.14), rgba(245, 166, 35, 0.14)),
    var(--ts-surface-card);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--ts-brand-ink);
  font-size: 28px;
  font-weight: 800;
  overflow: hidden;
}

.ts-profile-avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.ts-profile-name {
  margin: 12px 0 0;
  font-size: 21px;
  line-height: 27px;
  font-weight: 800;
  letter-spacing: -0.3px;
  overflow-wrap: anywhere;
}

.ts-profile-handle {
  margin: 2px 0 0;
  color: var(--ts-text-body);
  font-size: 13px;
  font-weight: 500;
}

.ts-profile-rank {
  margin: 10px 0 0;
}

.ts-rank-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 11px;
  border-radius: var(--ts-radius-pill);
  background: rgba(245, 166, 35, 0.14);
  border: 1px solid rgba(255, 141, 40, 0.3);
  color: var(--ts-brand-ink);
  font-size: 12px;
  line-height: 16px;
  font-weight: 700;
}

/* A `dl` for what it is, with each value drawn above its label. */
.ts-stats {
  display: flex;
  margin: 16px 0 0;
  padding: 12px 6px;
  background: var(--ts-surface-card);
  border: 1px solid var(--ts-border);
  border-radius: var(--ts-radius-lg);
}

.ts-stats > div {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column-reverse;
  align-items: center;
  gap: 2px;
  text-align: center;
}

.ts-stats > div + div {
  border-left: 1px solid var(--ts-border);
}

.ts-stats dt {
  color: var(--ts-text-body);
  font-size: 11px;
  font-weight: 500;
}

.ts-stats dd {
  margin: 0;
  font-size: 18px;
  line-height: 24px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
}

.ts-shelves-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  padding: 26px calc(var(--ts-gutter) + 2px) 12px;
}

.ts-shelves-head h2 {
  margin: 0;
  color: var(--ts-text-body);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.5px;
  text-transform: uppercase;
}

.ts-shelves-head span {
  color: var(--ts-text-body);
  font-size: 11px;
  font-weight: 600;
}

.ts-shelves {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 0 var(--ts-gutter);
}

.ts-shelf {
  background: var(--ts-surface-card);
  border: 1px solid var(--ts-border);
  border-radius: 18px;
  padding: 14px 0;
  overflow: hidden;
}

.ts-shelf-head {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 0 12px 0 14px;
  color: inherit;
  text-decoration: none;
}

.ts-shelf-head:focus-visible {
  outline: 2px solid var(--ts-brand);
  outline-offset: 2px;
  border-radius: var(--ts-radius-md);
}

.ts-shelf-glyph {
  width: 44px;
  height: 44px;
  flex-shrink: 0;
  border-radius: 12px;
  border: 1px solid rgba(255, 141, 40, 0.22);
  background: linear-gradient(160deg, rgba(245, 166, 35, 0.14), rgba(255, 141, 40, 0.3)),
    var(--ts-surface-card);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 24px;
  line-height: 1;
}

.ts-shelf-names {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.ts-shelf-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 16px;
  line-height: 21px;
  font-weight: 800;
}

.ts-shelf-sub {
  color: var(--ts-text-body);
  font-size: 12px;
  line-height: 16px;
  font-weight: 500;
}

.ts-shelf-all {
  display: none;
  color: var(--ts-brand-ink);
  font-size: 13px;
  font-weight: 700;
}

.ts-shelf-chevron {
  flex-shrink: 0;
  color: var(--ts-text-faint);
}

/* The strip. 100px tiles and a 10px gap put three and the edge of a fourth on
   a phone, which is what says it scrolls. */
.ts-shelf-row {
  display: flex;
  gap: 10px;
  margin-top: 12px;
  padding: 0 14px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding: 0 14px;
  scrollbar-width: none;
}

.ts-shelf-row::-webkit-scrollbar {
  display: none;
}

.ts-shelf-row .ts-frame {
  position: relative;
  flex: 0 0 100px;
  height: 140px;
  scroll-snap-align: start;
}

.ts-shelf-row .ts-frame .ts-photo {
  height: 100%;
  aspect-ratio: auto;
  border-radius: 14px;
}

.ts-shelf-row .ts-frame .ts-glyph {
  font-size: 44px;
}

.ts-shelf-row .ts-badge {
  top: 7px;
  left: 7px;
}

/* The caption lies over the photograph as one panel — the app's own tile. White
   on its own dark scrim in both themes, because it sits on photography (§ 1). */
.ts-shelf-row .ts-frame-caption {
  position: absolute;
  left: 6px;
  right: 6px;
  bottom: 6px;
  padding: 5px 8px;
  border-radius: 9px;
  background: rgba(28, 23, 20, 0.66);
}

/* One line, cut by the character — the two-line clamp the plate uses breaks
   at a word, which in a 100px tile leaves "1958…" of "1958 Chevrolet". */
.ts-shelf-row .ts-frame-name {
  display: block;
  margin: 0;
  min-height: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  text-align: left;
  color: #fff;
  font-size: 10.5px;
  line-height: 14px;
  font-weight: 700;
}

.ts-shelf-row .ts-frame-views {
  margin: 1px 0 0;
  text-align: left;
  color: rgba(255, 255, 255, 0.88);
  font-size: 9.5px;
  font-weight: 600;
}

@media (min-width: 641px) {
  .ts-profile {
    padding-top: 24px;
  }

  .ts-profile-id {
    flex-direction: row;
    gap: 18px;
    text-align: left;
  }

  .ts-profile-avatar {
    width: 80px;
    height: 80px;
    font-size: 30px;
  }

  .ts-profile-name {
    margin-top: 0;
    font-size: 22px;
    line-height: 28px;
  }

  .ts-profile-rank {
    margin-top: 8px;
  }

  .ts-stats {
    margin-top: 18px;
  }

  .ts-shelves {
    gap: 14px;
  }

  .ts-shelf {
    padding: 16px 0;
  }

  .ts-shelf-head {
    padding: 0 16px;
  }

  .ts-shelf-glyph {
    width: 46px;
    height: 46px;
    border-radius: 13px;
    font-size: 25px;
  }

  .ts-shelf-name {
    font-size: 17px;
    line-height: 22px;
  }

  .ts-shelf-sub {
    font-size: 12.5px;
    line-height: 17px;
  }

  .ts-shelf-all {
    display: inline;
  }

  .ts-shelf-chevron {
    width: 15px;
    height: 15px;
    margin-left: -9px;
    color: var(--ts-brand-ink);
  }

  /* All six fit the width, and nothing scrolls sideways. */
  .ts-shelf-row {
    display: grid;
    grid-template-columns: repeat(6, minmax(0, 1fr));
    margin-top: 14px;
    padding: 0 16px;
    overflow: visible;
  }

  .ts-shelf-row .ts-frame {
    height: auto;
    aspect-ratio: 3 / 4;
  }
}

@media (min-width: 900px) {
  .ts-profile {
    display: flex;
    align-items: center;
    gap: 22px;
    padding-top: 28px;
  }

  .ts-profile-id {
    flex: 1 1 auto;
    min-width: 0;
    gap: 22px;
  }

  .ts-profile-avatar {
    width: 96px;
    height: 96px;
    font-size: 36px;
  }

  .ts-profile-name {
    font-size: 27px;
    line-height: 34px;
    letter-spacing: -0.4px;
  }

  .ts-profile-handle {
    font-size: 13.5px;
  }

  .ts-profile-rank {
    margin-top: 10px;
  }

  .ts-stats {
    flex: 0 0 440px;
    margin-top: 0;
  }

  .ts-stats dd {
    font-size: 20px;
    line-height: 26px;
  }

  .ts-shelves {
    gap: 16px;
  }

  .ts-shelf {
    padding: 18px 0;
  }

  .ts-shelf-head {
    padding: 0 18px;
  }

  .ts-shelf-glyph {
    width: 48px;
    height: 48px;
    font-size: 26px;
  }

  .ts-shelf-name {
    font-size: 18px;
    line-height: 23px;
  }

  .ts-shelf-sub {
    font-size: 13px;
  }

  .ts-shelf-row {
    gap: 12px;
    margin-top: 16px;
    padding: 0 18px;
  }

  .ts-shelf-row .ts-frame .ts-glyph {
    font-size: 64px;
  }

  .ts-shelf-row .ts-frame-name {
    font-size: 12px;
    line-height: 16px;
  }

  .ts-shelf-row .ts-frame-views {
    font-size: 10.5px;
  }
}

@media (min-width: 1180px) {
  .ts-shelves {
    gap: 20px;
  }

  .ts-shelf-glyph {
    width: 50px;
    height: 50px;
    border-radius: 14px;
    font-size: 27px;
  }

  .ts-shelf-name {
    font-size: 19px;
    line-height: 25px;
  }

  .ts-shelf-row {
    gap: 16px;
  }
}

/* ---- join banner ---- */

.ts-join {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 6;
  background: var(--ts-surface-card);
  border-top: var(--ts-hairline) solid var(--ts-border);
  padding-bottom: env(safe-area-inset-bottom);
}

.ts-join-in {
  max-width: var(--ts-measure-wide);
  margin: 0 auto;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px var(--ts-gutter);
}

.ts-join-copy {
  flex: 1;
  min-width: 0;
}

.ts-join-title {
  margin: 0;
  font-size: 13px;
  font-weight: 800;
  letter-spacing: -0.2px;
}

.ts-join-sub {
  margin: 1px 0 0;
  color: var(--ts-text-body);
  font-size: 11px;
}

/* ⚠️ **`min-height: 44px`, which it did not have.** It measured 42 — under
   WCAG 2.5.8's 24px floor comfortably, but under Apple HIG's and Material's
   44/48 on the one control every public page ends on, tapped on a handset. The
   box is `inline-flex` rather than `inline-block` so the label stays centred
   once the height is a floor rather than the sum of its padding. */
.ts-btn {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 11px 18px;
  border-radius: 11px;
  background: linear-gradient(135deg, var(--ts-amber), var(--ts-brand));
  color: var(--ts-text-on-brand);
  font-size: 13px;
  font-weight: 700;
  text-decoration: none;
  border: none;
}

/* 📌 **Unscoped since TASK-288, 2026-09-22.** It was `.ts-btn:not(.is-soon)`
   while the root page's CTA was shown rather than linked — a gradient that
   lights up under the pointer is a promise of a click, and there was none to
   keep. That button links to `/download` now and **no button anywhere is
   show-only**, so the exclusion excluded nothing and `.ts-btn.is-soon { cursor:
   default }` went with it.
   ⚠️ **`is-soon` itself is still live on the BADGES** (`.ts-store-badge.is-soon`),
   which is a different selector and a different case: the App Store badge is a
   `<span>` until `APP_STORE_ID` is filled in. Do not delete that one too. */
.ts-btn:hover {
  background: linear-gradient(135deg, var(--ts-brand), var(--ts-brand-pressed));
}

/* docs/design.md § 4's **secondary** Button variant — 2026-08-19.
 *
 * 📌 **Secondary, not `outline`.** The app's `outline` is a brand-orange edge
 * and a brand-orange label (`ts_button.dart`); its `secondary` is a
 * `surface_card` fill with a neutral edge and a heading-ink label, which is
 * this. Naming it after the wrong variant is how a later reader "corrects" it
 * to orange.
 *
 * ⚠️ **The recovery action on a state page must NOT be a second gradient
 * button.** The sticky join banner already carries one, and it is the single
 * dominant element on every public page by design; a second one beside it makes
 * the page argue with itself about what to do. The offer stays the loudest
 * thing here — what changes is that leaving is now possible at all.
 *
 * ⚠️ **`--ts-border-interactive`, not `--ts-border-strong`.** The card hairline
 * measures 1.24:1 against the page: right for a *surface*, and not enough for
 * the only control on a page a stranger opened on an unknown handset. This one
 * clears 3:1 against the fill and against the page, in both themes.
 * 📌 **The app's secondary button keeps the quiet hairline**, deliberately — it
 * is always inside a screen that has already identified itself, where this is a
 * lone control on a page whose only other job is to explain a dead end. § 7 is
 * the arbiter for exactly that kind of web-only divergence. */
.ts-btn--quiet {
  background: var(--ts-surface-card);
  color: var(--ts-text-heading);
  border: 1px solid var(--ts-border-interactive);
}

.ts-btn--quiet:hover {
  background: var(--ts-surface-raised);
  border-color: var(--ts-text-heading);
}

.ts-btn:focus-visible {
  outline: 2px solid var(--ts-text-heading);
  outline-offset: 2px;
}

/* ---- empty and error states ---- */

/* Shared by the 404, the private-collection page and "nothing public here
   yet". These were three inline `style="margin-top:48px;text-align:center"`
   attributes, which is how the three drifted apart in padding. */
.ts-empty {
  padding: 56px var(--ts-gutter) 24px;
  text-align: center;
}

.ts-empty .ts-title {
  font-size: 19px;
}

.ts-empty .ts-prose {
  margin-top: 8px;
}

/* The lead sentence takes the HEADING ink.
   ⚠️ **Its original reason was a contrast failure and that reason is gone** —
   `--ts-text-body` was under AA on warm paper and has been corrected at the
   token. What stands on its own is hierarchy: on a state page this sentence
   *is* the content rather than a caption under something, and `.ts-empty-hint`
   below it is the secondary line. Both are legible now; this one is meant to
   be read first. */
.ts-empty .ts-empty-lead {
  color: var(--ts-text-heading);
  max-width: 42ch;
}

/* The mark a state leads with — 2026-08-19.
 *
 * ⚠️ **It was a 30px emoji at `opacity: 0.55`**, which docs/design.md § 3 rules
 * out twice over: emoji here are the collectible-*category* set and always sit
 * in a tinted rounded-square tile, and this named no category and sat in
 * nothing. Faded on warm paper it read as *an image that failed to load* — the
 * exact failure `.ts-hero--empty` two rules up exists to prevent. The glyph is
 * drawn now (`stateMark` in chrome.ts, box and lock, both named in § 3's set)
 * and this is the tile it was always supposed to be in: `.ts-avatar`'s
 * geometry, one step larger.
 *
 * 📌 **No opacity.** `--ts-text-body` on `--ts-surface-raised` measures 4.02:1
 * on paper and 5.12:1 on the dark page — over WCAG 1.4.11's 3:1 for a non-text
 * graphic in both. Fading it was what put it under. */
.ts-empty-mark {
  width: 72px;
  height: 72px;
  margin: 0 auto 18px;
  border-radius: var(--ts-radius-xl);
  background: var(--ts-surface-raised);
  color: var(--ts-text-body);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.ts-empty-mark svg {
  display: block;
}

.ts-empty-icon img {
  width: 72px;
  height: 72px;
  display: block;
}

/* What a working link looks like. Secondary to the diagnosis above it, so this
   is the one line on the page that legitimately takes the body ink. */
.ts-empty-hint {
  margin: 14px 0 0;
  max-width: 46ch;
  color: var(--ts-text-body);
  font-size: 12.5px;
  line-height: 1.6;
}

/* ⚠️ **`font-family: inherit`.** A `<code>` element defaults to the browser's
   monospace, which on these pages is a second typeface nothing else uses — and
   docs/design.md § 7 ships no web font precisely so the page paints on the
   first frame. Weight and ink do the separating instead. */
.ts-empty-hint code {
  font-family: inherit;
  font-weight: 700;
  color: var(--ts-text-heading);
  overflow-wrap: anywhere;
}

.ts-empty-actions {
  margin-top: 22px;
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  justify-content: center;
}

/* ---- the origin root (2026-08-19) ----
 *
 * `web_public/index.html`. It exists so `/` is not byte-identical to the page a
 * made-up username gets, and it is the address every shared link carries — so
 * trimming a URL back to its domain, which is what a reader does when a link
 * interests them, has to land somewhere that reads as a product.
 *
 * ⚠️ **It uses the same bar, switch and footer as every rendered page** and is
 * generated from the same template for that reason. It was a hand-written file
 * with three `style="margin-top:…"` sections and no brand on it at all. */

.ts-root {
  max-width: 720px;
}

.ts-intro {
  padding: 44px var(--ts-gutter) 8px;
  text-align: center;
}

.ts-intro-mark {
  display: block;
  width: 76px;
  height: 76px;
  margin: 0 auto 20px;
  background: url('/brand-mark-light.png') center / contain no-repeat;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .ts-intro-mark {
    background-image: url('/brand-mark-dark.png');
  }
}

:root[data-theme='dark'] .ts-intro-mark {
  background-image: url('/brand-mark-dark.png');
}

.ts-intro-title {
  margin: 0;
  font-size: 28px;
  font-weight: 800;
  letter-spacing: -0.6px;
  line-height: 1.2;
}

.ts-intro-lede {
  margin: 14px auto 0;
  max-width: 30em;
  color: var(--ts-text-body);
  font-size: 14.5px;
  line-height: 1.7;
}

/* The one button on the page, so it is bigger than the banner's. */
.ts-btn--lg {
  margin-top: 24px;
  padding: 14px 26px;
  font-size: 14.5px;
  border-radius: 13px;
}

/* ---- the official store badges (2026-08-19) ----
 *
 * 🚨 **Apple's and Google's artwork, used unmodified.** Both guidelines forbid
 * redrawing, recolouring, re-proportioning or rebuilding the badge — so these
 * rules only ever *place* the files. Nothing here may set a `filter`, a
 * `border-radius`, a background colour behind a badge, or a non-uniform scale.
 *
 * ⚠️ **Sized by HEIGHT with `background-size: contain`**, so each badge keeps
 * its own aspect ratio whatever the file's intrinsic dimensions are. Setting a
 * width as well is how a badge gets stretched, which is the modification that
 * is easiest to make by accident.
 *
 * ⚠️ **The two heights differ ON PURPOSE and are not a mistake to tidy up.**
 * Google's asset bakes its required clear space into the image while Apple's
 * does not, so at equal heights the Play badge's visible ink is noticeably
 * smaller. The taller Play box is what makes the two read as the same size —
 * and it is also how Play gets the clear space its guideline asks for.
 *
 * 📌 `gap` and the row's own margin carry Apple's clear-space requirement,
 * which is a fraction of the badge height on every side. */

.ts-badges {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  /* ⚠️ **Neither SVG carries clear space, so this row is where it comes from.**
     Google asks for space equal to a quarter of the badge height and Apple for a
     tenth; at 44px that is 11px and 4.4px, so 16px clears both with room. */
  gap: 12px 16px;
  margin-top: 22px;
  /* ⚠️ **`NO_STORE_DESTINATION` scrolls here, and `.ts-bar` is `position:
     sticky; top: 0`** — so without this the row lands *under* the bar and the
     reader arrives looking at the thing they were sent past. 47px is the bar,
     the same literal `.ts-page--fill` subtracts above; the rest is breathing
     room so the badges are not flush against it. */
  scroll-margin-top: calc(47px + 16px);
}

.ts-store-badge {
  display: block;
  background-repeat: no-repeat;
  background-position: center;
  background-size: contain;
  /* ⚠️ **Equal HEIGHT, and the width comes from each badge's own ratio.** Both
     guidelines place badges side by side at the same height; the widths differ
     because the lockups do. `aspect-ratio` is read off the supplied files —
     Apple 119.66×40, Google 239.17×70.87 — so each anchor is exactly its badge
     and `gap` alone controls the space between them.

     📌 **A wrong ratio here can only add padding, never distort**, because
     `background-size: contain` governs the artwork. So replacing a file with
     another locale's (which are much wider) is safe, and only wants these two
     numbers re-read. */
  height: 44px;
}

/* ⚠️ **A badge with no listing is a `<span>` and must not pretend otherwise.**
   It is visually identical to the linked one on purpose — the pair reads as a
   pair — so the only cue is the cursor. The artwork may not be dimmed,
   greyscaled or recoloured to signal it. */
.ts-store-badge.is-soon {
  cursor: default;
}

.ts-store-badge:focus-visible {
  outline: 2px solid var(--ts-brand);
  outline-offset: 4px;
  border-radius: 2px;
}

/* ⚠️ **ONE Apple file, so there is no theme swap.** The supplied asset is the
   **black** variant (`…_RGB_blk_092917`) and it is used in both themes.

   📌 **It reads correctly on the dark page, which was checked rather than
   assumed.** The badge carries a light border — drawn as a *filled path*, not a
   `stroke`, so grepping for `stroke=` finds nothing and suggests the opposite —
   and that border is what separates the black lockup from `#0D0F14`.

   ⚠️ **Apple's guideline still prefers the white badge on a dark background**,
   so the pair is worth completing even though the black one is legible.
   Dropping `badge-app-store-white.svg` into `assets/img/`, adding it to the copy
   list in `tool/make_web_brand.swift`, and uncommenting the two rules below
   turns it on; nothing else changes.

       @media (prefers-color-scheme: dark) {
         :root:not([data-theme='light']) .ts-store-badge--ios {
           background-image: url('/img/badge-app-store-white.svg');
         }
       }
       :root[data-theme='dark'] .ts-store-badge--ios {
         background-image: url('/img/badge-app-store-white.svg');
       }
*/
.ts-store-badge--ios {
  aspect-ratio: 119.66 / 40;
  background-image: url('/img/badge-app-store.svg');
}

/* ⚠️ **Google ships ONE badge for both backgrounds**, so this has no theme rule
   and that asymmetry is correct — inventing a colourway to match Apple's pair
   is the modification the guideline forbids.

   🐛 **This was 58px against Apple's 44 and that was wrong for the SVG.** The
   figure came from Google's **PNG**, which carries its required clear space
   inside the image; the SVG's first element is a rounded rect spanning the whole
   viewBox, so the artwork is full-bleed exactly like Apple's and the two want
   the same height. Measured off the file rather than assumed. */
.ts-store-badge--play {
  aspect-ratio: 239.17 / 70.87;
  background-image: url('/img/badge-google-play.svg');
}

.ts-cards {
  display: grid;
  gap: 12px;
  padding: 34px var(--ts-gutter) 0;
}

.ts-card {
  border: var(--ts-hairline) solid var(--ts-border);
  border-radius: var(--ts-radius-lg);
  background: var(--ts-surface-card);
  padding: 16px 18px;
}

.ts-card-title {
  margin: 0 0 6px;
  font-size: 14px;
  font-weight: 800;
  letter-spacing: -0.2px;
}

.ts-card strong {
  color: var(--ts-text-heading);
  font-weight: 700;
}

@media (min-width: 641px) {
  .ts-intro {
    padding-top: 60px;
  }

  .ts-intro-title {
    font-size: 34px;
  }

  .ts-cards {
    grid-template-columns: 1fr 1fr;
    gap: 14px;
  }
}

/* ---- report form (TASK-100) ----
   A plain HTML form: the public pages ship no JavaScript and this one has no
   reason to start. Every control is a native element, which is also what makes
   it work in the in-app webviews a shared link tends to open in. */

.ts-form {
  padding: 0 var(--ts-gutter);
}

.ts-fieldset {
  border: var(--ts-hairline) solid var(--ts-border);
  border-radius: var(--ts-radius-md);
  background: var(--ts-surface-card);
  padding: 12px 14px 14px;
  margin: 0 0 18px;
}

.ts-legend {
  padding: 0 6px;
  color: var(--ts-text-faint);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 1px;
  text-transform: uppercase;
}

.ts-radio {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 8px 0;
  font-size: 13.5px;
  /* `--ts-text-strong` has never existed in this file, so every one of these
     controls was falling back to the inherited body colour. It happens to be
     the colour they should be, which is why nobody saw it. */
  color: var(--ts-text-heading);
  cursor: pointer;
}

.ts-radio input {
  /* 2px down so the control sits on the text baseline rather than its box. */
  margin: 2px 0 0;
  accent-color: var(--ts-brand);
  flex-shrink: 0;
}

.ts-label {
  display: block;
  margin-bottom: 6px;
  color: var(--ts-text-body);
  font-size: 12.5px;
  font-weight: 600;
}

.ts-textarea {
  display: block;
  width: 100%;
  /* Without border-box the padding pushes it past the gutter. The prototype
     ships no reset, and this is the trap that costs an afternoon. */
  box-sizing: border-box;
  padding: 10px 12px;
  margin-bottom: 18px;
  border: var(--ts-hairline) solid var(--ts-border);
  border-radius: var(--ts-radius-md);
  background: var(--ts-surface-raised);
  color: var(--ts-text-heading);
  font: inherit;
  font-size: 14px;
  resize: vertical;
}

.ts-btn--block {
  /* `flex`, not `block` — full-width behaves identically and keeps `.ts-btn`'s
     centring now that the button has a min-height rather than a content one. */
  display: flex;
  width: 100%;
  box-sizing: border-box;
  border: 0;
  cursor: pointer;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
}

.ts-form-foot {
  margin: 14px 0 0;
  text-align: center;
  font-size: 12.5px;
}

.ts-form-foot a {
  color: var(--ts-text-body);
}

/* ---- pager (collection page only) ----
   TASK-093. The collection page is unbounded, unlike the shelf of six it
   replaces, so it is the one public page that can run to a second screenful.
   `justify-content: space-between` with an empty <span> holding the other side
   keeps "Next →" pinned right on page one, where there is no "Previous". */

.ts-pager {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 18px var(--ts-gutter) 0;
  font-size: 12.5px;
  font-weight: 600;
}

.ts-pager a {
  display: inline-block;
  padding: 8px 14px;
  border-radius: var(--ts-radius-pill);
  border: var(--ts-hairline) solid var(--ts-border-strong);
  background: var(--ts-surface-card);
  color: var(--ts-brand);
  text-decoration: none;
}

/* The collection page's header links back to the owner's profile, and it was
   the first link in a .ts-meta line — so it rendered as unstyled browser blue,
   underlined, in the middle of a muted grey subtitle. Caught by looking at it
   at 375pt; no test would have. */

.ts-meta a {
  color: inherit;
  text-decoration: none;
  font-weight: 700;
}

.ts-meta a:hover,
.ts-meta a:focus-visible {
  text-decoration: underline;
}

/* ---- footer ----
 *
 * ⚠️ **Two rows, not one sentence.** It was a single 11px grey line running
 * "A collection on TrophyShelf — photograph your collectibles, we fill in the
 * details. · Report this page · Privacy · Terms", which put a product pitch, a
 * moderation path and two legal documents in one undifferentiated run of text.
 * The links are the half that has to be findable — a policy a store reviewer
 * cannot see is the thing TASK-130 exists to prevent. */

.ts-footer {
  padding: 34px var(--ts-gutter) 28px;
  margin-top: 34px;
  border-top: var(--ts-hairline) solid var(--ts-border);
  text-align: center;
  color: var(--ts-text-body);
  font-size: 11.5px;
}

.ts-footer p {
  margin: 0;
}

/* A state page's footer carries the legal links and nothing else, so it has no
   sentence above them to be spaced away from. */
.ts-footer--slim {
  margin-top: 0;
  border-top: 0;
  padding-top: 8px;
}

.ts-footer--slim .ts-footer-links {
  margin-top: 0;
}

.ts-footer-links {
  margin-top: 10px;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px 14px;
}

/* ⚠️ **The heading ink, not the body ink.** These are the moderation path and
   the two legal documents — the half of the footer that has to be *found*, on
   the page a store reviewer opens first. At `--ts-text-body` they measured the
   same as the sentence above them and read as more of it. */
.ts-footer a {
  color: var(--ts-text-heading);
  text-decoration: none;
  font-weight: 600;
}

.ts-footer a:hover,
.ts-footer a:focus-visible {
  text-decoration: underline;
}

/* ---- wider than a phone ----
 *
 * ⚠️ **Type scales here and NOT below**, which is the compromise that keeps
 * docs/design.md the arbiter. The token sizes in this file are transcribed
 * from an app that only ever renders on a handset, so they are correct at
 * phone widths and simply describe nothing at 1280px. Scaling at the
 * breakpoint diverges from the app exactly where the app does not exist.
 */

@media (min-width: 641px) {
  .ts-page {
    padding-top: 20px;
  }

  .ts-photo {
    border-radius: var(--ts-radius-lg);
  }

  /* The hero's plate is a rounded card once there is room around it, rather
     than a full-bleed band — and it keeps its own border on all four sides. */
  .ts-hero {
    border: var(--ts-hairline) solid var(--ts-border);
    border-radius: var(--ts-radius-lg);
  }

  .ts-stage {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    padding: 18px;
    gap: 16px;
  }
}

@media (min-width: 900px) {
  body {
    font-size: 15px;
  }

  .ts-title {
    font-size: 27px;
  }

  /* ⚠️ **A specificity bug, and it made this page the smallest heading on the
     site.** `.ts-empty .ts-title` is (0,2,0) and the rule above is (0,1,0), so
     the 404 and the private-collection page rendered a **19px** headline at
     1280px while every other public page rendered 27px — measured live. The
     override has to be restated at matching specificity to scale at all. */
  .ts-empty .ts-title {
    font-size: 23px;
  }

  .ts-meta,
  .ts-row dd {
    font-size: 13.5px;
  }

  .ts-row dt {
    font-size: 13px;
  }

  .ts-prose {
    font-size: 14.5px;
  }

  .ts-row {
    padding: 13px 0;
  }

  .ts-stage {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }

  /* ⚠️ **The collectible page widens HERE and not at 641px.** Below the split
     it is a single column of label/value rows, and a column of rows stops
     being readable long before it stops fitting — so it holds the reading
     measure until there is genuinely a second column to put beside it. */
  .ts-page--split {
    max-width: var(--ts-measure-wide);
  }

  .ts-frame-name {
    font-size: 12.5px;
  }

  .ts-frame-views {
    font-size: 10.5px;
  }

  /* The collectible page becomes two columns: the piece on the left, what is
     known about it on the right. The photograph is what the link was sent for,
     so it stays in view while the rows scroll past it. */
  .ts-split {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 30px;
    align-items: start;
    padding: 0 var(--ts-gutter);
  }

  .ts-split > * {
    min-width: 0;
  }

  .ts-split-media {
    position: sticky;
    top: 78px;
  }

  /* Inside the split the children supply the gutter themselves, so the
     sections stop indenting twice. */
  .ts-split .ts-header,
  .ts-split .ts-section {
    padding-left: 0;
    padding-right: 0;
  }

  .ts-split .ts-header {
    padding-top: 0;
  }

  .ts-split .ts-section:first-child {
    margin-top: 0;
  }
}

@media (min-width: 1180px) {
  .ts-stage {
    gap: 20px;
  }
}

/* A visitor who asks for less motion gets none of the hover transitions, and
   the sticky media column stops sticking — a pane that moves independently of
   the page is exactly the effect that setting is about. */
@media (prefers-reduced-motion: reduce) {
  .ts-split-media {
    position: static;
  }
}
