/* ═══════════════════════════════════════════════════════════════════════
   taming.tools

   Design premise: confidence IS the design system. This site's whole claim
   is that it distinguishes verified from unverified, so provenance has to be
   legible at a glance — not buried in a tag. The four provenance colours are
   semantic and are never used decoratively.

   No JavaScript anywhere (CSP is script-src 'none'), no webfonts (CSP blocks
   external hosts), so the type work is done with system stacks used
   deliberately: real scale steps, weight contrast, and mono for every number.
   ═══════════════════════════════════════════════════════════════════════ */

:root {
  /* Ground — cool-biased neutrals, picked not inherited */
  --ground:     #0B0E14;
  --sunken:     #080B10;
  --raised:     #141922;
  --raised-2:   #1B212C;
  --edge:       #232A36;
  --edge-soft:  #1A202A;

  /* Ink */
  --ink:        #E9EFF7;
  --ink-2:      #97A3B6;
  /* Was #616C7E — measured 3.32:1 on --raised (WCAG AA text needs 4.5:1), the
     background most .faint/.stat .l/table-header text sits on, and this color
     is real content (rank ties, field keys, chart axis labels, timestamps),
     not decoration. Lifted to clear 4.5:1 against --ground/--raised/--raised-2
     (5.49/5.01/4.59 respectively) while staying visibly darker than --ink-2. */
  --ink-3:      #7E89A0;

  /* Accent — interactive + brand only */
  --accent:     #4ADE80;
  --accent-rgb: 74 222 128;
  --accent-ink: #052E16;
  --accent-dim: #1B5A33;

  /* Provenance — semantic, never decorative.
     This is the load-bearing idea of the whole design. */
  --verified:   #4ADE80;   /* datamined: shipped in the client */
  --probable:   #FBBF24;   /* labelled, not yet confirmed */
  --observed:   #A78BFA;   /* server-side: cannot be verified from the client */
  --unknown:    #64748B;   /* captured, unnamed — graphics only (bars, curves,
     strokes, borders): measured 3.39–4.14:1 against --ground/--sunken/
     --raised/--raised-2, which clears the 3:1 non-text minimum those uses
     need but not the 4.5:1 text minimum. Left as-is here on purpose — this
     token drives ten graphical rules (.bar.p-unknown, .curve.p-unknown,
     .hbar.p-unknown, .dist-pin/.dist-dot.p-unknown, .hist-gap, ...) that are
     deliberately recessive, and lightening it would brighten all of them
     past the "less solid than a verified reading" effect they exist for.
     See --unknown-text below for the same tier's actual label text, which
     is real content and needs the higher bar. */
  --unknown-text: #8091A8; /* .prov-unknown / .atlas-gone-label /
     the atlas's gone-label reads this instead of --unknown — those are the
     "UNKNOWN"/"gone"/"no art" words themselves, not decoration, same
     distinction --ink-3 already draws elsewhere on this page. Measured
     5.02–6.13:1 against --ground/--sunken/--raised/--raised-2, clearing
     4.5:1 with the same kind of margin the --ink-3 fix aimed for. */

  /* Change direction */
  --up:         #4ADE80;
  --down:       #FB7185;
  --neutral:    #60A5FA;

  --link:       #7DD3FC;

  /* Type */
  --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  --t-display: 2rem;      /* 32 */
  --t-title:   1.3125rem; /* 21 */
  --t-lead:    1.0625rem; /* 17 */
  --t-body:    0.9375rem; /* 15 */
  --t-small:   0.8125rem; /* 13 */
  --t-micro:   0.6875rem; /* 11 */

  /* Space — one scale, used everywhere */
  --s1: 4px;  --s2: 8px;  --s3: 12px; --s4: 16px;
  --s5: 24px; --s6: 32px; --s7: 48px; --s8: 72px;

  /* The height of .topbar, and what sticks directly beneath it.
     Anything sticky below the bar offsets from HERE rather than carrying its
     own copy of the number. It was written out as a bare `76px` in six places
     across .rail and .infobox, and the bar is `position: sticky; top: 0`, so
     the two only agree while the bar is exactly one row tall. On 2026-09-02 it
     was not: `.topbar nav` wrapped nine links onto a second row at 1200px and a
     third by 760px, taking the bar to 90px and then 126px while the rails went
     on sticking at 76 — so the rails sat UNDERNEATH the bar and the chat rail's
     search box rendered on top of the account chips.
     The bar is one row by construction now (nav and .me shrink and scroll
     rather than wrap), which is what makes this number true. Naming it means
     the next person to change the bar's height changes one thing. */
  --topbar-h: 56px;
  --rail-top: calc(var(--topbar-h) + 20px);

  --r-sm: 6px; --r: 10px; --r-lg: 14px;
}

/* ── Light theme ─────────────────────────────────────────────────────────
   The dark palette above has individually measured contrast ratios in its
   own comments (--ink-3, --unknown/--unknown-text). This one hasn't had that
   same pass yet — it reuses the standard Tailwind 600/700 tier for every
   color that carries text, which is a well-known safe-on-white range, but
   "well-known safe" is a different claim from "measured against this exact
   background" and shouldn't be read as the latter. Same relative structure
   as the dark palette: --raised lighter than --ground, --sunken darker. */
@media (prefers-color-scheme: light) {
  :root {
    --ground:     #F1F3F6;
    --sunken:     #E4E8EE;
    --raised:     #FFFFFF;
    --raised-2:   #FFFFFF;
    --edge:       #D3D9E1;
    --edge-soft:  #E2E6EC;

    --ink:        #10151F;
    --ink-2:      #4B5565;
    --ink-3:      #5B6678;

    --accent:     #15803D;
    --accent-rgb: 21 128 61;
    --accent-ink: #F0FDF4;
    --accent-dim: #DCFCE5;

    --verified:      #15803D;
    --probable:      #92400E;
    --observed:      #6D28D9;
    --unknown:       #64748B;
    --unknown-text:  #334155;

    --up:       #15803D;
    --down:     #B91C1C;
    --neutral:  #1D4ED8;
    --link:     #0369A1;
  }
}

/* An explicit choice (the footer's Light/Dark form, POST /theme) overrides
   system preference either direction — `[data-theme]` beats a bare `:root`
   on specificity regardless of which comes later in the file, so these win
   over the media query above without needing !important. No JS is required
   for either: the toggle is a real form, and data-theme is set server-side
   from the theme cookie (layout.ts) before the page ever paints.
   Values duplicate the two blocks above on purpose — CSS custom properties
   have no way to share a value set between a bare selector and one gated by
   @media, short of repeating them. Keep both pairs in sync by hand:
   [data-theme="light"] mirrors the (prefers-color-scheme: light) block
   above it; [data-theme="dark"] mirrors the plain :root at the top of this
   file. */
:root[data-theme="light"] {
  --ground:     #F1F3F6;
  --sunken:     #E4E8EE;
  --raised:     #FFFFFF;
  --raised-2:   #FFFFFF;
  --edge:       #D3D9E1;
  --edge-soft:  #E2E6EC;

  --ink:        #10151F;
  --ink-2:      #4B5565;
  --ink-3:      #5B6678;

  --accent:     #15803D;
    --accent-rgb: 21 128 61;
  --accent-ink: #F0FDF4;
  --accent-dim: #DCFCE5;

  --verified:      #15803D;
  --probable:      #92400E;
  --observed:      #6D28D9;
  --unknown:       #64748B;
  --unknown-text:  #334155;

  --up:       #15803D;
  --down:     #B91C1C;
  --neutral:  #1D4ED8;
  --link:     #0369A1;
}
:root[data-theme="dark"] {
  --ground:     #0B0E14;
  --sunken:     #080B10;
  --raised:     #141922;
  --raised-2:   #1B212C;
  --edge:       #232A36;
  --edge-soft:  #1A202A;

  --ink:        #E9EFF7;
  --ink-2:      #97A3B6;
  --ink-3:      #7E89A0;

  --accent:     #4ADE80;
  --accent-rgb: 74 222 128;
  --accent-ink: #052E16;
  --accent-dim: #1B5A33;

  --verified:   #4ADE80;
  --probable:   #FBBF24;
  --observed:   #A78BFA;
  --unknown:    #64748B;
  --unknown-text: #8091A8;

  --up:         #4ADE80;
  --down:       #FB7185;
  --neutral:    #60A5FA;
  --link:       #7DD3FC;
}


/* ══ taming.io skin ═══════════════════════════════════════════════════════
   An OVERRIDE LAYER, not a rewrite. Every value below sits inside
   [data-skin="tio"]; the palettes above are untouched, so removing the
   attribute restores the original site exactly. The footer toggle sets it
   server-side from a cookie, the same mechanism as data-theme, so switching
   back is one click and needs no deploy.

   ── Where these numbers come from ──────────────────────────────────────
   Not sampled from screenshots: taming.io's own stylesheet defines 29 CSS
   custom properties, and these are its values verbatim — --brown0 #9e5c33,
   --black #3a322b, --white #ffe5c1, and the yellow/orange/green/red ramps.
   The one thing the stylesheet does NOT contain is the page background: the
   game's ground is canvas art, layered leaf greens, which is why the greens
   here are derived from its own --green2 #6e860e rather than quoted.

   ── The one place this deliberately diverges from the game ─────────────
   The game puts brown behind everything. Measured, cream on --brown0 is
   4.27:1 — under AA for body text — and the confidence colours land between
   1.18 and 3.69, which is unusable for the one distinction this site exists
   to make. So brown is used as CHROME (page frame, nav, borders, rails) and
   the reading surfaces are the game's near-black --black/--black0, where the
   same text measures 10.31 and every confidence state clears 5.9.

   That is not a compromise away from the look — it is what the game already
   does. Its own stat panels, member rows and shop tiles are dark boxes
   inside a brown shell; brown is the frame around data, never behind it. */

:root[data-skin="tio"] {
  /* Ground: the leaf-green world the game floats its UI on — deep and low
     chroma, because the game paints texture and foliage over its green and we
     have a flat fill instead. The first pass used the game's own mid-green at
     full saturation and it read as acid olive across a whole page of text. */
  /* The content surfaces are deliberately brown, not the neutral near-blacks
     the game uses for its nested boxes. Its panel is #9e5c33 at 41%
     lightness, so a 15%-saturation dark box next to it still reads as dark.
     Our frame has to be much darker to carry body text, which leaves a small
     lightness step — and across a small step it is the NEUTRALITY that shows,
     not the darkness. Measured: the old surfaces were 6-18% saturation inside
     a 61% frame. These are 62-69%, and every text colour measures higher on
     them than it did before. */
  --ground:     #1b2614;
  --sunken:     #2b1408;
  --raised:     #452110;   /* the reading surface — cream on this is 10.31:1 */
  --raised-2:   #33180a;
  --edge:       #9e5c33;   /* brown outline, the game's most recognisable move */
  --edge-soft:  #5c4a36;

  --ink:        #ffe5c1;   /* the game's --white: warm cream, never #fff */
  --ink-2:      #eecc9b;
  --ink-3:      #ddac53;

  --accent:     #a8c636;
  --accent-rgb: 168 198 54;   /* --green1, the Play-button green */
  --accent-ink: #23221f;
  --accent-dim: #4c5c18;

  /* Confidence stays four distinguishable states — the site's whole promise.
     Re-hued into the game ramp, each measured on --raised, none below 5.9:1.
     --observed is lightened from the game's #7869d1 (1.18:1 here, unreadable)
     to keep the purple identity at a contrast that actually works. */
  --verified:      #c8e653;
  --probable:      #dec035;
  --observed:      #cba3e0;
  --unknown:       #ddac53;
  --unknown-text:  #eecc9b;

  --up:       #c8e653;
  --down:     #ff8f7a;
  /* THE ONE COLOUR HERE THAT IS NOT THE GAME'S. --neutral marks a fix or an
     informational patch, and it has to stay tellable from --probable, which
     it sits next to in change lists. The game's palette had nothing left:
     lime, yellow, purple, beige, salmon and gold are all spoken for by other
     states, and every remaining warm slot measured under 20 degrees of hue
     from one of them — close enough to misread a fix as a confidence level,
     which on this site is a real error rather than a cosmetic one. A muted
     teal is the smallest addition that stays 90 degrees clear of everything
     and still sits with the browns; the sky blue it replaces was the colour
     that made the page look borrowed. */
  --neutral:  #8ac9bb;
  --link:     #e8c98a;

  /* Rowdies is the game's face. It goes on chrome ONLY — headings, nav,
     buttons, tags. Body copy, tables and code keep --sans: this site is read,
     not played, and a heavy rounded display face at 0.9375rem in a 13,000-row
     table is illegible. */
  --display: 'Rowdies', var(--sans);

  /* Chunkier than the default site, matching the game's 4–8px borders and
     10–13px radii, but not so heavy that a dense table drowns in outlines. */
  --r-sm: 6px;
  --r:    10px;
  --r-lg: 13px;
  --tio-border: 3px;
}

/* Light: the game has no dark/light split, so this is derived rather than
   quoted — cream grounds from its own #f0e4d0 / #ffe6c2, with the browns
   darkened until every value clears AA on them. Same structure as the dark
   skin: --raised is the surface that holds text, brown is the frame. */
/* The media query above matches when no explicit theme is chosen, so an
   explicit dark choice has to win it back. Mirrors the dark block verbatim —
   same duplication the file already lives with for data-theme, for the same
   reason: custom properties cannot be shared across a bare selector and a
   media-gated one. */
:root[data-skin="tio"][data-theme="dark"] {
  /* The content surfaces are deliberately brown, not the neutral near-blacks
     the game uses for its nested boxes. Its panel is #9e5c33 at 41%
     lightness, so a 15%-saturation dark box next to it still reads as dark.
     Our frame has to be much darker to carry body text, which leaves a small
     lightness step — and across a small step it is the NEUTRALITY that shows,
     not the darkness. Measured: the old surfaces were 6-18% saturation inside
     a 61% frame. These are 62-69%, and every text colour measures higher on
     them than it did before. */
  --ground:     #1b2614;
  --sunken:     #2b1408;
  --raised:     #452110;
  --raised-2:   #33180a;
  --edge:       #9e5c33;
  --edge-soft:  #5c4a36;

  --ink:        #ffe5c1;
  --ink-2:      #eecc9b;
  --ink-3:      #ddac53;

  --accent:     #a8c636;
  --accent-rgb: 168 198 54;
  --accent-ink: #23221f;
  --accent-dim: #4c5c18;

  --verified:      #c8e653;
  --probable:      #dec035;
  --observed:      #cba3e0;
  --unknown:       #ddac53;
  --unknown-text:  #eecc9b;

  --up:       #c8e653;
  --down:     #ff8f7a;
  /* THE ONE COLOUR HERE THAT IS NOT THE GAME'S. --neutral marks a fix or an
     informational patch, and it has to stay tellable from --probable, which
     it sits next to in change lists. The game's palette had nothing left:
     lime, yellow, purple, beige, salmon and gold are all spoken for by other
     states, and every remaining warm slot measured under 20 degrees of hue
     from one of them — close enough to misread a fix as a confidence level,
     which on this site is a real error rather than a cosmetic one. A muted
     teal is the smallest addition that stays 90 degrees clear of everything
     and still sits with the browns; the sky blue it replaces was the colour
     that made the page look borrowed. */
  --neutral:  #8ac9bb;
  --link:     #e8c98a;
}

@font-face {
  font-family: 'Rowdies';
  font-style: normal;
  font-weight: 300;
  font-display: swap;
  src: url('/fonts/rowdies-300.woff2') format('woff2');
}
@font-face {
  font-family: 'Rowdies';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/fonts/rowdies-400.woff2') format('woff2');
}
@font-face {
  font-family: 'Rowdies';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('/fonts/rowdies-700.woff2') format('woff2');
}

/* ── Chrome: the parts that carry the game's identity ─────────────────── */

[data-skin="tio"] h1,
[data-skin="tio"] h2,
[data-skin="tio"] h3,
[data-skin="tio"] .brand,
[data-skin="tio"] nav a,
[data-skin="tio"] .btn,
[data-skin="tio"] button,
[data-skin="tio"] .tag {
  font-family: var(--display);
  font-weight: 700;
  letter-spacing: 0.01em;
}

/* Rowdies has tall, tight-set caps; at heading sizes the default line-height
   crowds descenders into the line below. */
[data-skin="tio"] h1 { line-height: 1.15; }
[data-skin="tio"] h2,
[data-skin="tio"] h3 { line-height: 1.25; }

/* Panels get the brown outline. 3px rather than the game's 6 — at 6px a page
   of stacked cards is mostly border, and the site shows a lot more of them at
   once than a game modal ever does. */
/* Panels sit INSIDE the brown frame now, not on the green, which reverses the
   border decision made when they floated on the page: brown-on-brown has
   nothing to separate with, and near-black does — which is what the game uses
   for every nested box in its panels. */
[data-skin="tio"] .panel,
[data-skin="tio"] .card,
[data-skin="tio"] .empty {
  border: var(--tio-border) solid var(--tio-outline);
  border-radius: var(--r-lg);
}

/* Buttons: the game's bevel. A darker inset band along the bottom edge reads
   as thickness, and the press states move it — 100ms, exactly as the game
   times it. Scale is dropped to 1.02/0.98 from the game's 1.1/0.9, which is
   right for a 60px icon button and far too much for a text button in a form
   row. */
[data-skin="tio"] .btn,
[data-skin="tio"] button {
  border: var(--tio-border) solid var(--edge);
  border-radius: var(--r);
  box-shadow: inset 0 -4px 0 rgb(0 0 0 / 0.28);
  transition: transform 100ms, filter 100ms;
}
[data-skin="tio"] .btn:hover,
[data-skin="tio"] button:hover {
  filter: brightness(112%);
  transform: scale(1.02);
}
[data-skin="tio"] .btn:active,
[data-skin="tio"] button:active {
  transform: scale(0.98);
  box-shadow: inset 0 -2px 0 rgb(0 0 0 / 0.28);
}
/* The bevel is decoration; it must never eat the focus ring. */
[data-skin="tio"] .btn:focus-visible,
[data-skin="tio"] button:focus-visible {
  transform: none;
  filter: none;
}

/* Tags read as the game's little pill labels. */
[data-skin="tio"] .tag {
  border: 2px solid var(--edge);
  border-radius: 999px;
}


/* ══ taming.io skin — components ══════════════════════════════════════════
   Phase 1 set the palette and the two primitives (panel, button). This is
   everything else the site is actually made of: the header, tables, inputs,
   meters, notices, scrollbars, code.

   Same rule throughout, and it is the one that keeps this usable: the game's
   treatment goes on CHROME — things you look at and click — and stops at the
   edge of anything you have to READ. A table gets a game-styled header band
   and keeps 1px separators between its rows; an input gets the game's
   recessed well and keeps its normal text size.

   Where a value is quoted from the game it says so. Where it is adapted, it
   says why, because "the game uses 6px" is not on its own a reason to put a
   6px border on something the game never had. */

/* ── Header ──────────────────────────────────────────────────────────────
   The game floats dark rounded chrome over the world rather than running a
   full-width bar. Reproduced here as a dark band with a brown underline: the
   site's header holds a nav that has to work at 56 links, so it stays a bar,
   but it stops being the same colour as the page. */
[data-skin="tio"] .topbar {
  background: var(--raised);
  border-bottom: var(--tio-border) solid var(--edge);
}
[data-skin="tio"] .brand { letter-spacing: 0.005em; }
[data-skin="tio"] .brand-dot { color: var(--accent); }

/* Nav items read as the game's tab strip: the current one is a raised chip
   rather than merely a brighter word. */
[data-skin="tio"] nav a {
  border-radius: var(--r-sm);
  padding: 4px 9px;
  font-weight: 400;
}
[data-skin="tio"] nav a:hover { background: rgb(var(--accent-rgb) / .10); }
[data-skin="tio"] nav a.on {
  background: var(--raised-2);
  box-shadow: inset 0 -3px 0 var(--accent);
  color: var(--ink);
}

/* ── Tables ──────────────────────────────────────────────────────────────
   The header band gets the game's dark recess and a solid accent underline.
   Rows deliberately keep hairline separators: this is the one place where
   copying the game literally would be worst, since a 13,000-row table with
   3px borders is a wall. */
[data-skin="tio"] th {
  background: var(--raised-2);
  color: var(--ink-2);
  border-bottom: 2px solid var(--accent-dim);
}
[data-skin="tio"] tbody tr:hover td { background: rgb(var(--accent-rgb) / .07); }
/* overflow-Y only. The `overflow: hidden` shorthand this used to carry expands
   to overflow-x, at (0,2,0) — which outranks `.table-scroll { overflow-x: auto }`
   and `.tablewrap { overflow-x: auto }` no matter which comes first in the file,
   so every wide table on the site CLIPPED instead of scrolling and no scrollbar
   hinted that anything was missing. Measured with the skin on: /entities?view=table
   at 700px hid 179px of stat columns and 415px at 380px; /admin hid 498px on a
   phone. The wrappers carry tabindex="0" role="group", so a keyboard user got a
   focus ring on a region that would not move.

   This is the SAME bug the file repairs at .panel.tight.table-scroll below, one
   specificity step higher — and that three-class repair could not cover the
   seventeen bare `<div class="table-scroll">` wrappers or `.tablewrap` at all.
   Same resolution as there: the corner clipping the rule exists for is vertical,
   so keep that and leave the horizontal axis to the base rules. */
[data-skin="tio"] .tablewrap,
[data-skin="tio"] .table-scroll {
  border-radius: var(--r-lg);
  overflow-y: hidden;
}

/* ── Inputs ──────────────────────────────────────────────────────────────
   Quoted from the game's `.input`, which is the exact inverse of its button:
   a button bevels light-to-dark downward to look raised, a field carries
   `inset 0 8px 0` along its TOP edge to look pressed in. Reproduced at 4px
   rather than 8, which is proportionate to a 38px form row instead of a 47px
   game field. */
[data-skin="tio"] input[type=text],
[data-skin="tio"] input[type=password],
[data-skin="tio"] input:not([type]),
[data-skin="tio"] input[type=search],
[data-skin="tio"] input[type=email],
[data-skin="tio"] input[type=number],
[data-skin="tio"] textarea,
[data-skin="tio"] select {
  border: 2px solid var(--edge);
  border-radius: var(--r);
  background: var(--sunken);
  box-shadow: inset 0 4px 0 rgb(0 0 0 / 0.18);
}
[data-skin="tio"] input:focus,
[data-skin="tio"] textarea:focus,
[data-skin="tio"] select:focus {
  border-color: var(--accent);
  box-shadow: inset 0 4px 0 rgb(0 0 0 / 0.18), 0 0 0 3px rgb(var(--accent-rgb) / .30);
}

/* ── Buttons ─────────────────────────────────────────────────────────────
   Phase 1 gave every button the bevel. The variants need their own faces, or
   a primary action and a quiet one are the same object with different text. */
[data-skin="tio"] .btn {
  background: var(--accent);
  color: var(--accent-ink);
  font-weight: 700;
}
[data-skin="tio"] .btn.ghost,
[data-skin="tio"] .btn-quiet {
  background: var(--raised-2);
  color: var(--ink);
}
[data-skin="tio"] .btn.small { box-shadow: inset 0 -3px 0 rgb(0 0 0 / 0.25); }
[data-skin="tio"] .btn.small:active { box-shadow: inset 0 -1px 0 rgb(0 0 0 / 0.25); }

/* ── Tags, chips and badges ──────────────────────────────────────────────
   The game labels everything with small outlined pills. These already exist
   as .tag and .chip; the skin only thickens the outline and sets the type. */
[data-skin="tio"] .chip {
  border: 2px solid var(--edge);
  border-radius: 999px;
  font-family: var(--display);
}
[data-skin="tio"] .tag-num,
[data-skin="tio"] .badge-count {
  font-family: var(--display);
  font-variant-numeric: tabular-nums;
}

/* ── Notices ─────────────────────────────────────────────────────────────
   A 3px rule becomes the game's chunkier band, and the whole notice sits on
   a recessed surface so it separates from the page rather than only from the
   text above it. */
[data-skin="tio"] .notice {
  border-left-width: 5px;
  border-radius: 0 var(--r) var(--r) 0;
  background: var(--raised-2);
}

/* ── Meters and bars ─────────────────────────────────────────────────────
   The game's XP bar: a dark well with a bright fill and a hard edge, not a
   soft 6px hairline. */
[data-skin="tio"] .meter,
[data-skin="tio"] .bar-track {
  height: 10px;
  border: 2px solid var(--edge);
  border-radius: 999px;
  background: var(--sunken);
}
[data-skin="tio"] .meter .bar,
[data-skin="tio"] .bar {
  border-radius: 999px;
  background: var(--accent);
}

/* ── Scrollbars ──────────────────────────────────────────────────────────
   Quoted: the game paints its scrollable lists with a brown track and a dark
   thumb at 10px radius. Firefox gets the two-colour form of the same thing.
   Applied to scroll CONTAINERS rather than the document, so the page's own
   scrollbar stays the one the reader's OS drew. */
[data-skin="tio"] .table-scroll::-webkit-scrollbar,
[data-skin="tio"] pre::-webkit-scrollbar,
[data-skin="tio"] .tablewrap::-webkit-scrollbar {
  height: 12px;
  width: 12px;
  background: #7b401a;
  border-radius: 10px;
}
[data-skin="tio"] .table-scroll::-webkit-scrollbar-thumb,
[data-skin="tio"] pre::-webkit-scrollbar-thumb,
[data-skin="tio"] .tablewrap::-webkit-scrollbar-thumb {
  background: #4e3221;
  border-radius: 10px;
}
[data-skin="tio"] .table-scroll,
[data-skin="tio"] pre,
[data-skin="tio"] .tablewrap {
  scrollbar-color: #4e3221 #7b401a;
}

/* Originally written for the stacked warning panels at the top of /plugins,
   since removed (search plug-lede) — .plug-warn's only caller now is the
   network-switch alert on /accounts. The reason for the rule still holds: the
   skin gives every panel the same outline, which flattens .plug-warn's
   coloured left rule into it, so it's restored INSIDE the skin's frame rather
   than instead of it, keeping --probable's warn colour on what is still the
   game's box. */
[data-skin="tio"] .plug-warn { border-left: var(--tio-border) solid var(--probable); }

/* Same failure, one element over: .plugin-copy-fallback is a <pre>, so the rule
   above it in this layer wins on specificity and the box arrives looking exactly
   like the source block it is standing in for. Restate the dashed marker. */
[data-skin="tio"] .plugin-copy-fallback {
  border: 2px dashed var(--edge); border-radius: var(--r);
}

/* ── Code ────────────────────────────────────────────────────────────────
   Code is read character by character, so it gets the recess and the border
   and nothing else — no display font, no letter-spacing. */
[data-skin="tio"] pre,
[data-skin="tio"] .plugin-src {
  border: 2px solid var(--edge);
  border-radius: var(--r);
  background: var(--sunken);
}

/* ── Cards ───────────────────────────────────────────────────────────────
   The game's shop tiles: a bordered box with a lighter face than the panel
   behind it, so a grid of them reads as objects rather than as a ruled list. */
[data-skin="tio"] .card,
[data-skin="tio"] .atlas-tile,
[data-skin="tio"] .noob-tile {
  background: var(--raised-2);
}
[data-skin="tio"] .card:hover,
[data-skin="tio"] .atlas-tile:hover {
  border-color: var(--accent);
}

/* ── Selection and focus ─────────────────────────────────────────────────
   Selection picks up the accent instead of the browser's default blue, which
   is the last obviously-not-this-site colour left on the page. The focus ring
   is deliberately loud and is never suppressed by any rule above. */
[data-skin="tio"] ::selection {
  background: rgb(var(--accent-rgb) / .35);
  color: var(--ink);
}
[data-skin="tio"] :focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}

/* ── Footer ──────────────────────────────────────────────────────────────*/
[data-skin="tio"] footer {
  border-top: var(--tio-border) solid var(--edge);
  background: var(--raised);
}

/* Rowdies sets numerals wider than the sans does, and a table of figures has
   to stay in columns — so anything numeric keeps tabular figures even where
   the display face is in use. */
[data-skin="tio"] td,
[data-skin="tio"] .mono { font-variant-numeric: tabular-nums; }

/* Reduced motion: the press/hover transforms are decoration, and someone who
   has asked for less movement should not get a page that scales under the
   cursor. Colour and bevel stay; only the movement goes. */
@media (prefers-reduced-motion: reduce) {
  [data-skin="tio"] .btn,
  [data-skin="tio"] button {
    transition: none;
  }
  [data-skin="tio"] .btn:hover,
  [data-skin="tio"] button:hover,
  [data-skin="tio"] .btn:active,
  [data-skin="tio"] button:active {
    transform: none;
  }
}


/* The four change tags shipped as literal hex from the original palette —
   mint, pink, cornflower, amber. They pass contrast under the skin (checked,
   compositing the translucent backgrounds rather than reading them as
   opaque), but mint and cornflower are not colours taming.io contains, so
   they read as imported from somewhere else. Re-pointed at the tokens, which
   the skin has already re-hued into the game's ramp. Still four states, still
   distinguishable, and now the same family as everything around them. */
[data-skin="tio"] .tag-new,
[data-skin="tio"] .tag-buff {
  color: var(--verified);
  border-color: var(--accent-dim);
}
[data-skin="tio"] .tag-nerf {
  color: var(--down);
  border-color: color-mix(in oklab, var(--down) 45%, var(--raised));
  background: color-mix(in oklab, var(--down) 12%, transparent);
}
[data-skin="tio"] .tag-fix {
  color: var(--neutral);
  border-color: color-mix(in oklab, var(--neutral) 45%, var(--raised));
  background: color-mix(in oklab, var(--neutral) 12%, transparent);
}
[data-skin="tio"] .tag-change {
  color: var(--probable);
  border-color: color-mix(in oklab, var(--probable) 45%, var(--raised));
  background: color-mix(in oklab, var(--probable) 12%, transparent);
}


/* .lead-art is pure #000 in the base palette, and the rule says why: --sunken
   sits a few RGB points from --ground there, so a fill could not read as a
   frame and black could. The skin has the same problem for the same reason —
   its browns and its green land at almost identical luminance — but a black
   rectangle on a forest-green page is a hole rather than a frame. So the
   border does the separating instead, which is what the game does everywhere:
   a warm near-black fill inside a brown outline. */
[data-skin="tio"] .lead-art {
  background: var(--sunken);
  border: var(--tio-border) solid var(--edge);
}


/* ── Native controls ─────────────────────────────────────────────────────
   Checkboxes, radios and range sliders are drawn by the OS, not by any rule
   above, so they were still rendering in the system's blue on a page with no
   blue in it. `accent-color` is the one property that reaches them.

   `color-scheme` reaches further: it tells the browser which way round to
   paint everything else it owns — the popup list a <select> opens, the
   caret, the default scrollbar, spinners. Without it the skin's light theme
   opened dark dropdown menus, because the browser was still going by the OS
   preference rather than by the page. It is set per theme rather than once,
   for exactly that reason. */
[data-skin="tio"] {
  accent-color: var(--accent);
  /* Pinned, not defaulted. taming.io has exactly one look — a warm dark world
     — and the light variant here was derived rather than quoted: cream grounds
     invented to fill a slot the game never had. It never looked like the game,
     so the skin no longer offers it, and the Light/Dark control is hidden
     while this skin is on rather than left as a switch that makes the page
     worse.

     A reader who had chosen Light keeps that choice: the cookie is untouched
     and takes effect again the moment they switch back to Plain. */
  color-scheme: dark;
}

[data-skin="tio"][data-theme="dark"]  { color-scheme: dark; }

/* ── Zebra rows ──────────────────────────────────────────────────────────
   The game bands its stat lists — the profile and clan panels alternate a
   darker strip behind every other row — and it is the cheapest scanning aid
   there is on a table this wide. Kept very low contrast so it separates rows
   without turning into stripes competing with the data. */
[data-skin="tio"] tbody tr:nth-child(even) td {
  background: rgb(0 0 0 / 0.13);
}
/* The infobox label column is a th with no fill of its own, so banding only
   the td split each striped row in two. */
[data-skin="tio"] .infobox tbody tr:nth-child(even) > th {
  background: rgb(0 0 0 / 0.13);
}
/* Light cannot take the same alpha. On a cream row 13% black is not a hint,
   it is a shade, and it dragged the dark-green tag text on those rows to
   3.79:1 — the tag paints its own translucent wash on top, so the two
   darkenings compound. 4% measured at 5.84 for the same worst case. */
/* Hover still has to win over the banding, or half the rows stop responding. */
[data-skin="tio"] tbody tr:hover td,
[data-skin="tio"] .infobox tbody tr:hover > th {
  background: rgb(var(--accent-rgb) / .09);
}

/* Rules become the game's solid divider rather than a hairline. */
[data-skin="tio"] hr {
  border: none;
  border-top: 2px solid var(--edge);
  opacity: .5;
}

/* List bullets pick up the accent — small, but they are one of the last
   places the old palette's grey was still showing through in body copy. */
[data-skin="tio"] main li::marker { color: var(--ink-3); }


/* ── Links ───────────────────────────────────────────────────────────────
   The blue had to go: taming.io's palette contains no blue at all, so a
   cold #8fd0ff sat against the browns as the one colour on the page that
   came from somewhere else — and it was the most-used colour on the site.

   Picking the replacement was constrained harder than it looks. The palette
   is almost entirely warm, and the warm slots are already spoken for by
   meaning: lime is verified, yellow is probable, salmon is a drop, purple is
   observed. A plain amber link measured 19 degrees of hue from --probable
   and 21 from --down, close enough to read as a confidence state rather than
   a link, which on this site is a genuine misreading rather than a cosmetic
   one.

   So the colour steps back and the underline does the work. Links are a pale
   gold — clearly not body cream, clearly not any semantic state — with a
   brown rule under them. That is also the honest hierarchy for a site whose
   whole point is that colour carries confidence: spending a hue on "this is
   a link" is spending it in the wrong place.

   Scoped to prose. Buttons, tags and nav items are anchors too and must not
   sprout underlines.

   The exclusions sit inside :where() so they contribute NO specificity. Six
   bare :not() clauses each count as a class, which made this rule outrank the
   table-of-contents exception below it and kept underlining twenty stacked
   jump links. :where() keeps the same matching and lets a plain, readable
   selector override it. */
[data-skin="tio"] a {
  color: var(--link);
}
[data-skin="tio"] main a:where(:not(.btn):not(.tag):not(.chip):not(.card):not([class*="chip"]):not([class*="tile"])),
[data-skin="tio"] .infobox a:where(:not(.btn):not(.tag):not(.chip)) {
  text-decoration: underline;
  text-decoration-color: var(--edge);
  text-decoration-thickness: 2px;
  text-underline-offset: 3px;
}
[data-skin="tio"] main a:hover,
[data-skin="tio"] .infobox a:hover {
  color: var(--ink);
  text-decoration-color: var(--accent);
}


/* ══ taming.io skin — depth ══════════════════════════════════════════════
   Rewritten against the live site's computed styles rather than against a
   screenshot, because reading the pixels got three things backwards. What
   taming.io actually does, measured off its own DOM:

     DAILY NEWS          background rgb(222,192,53)   FLAT
                         border     6px rgb(58,50,43)
                         box-shadow inset 0 -5px 0 rgb(226,130,53)
     Play                background rgb(168,198,54)   FLAT
                         box-shadow inset 0 -6px 0 (a darker lime)
     username field      background rgb(255,230,194)  cream, dark text
                         box-shadow inset 0 8px 0 rgb(224,198,160)

   1. NOTHING IS A GRADIENT. Every fill on that page is one flat colour. The
      gradients I added were the thing making this read as a website wearing
      a game's palette — a gradient is a web idiom, and the game uses none.

   2. THE BEVEL IS A SOLID SIBLING COLOUR, not black at low alpha. The yellow
      button's foot is orange; the lime button's is a deeper lime. Black alpha
      greys a colour out, where a warmer darker sibling deepens it, and that
      difference is most of why mine looked muddy.

   3. THE OUTLINED TEXT IS THE LOOK. Every label on that page carries a
      1081-character stacked text-shadow forming a real outline. I skipped it
      as too expensive and it turns out to be the single most characteristic
      thing about the UI. Done here with -webkit-text-stroke and
      paint-order — one declaration, same result, no shadow stack — and only
      at sizes where an outline does not close up the letterforms. */

:root[data-skin="tio"] {
  /* Their border colour is #3a322b, which works because their panel is
     #9e5c33 at 41% lightness — a big step down. Ours are darker browns at
     17%, so #3a322b is LIGHTER than the thing it would outline and reads as a
     rim. This sits under every surface in the skin instead. */
  --tio-outline: #1c0d04;
  /* The brown container `main` sits in — the game's --brown5, not the
     --brown0 its panels use: at #9e5c33 cream body text measures 4.27:1 and
     fails, and this surface carries paragraphs. Overridden to a tan in the
     light palettes. */
  --tio-frame: #663619;
  /* Their field is cream with dark text and a darker-cream band inset along
     the top — the exact inverse of a button, which is what makes a field read
     as a hole rather than a tile. The game has only one theme, so these are
     taken literally and used in both of ours. */
  --tio-field:     #ffe6c2;
  --tio-field-ink: #3a322b;
  --tio-field-lip: #e0c6a0;
  /* The outline colour for stroked text. An outline only reads when the text
     is lighter than it — in the light palette headings are near-black on tan,
     where a near-black stroke does not outline the letterform, it thickens it
     into a blob. The game never had to solve this: it has one theme. Light
     sets this to transparent, which with paint-order leaves the glyph exactly
     as it would have been. */
  --tio-stroke: var(--tio-outline);
  /* Their border weight is 6px. On a page with one modal that is right; on a
     page with forty controls it is a wall, so chrome takes 4 and small
     objects take 2 — the proportion is kept, the absolute weight is not. */
  --tio-edge-w: 4px;
}

/* ── Buttons ─────────────────────────────────────────────────────────────
   Flat fill, heavy dark border, and a foot in a darker sibling of the fill
   itself. color-mix derives that sibling from whatever colour the button
   already has, so primary, quiet and danger all get a correct foot without
   three hardcoded pairs. */
[data-skin="tio"] .btn,
[data-skin="tio"] button {
  border: var(--tio-edge-w) solid var(--tio-outline);
  border-radius: var(--r);
  background-image: none;
  box-shadow: inset 0 -5px 0 color-mix(in srgb, currentColor 18%, rgb(0 0 0 / 0.34));
  font-weight: 700;
}
[data-skin="tio"] .btn { background-color: var(--accent); color: var(--accent-ink); }
[data-skin="tio"] .btn.ghost,
[data-skin="tio"] .btn-quiet { background-color: var(--raised-2); color: var(--ink); }

/* Their press is a real 1:1 translate — the button moves down by the height
   of its own foot, so the foot disappears under it. */
[data-skin="tio"] .btn:hover,
[data-skin="tio"] button:hover { filter: brightness(108%); }
[data-skin="tio"] .btn:active,
[data-skin="tio"] button:active {
  transform: translateY(3px);
  box-shadow: inset 0 -2px 0 color-mix(in srgb, currentColor 18%, rgb(0 0 0 / 0.34));
}
[data-skin="tio"] .btn:focus-visible,
[data-skin="tio"] button:focus-visible { transform: none; filter: none; }


/* ── Outlined text ───────────────────────────────────────────────────────
   The thing that makes it read as taming.io rather than as brown Bootstrap.
   paint-order puts the stroke behind the fill so the letterform keeps its
   full weight instead of being eaten from both sides.

   Sized down with the text, and deliberately absent below ~15px: at small
   sizes any stroke closes the counters of a, e and o, and this site is read
   far more than the game's UI ever is. */
[data-skin="tio"] h1 {
  -webkit-text-stroke: 4px var(--tio-stroke);
  paint-order: stroke fill;
}
[data-skin="tio"] h2,
[data-skin="tio"] .brand {
  -webkit-text-stroke: 3px var(--tio-stroke);
  paint-order: stroke fill;
}
[data-skin="tio"] h3 {
  -webkit-text-stroke: 2px var(--tio-stroke);
  paint-order: stroke fill;
}

/* BUTTONS ARE DELIBERATELY NOT STROKED, though the game strokes theirs. Its
   button labels are CREAM on a saturated fill, so a dark outline sharpens
   them. Ours are dark-on-lime, because dark-on-lime is what passes contrast
   — and a dark stroke around dark text just thickens it into a blob, which
   is exactly what it did. Matching the game here would mean adopting its
   cream-on-lime label too, whose legibility depends entirely on the outline
   and which no contrast check can verify. The outline stays where it earns
   its place: headings, cream on brown, at sizes that can carry it. */

/* ── Chrome surfaces ─────────────────────────────────────────────────────*/
[data-skin="tio"] .topbar {
  background-image: none;
  border-bottom: var(--tio-edge-w) solid var(--tio-outline);
}
[data-skin="tio"] th {
  background-image: none;
  border-bottom: 3px solid var(--tio-outline);
}

/* ── Panels ──────────────────────────────────────────────────────────────
   Flat, like everything else, with the weight in the border. */
[data-skin="tio"] .panel,
[data-skin="tio"] .card,
[data-skin="tio"] .infobox,
[data-skin="tio"] .empty {
  background-image: none;
  box-shadow: none;
}

/* ── Tags and chips ──────────────────────────────────────────────────────*/
[data-skin="tio"] .tag,
[data-skin="tio"] .chip {
  border: 2px solid var(--tio-outline);
  background-image: none;
  box-shadow: none;
}

/* ── Nav ─────────────────────────────────────────────────────────────────*/
[data-skin="tio"] nav a.on {
  background-image: none;
  box-shadow: inset 0 -3px 0 var(--accent);
}

/* ── Inputs ──────────────────────────────────────────────────────────────
   Theirs are CREAM with dark text and a darker-cream band inset along the
   top — the exact inverse of the button, which is what makes a field read as
   a hole rather than a tile. Reproduced with the skin's own light surface so
   it still works in both themes. */
[data-skin="tio"] input[type=text],
[data-skin="tio"] input[type=password],
[data-skin="tio"] input[type=search],
[data-skin="tio"] input[type=email],
[data-skin="tio"] input[type=number],
[data-skin="tio"] input:not([type]),
[data-skin="tio"] textarea,
[data-skin="tio"] select {
  border: 3px solid var(--tio-outline);
  border-radius: var(--r);
  background-color: var(--tio-field);
  color: var(--tio-field-ink);
  box-shadow: inset 0 6px 0 var(--tio-field-lip);
}
[data-skin="tio"] input::placeholder,
[data-skin="tio"] textarea::placeholder { color: #7d5a3a; }
[data-skin="tio"] input:focus,
[data-skin="tio"] textarea:focus,
[data-skin="tio"] select:focus {
  border-color: var(--accent);
  box-shadow: inset 0 6px 0 var(--tio-field-lip), 0 0 0 3px rgb(var(--accent-rgb) / .35);
}

/* ── Meters ──────────────────────────────────────────────────────────────*/
[data-skin="tio"] .meter,
[data-skin="tio"] .bar-track {
  border: 2px solid var(--tio-outline);
  box-shadow: inset 0 3px 0 rgb(0 0 0 / 0.3);
}
[data-skin="tio"] .meter .bar,
[data-skin="tio"] .bar { background-image: none; }

@media (prefers-reduced-motion: reduce) {
  [data-skin="tio"] .btn,
  [data-skin="tio"] button { transition: none; }
  [data-skin="tio"] .btn:hover,
  [data-skin="tio"] button:hover,
  [data-skin="tio"] .btn:active,
  [data-skin="tio"] button:active { transform: none; }
}

/* ── The frame ───────────────────────────────────────────────────────────
   The structural thing the skin was missing. taming.io nests three deep:
   the green world, a brown container floating on it, and dark or tan boxes
   inside that container. Everything here sat at layer one — panels floating
   straight on the green — which is why it read as a dashboard that had been
   recoloured rather than as the game's UI.

   `main` becomes the brown container. The colour is the game's own --brown5
   rather than its --brown0: at #9e5c33 cream body text measures 4.27:1 and
   fails, and this surface has to carry paragraphs, not just labels. At
   #663619 cream is 8.18, secondary text 6.52 and links 6.24 — all clear.

   The near-black outline is doing real work, not decoration: brown against
   deep forest green is only 1.58:1 apart in luminance, so without an edge the
   container would bleed into the page. This is exactly why the game outlines
   every one of its surfaces. */
[data-skin="tio"] main {
  background-color: var(--tio-frame);
  background-image: linear-gradient(rgb(255 255 255 / 0.05), rgb(0 0 0 / 0.06));
  border: 3px solid var(--tio-outline);
  border-radius: var(--r-lg);
  box-shadow: inset 0 3px 0 rgb(255 255 255 / 0.06), 0 4px 0 rgb(0 0 0 / 0.25);
  margin: var(--s5) auto var(--s8);
}
/* Full-bleed pages set their own width; keep the frame off them rather than
   boxing a layout that was designed to run edge to edge. */
[data-skin="tio"] body.wide main {
  max-width: 1340px;
}
@media (max-width: 720px) {
  /* On a phone the frame is all margin and no benefit — the viewport IS the
     container. Keep the fill, drop the inset. */
  [data-skin="tio"] main {
    border-left: none; border-right: none; border-radius: 0;
    margin-left: 0; margin-right: 0;
    /* and no top margin either: the strip of green between the header and the
       frame reads as a gap in the page rather than as a border round it, once
       the frame is running edge to edge anyway. */
    margin-top: 0;
  }
}


/* Code blocks sit on --sunken, which the skin makes a real tan in the light
   palette — dark enough that the syntax colours drop under AA (the string
   token measured 4.03). They get the lighter surface in light mode instead,
   where every token clears 4.5, and keep the recessed feel from the border
   and the inset rather than from the fill being darker. The dark palette is
   unaffected: --sunken there is near-black and the same tokens are fine. */
/* ══ taming.io skin — sprites, section labels, ground ═════════════════════
   Three more things read off the game rather than invented.

   1. HARD SPRITE SHADOWS. Its selected pet carries
      `filter: drop-shadow(4px 6px 0 #3A322C)` — offset, solid, NO blur. That
      is what makes flat game art sit on a surface instead of floating over
      it, and it is the opposite of the soft 18px blurs this site used. Scaled
      to the size of each sprite: a 44px card icon cannot carry a 6px offset.

   2. SECTION LABELS AS DARK PILLS. Its panels label their sections with a
      dark chip -- its own --black at 7px radius and 5px/18px padding --
      rather than bare small-caps text (.info-inventory-header). We already
      have exactly that element — .eyebrow — set as bare small-caps.

   3. A GROUND THAT IS NOT A FLAT FILL. The game's world is painted foliage,
      which we cannot and should not copy. What we can do is stop the green
      being one dead colour: a very faint leaf motif, drawn here rather than
      taken, at an opacity where it registers as texture and never as pattern. */

/* ── Sprites ─────────────────────────────────────────────────────────────*/
[data-skin="tio"] .lead-art img {
  filter: drop-shadow(4px 6px 0 var(--tio-outline));
}
[data-skin="tio"] .story-art img,
[data-skin="tio"] .infobox-art img {
  filter: drop-shadow(3px 4px 0 var(--tio-outline));
}
[data-skin="tio"] .atlas-shot img,
[data-skin="tio"] .card-art img,
[data-skin="tio"] .sigil img,
[data-skin="tio"] .row-art img {
  filter: drop-shadow(2px 2px 0 var(--tio-outline));
}

/* ── Section labels ──────────────────────────────────────────────────────*/
[data-skin="tio"] .eyebrow {
  display: inline-block;
  background: var(--sunken);
  border: 2px solid var(--tio-outline);
  border-radius: 7px;
  padding: 3px 12px;
  color: var(--ink-3);
}

/* ── Ground texture ──────────────────────────────────────────────────────
   Two leaf silhouettes on a 120px tile, at 3.5% — enough that the green
   moves under the eye, far too faint to read as wallpaper. Drawn as a data
   URI so it costs no request and nothing external is fetched, which the CSP
   would refuse anyway. Only on the dark skin: the light one is a warm cream
   page where the same motif reads as dirt rather than foliage. */
[data-skin="tio"][data-theme="dark"] body,
[data-skin="tio"]:not([data-theme="light"]) body {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cg fill='%23c8e653' fill-opacity='0.035'%3E%3Cpath d='M18 34c10-14 26-16 34-8-8 14-24 18-34 8z'/%3E%3Cpath d='M86 92c-10-14-6-30 4-34 8 14 6 30-4 34z'/%3E%3Cpath d='M96 26c-12 2-19 13-16 21 12-2 19-13 16-21z'/%3E%3Cpath d='M30 96c8-8 20-8 25-1-8 8-20 9-25 1z'/%3E%3C/g%3E%3C/svg%3E");
  background-repeat: repeat;
}
/* ══ taming.io skin — tab strip ═══════════════════════════════════════════
   The game's section tabs, from .section-button / .section-button-selected:

     unselected   background: their --brown3   border-radius 10px
                  margin 8px 11px -9px 0    transform scale(0.8)
     selected     border-radius 13px 13px 0 0
                  margin 12px 11px -2px 0   padding-bottom 8px

   The whole trick is in the radii and the negative bottom margins: an
   unselected tab is fully rounded and sits low, a selected one squares off its
   bottom corners and grows down THROUGH the edge of the panel, so the line
   that separates strip from content is interrupted exactly where the tab is.
   That interruption is what makes it read as one continuous surface rather
   than as a button that happens to be highlighted.

   APPLIED ONLY WHERE THE MARKUP IS ACTUALLY TABS. `:has(a.on)` is the test:
   a chip group with exactly one selected item is a mutually-exclusive filter
   that swaps the content below — genuinely tabs. A chip group with nothing
   selected is a tag list, and giving that a tab strip would be dressing up
   something that does not behave like one. `.desks` was a candidate and is
   not one: it is a grid of multi-line cards, not a strip. */

[data-skin="tio"] .chips:has(a.on) {
  display: flex;
  align-items: flex-end;
  gap: 3px;
  flex-wrap: wrap;
  margin-bottom: var(--s5);
  padding-bottom: 0;
  border-bottom: 3px solid var(--tio-outline);
}

[data-skin="tio"] .chips:has(a.on) a {
  border-radius: 10px 10px 0 0;
  border-bottom: none;
  background: var(--raised-2);
  color: var(--ink-2);
  padding: 5px var(--s4) 6px;
  top: 0;
  transform: none;
}
[data-skin="tio"] .chips:has(a.on) a:hover {
  background: var(--raised);
  transform: none;
}

/* The selected tab. It takes the colour of the surface BELOW the strip — the
   brown frame — and grows 3px down over the strip's own border, so the border
   stops where the tab is. Its bottom border repaints that line in the frame
   colour rather than trying to sit above it, which is the only way this works
   when the tab and the surface are the same colour. */
[data-skin="tio"] .chips:has(a.on) a.on {
  background: var(--tio-frame);
  color: var(--ink);
  border-color: var(--tio-outline);
  border-bottom: 3px solid var(--tio-frame);
  border-radius: 13px 13px 0 0;
  margin-bottom: -3px;
  padding-bottom: 9px;
  font-weight: 700;
}
[data-skin="tio"] .chips:has(a.on) a.on:hover {
  background: var(--tio-frame);
}

/* The strip's own sweep animation runs off ::after and looked wrong once the
   tabs stopped being pills — it swept across the square bottom corners. */
[data-skin="tio"] .chips:has(a.on) a::after { display: none; }

/* Discord's filter row is the same thing built from buttons rather than
   anchors, and sits directly on top of the message log. It keeps its button
   shapes — restyling those would fight the .btn rules for no gain — and just
   gains the edge the tabs sit on. */
[data-skin="tio"] .dfilters {
  border-bottom: 3px solid var(--tio-outline);
  padding-bottom: var(--s3);
  margin-bottom: 0;
}

* { box-sizing: border-box; }

/* Jump lists and other navigation that happens to live inside <main> are not
   prose: underlining twenty adjacent anchors turns a table of contents into a
   block of rules. They keep the colour and lose the underline — the colour is
   enough where the links are already stacked in an obvious list. */
[data-skin="tio"] nav.toc a,
[data-skin="tio"] .toc a,
[data-skin="tio"] .chips a,
[data-skin="tio"] nav.desks a {
  text-decoration: none;
}

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

body {
  margin: 0;
  background: var(--ground);
  color: var(--ink);
  font: var(--t-body)/1.6 var(--sans);
  -webkit-font-smoothing: antialiased;
  /* Hold the footer at the bottom on a short page. Without this /compare, and
     any other page with little content, ended 365px above the fold on a tall
     window and left a band of bare page background under the footer — which
     read as the layout having given up. authLayout() already did exactly this
     for the signed-out pages; the main shell never did.
     dvh, with vh behind it, so a mobile browser's collapsing toolbar does not
     leave the same gap it was meant to remove. */
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}
/* The slack goes ABOVE the footer, not into the page frame. Growing `main`
   also works and was the first thing I tried, but `main` carries the brown
   container, so a short page became a mostly-empty brown panel 800px tall.
   The game sizes its panels to their contents and lets the world show around
   them; an auto top margin on the footer does the same thing here — the frame
   stays the size of what is in it, and the ground fills the rest. */
body > footer { margin-top: auto; }

/* Numbers always align and always read as data */
.num, .mono, td.num, .date {
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1;
}
/* An ISO date is one token. Letting it wrap turns a tidy column into two
   ragged lines the moment another column is added beside it. */
.date { white-space: nowrap; }

a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 2px; }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}

main { max-width: 1040px; margin: 0 auto; padding: var(--s6) var(--s5) var(--s8); }
body.wide main { max-width: 1340px; }

/* ── Type scale ──────────────────────────────────────────────────────── */
h1 {
  font-size: var(--t-display); line-height: 1.12; margin: 0 0 var(--s2);
  font-weight: 700; letter-spacing: -0.025em; text-wrap: balance;
}
h2 {
  font-size: var(--t-title); line-height: 1.25; margin: 0;
  font-weight: 650; letter-spacing: -0.015em;
}
h3 { font-size: var(--t-lead); margin: var(--s5) 0 var(--s2); font-weight: 650; }

.eyebrow {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: 0.09em;
  color: var(--ink-3); font-weight: 650;
}
.lede { color: var(--ink-2); font-size: var(--t-lead); max-width: 62ch; margin: 0 0 var(--s6); }
.sub  { color: var(--ink-2); max-width: 68ch; margin: 0 0 var(--s5); }
/* A JS selector hook (refresh-progress.js finds its target link this way) --
   always paired with .sub, which does the actual styling; display:block is
   already this <p>'s default and is here only so the class resolves to a
   real declaration rather than an empty rule. */
.interstitial-continue { display: block; }
.muted { color: var(--ink-2); }
.faint { color: var(--ink-3); }

/* Skip-to-content: the same off-screen trick as .sr-only below, but a skip
   link has to reappear the moment it's focused rather than staying hidden
   forever — a keyboard user shouldn't have to tab through five nav links,
   two dropdowns, Support and the user menu just to reach <main> on every
   single page. */
.skip-link {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
  opacity: 0; transform: translateY(-6px);
  transition: opacity .16s ease, transform .16s ease;
}
.skip-link:focus {
  position: fixed; top: var(--s2); left: var(--s3); z-index: 100;
  width: auto; height: auto; padding: 10px var(--s4); margin: 0;
  overflow: visible; clip: auto; white-space: normal;
  background: var(--accent); color: var(--accent-ink); font-weight: 650;
  border-radius: var(--r-sm);
  opacity: 1; transform: translateY(0);
}

/* ── Top bar ─────────────────────────────────────────────────────────── */
.topbar {
  display: flex; align-items: center; gap: var(--s5);
  padding: var(--s2) var(--s5); min-height: 56px;
  background: color-mix(in srgb, var(--raised) 92%, transparent);
  border-bottom: 1px solid var(--edge);
  position: sticky; top: 0; z-index: 40;
  backdrop-filter: blur(10px);
}
.brand { font-weight: 700; font-size: var(--t-lead); letter-spacing: -0.03em; color: var(--ink); flex: none; }
.brand:hover { text-decoration: none; }
.brand-dot { color: var(--accent); display: inline-block; }
.brand-tld { color: var(--ink-3); }
/* A wink, not a logo animation — the dot hops like it's the one part of the
   wordmark that's alive, everything else on the brand stays put. */
@keyframes tt-dot-hop {
  0%, 100% { transform: translateY(0); }
  35% { transform: translateY(-3px); }
  55% { transform: translateY(0); }
  70% { transform: translateY(-1px); }
}
.brand:hover .brand-dot { animation: tt-dot-hop .5s ease; }
/* Poke the dot five times — public/assets/delight.js waves the whole wordmark
   and briefly sets this. A tint and nothing structural, so a page mid-wave is
   still exactly the page it was. */
.tt-konami .brand { color: var(--accent); transition: color .3s ease; }
.tt-konami .stat .n { color: var(--accent); transition: color .3s ease; }

.brand.big { font-size: 1.875rem; display: block; text-align: center; }

/* Mobile menu toggle — a <details> with nothing inside it but a summary,
   sitting next to .topbar nav rather than wrapping it. It exists purely for
   its [open] attribute, which the mobile media query below reads with a
   sibling selector to show or hide the real nav.

   nav is NOT nested inside the <details> on purpose: a CLOSED <details>
   collapses its content to zero size in every current browser regardless of
   any `display` override placed on that content — overriding `display` on a
   child of a closed <details> looks like it should win by normal cascade
   rules and does not, because the suppression is not implemented as an
   overridable `display: none`. Reached that the hard way — every attempt to
   force the nested nav visible on desktop with CSS alone measured 0 width
   even though its own computed `display` correctly read `flex`. Keeping nav
   a sibling sidesteps the whole mechanism: it is never inside a closed
   anything, so ordinary `display` rules apply to it exactly as they would
   anywhere else on this site. On a wide screen the toggle is hidden and nav
   sits in its normal unconditional flow, unchanged from before this
   existed. */
.nav-toggle-btn { display: none; }

/* SCROLLS when it runs out of room; it must never wrap.
   .topbar is `position: sticky; top: 0`, and .rail / .infobox stick beneath it
   at a hard-coded `top: 76px` — a number that assumes this bar is one 56px row.
   With `flex-wrap: wrap` the nine nav links wrapped to a second row at 1200px
   and a third by 760px, taking the bar to 90px and then 126px while the rails
   went on sticking at 76 — so the rails slid UNDER the bar and the chat rail's
   search box came out on top of the account chips. Reported from a 1330px
   window on /chat, which is an ordinary laptop size, not an edge case.
   The wrap only ever made sense below 640px, where the .nav-toggle menu takes
   over anyway (see the media query) and this bar is not the thing pushing the
   page down. Keeping one row keeps `top: 76px` true.

   NOT `overflow-x: auto`, which is how this was first written and which broke
   the Explore and Staff menus within the hour. `.navdrop-menu` is
   `position: absolute` inside this element, and a scroll container clips its
   absolutely positioned children — setting overflow-x to auto also forces
   overflow-y from visible to auto, so the menu rendered 333px tall inside a
   33px box and was invisible. There is no min-width: 0 here either: this
   element keeps its min-content width and `.me` absorbs the squeeze instead,
   which it can do harmlessly because it holds inline chips and no popover. */
.topbar nav {
  display: flex; align-items: center; flex-wrap: nowrap; gap: 2px;
  margin-right: auto;
}

.topbar nav a {
  position: relative; overflow: hidden;
  color: var(--ink-2); padding: 6px var(--s3); border-radius: var(--r-sm);
  font-size: var(--t-small); white-space: nowrap;
  transition: background .12s ease, color .12s ease;
}
.topbar nav a::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,.25) 50%, transparent 65%);
  transform: translateX(-160%) skewX(-12deg);
}
.topbar nav a:hover::after { animation: tt-sweep .5s ease; }
.topbar nav a:hover { background: var(--raised-2); color: var(--ink); text-decoration: none; }
.topbar nav a.on { color: var(--ink); background: var(--raised-2); font-weight: 600; }

/* Explore / Staff — a <details> dropdown shows and hides its menu with no
   script involved, the same trick the compare tray uses via :has(). Kept to
   two of these so the bar reads as a handful of destinations, not a wall
   of links; see layout.ts for what's grouped where and why. */
.topbar nav details.navdrop { position: relative; flex: none; }
.topbar nav details.navdrop > summary {
  list-style: none; cursor: pointer;
  display: flex; align-items: center; gap: 4px;
  color: var(--ink-2); padding: 6px var(--s3); border-radius: var(--r-sm);
  font-size: var(--t-small); white-space: nowrap;
  transition: background .12s ease, color .12s ease;
}
.topbar nav details.navdrop > summary::-webkit-details-marker { display: none; }
.topbar nav details.navdrop > summary::after {
  content: ''; width: 0; height: 0; margin-top: 1px;
  border: 4px solid transparent; border-top-color: currentColor; border-bottom: 0;
  opacity: .65;
}
.topbar nav details.navdrop > summary:hover,
.topbar nav details.navdrop[open] > summary { background: var(--raised-2); color: var(--ink); }
.topbar nav details.navdrop.on > summary { color: var(--ink); font-weight: 600; }

.navdrop-menu {
  position: absolute; top: calc(100% + 6px); left: 0; z-index: 50;
  min-width: 172px; padding: var(--s1);
  /* The Staff menu is 22 links in six groups and ran off the bottom of a
     600px-tall window, so the last group was unreachable without resizing.
     Capped to the space below the bar and allowed to scroll inside itself. */
  max-height: calc(100vh - 64px); overflow-y: auto; overscroll-behavior: contain;
  background: var(--raised-2); border: 1px solid var(--edge);
  border-radius: var(--r); box-shadow: 0 12px 32px rgba(0,0,0,.5);
  display: flex; flex-direction: column; gap: 1px;
  transform-origin: top left;
  transition: transform .16s cubic-bezier(.2,.8,.3,1), opacity .12s ease;
}
/* <details> hides its non-summary content natively — no open/closed class
   to hook. @starting-style catches the moment it starts rendering, same
   trick as .bar's fill-in, so the menu drops in instead of just appearing. */
@starting-style {
  .navdrop-menu { transform: translateY(-6px) scale(.96); opacity: 0; }
}
.navdrop-menu a {
  position: relative; overflow: hidden;
  display: block; padding: 7px var(--s3); border-radius: var(--r-sm);
  color: var(--ink-2); font-size: var(--t-small); white-space: nowrap;
  transition: background .12s ease, color .12s ease;
}
.navdrop-menu a::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,.2) 50%, transparent 65%);
  transform: translateX(-160%) skewX(-12deg);
}
.navdrop-menu a:hover::after { animation: tt-sweep .5s ease; }
.navdrop-menu a:hover { background: var(--raised); color: var(--ink); text-decoration: none; }
.navdrop-menu a.on { color: var(--accent); font-weight: 600; }
/* A heading between groups in the Staff menu. Not a link, not clickable —
   its whole job is to let you read four items instead of nineteen. */
.navdrop-head {
  display: block; padding: var(--s2) var(--s3) 2px;
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .08em;
  color: var(--ink-3);
}
.navdrop-head:first-child { padding-top: 2px; }

.navdrop-menu a.admin {
  color: var(--accent); border-top: 1px solid var(--edge); margin-top: 2px;
  border-radius: 0 0 var(--r-sm) var(--r-sm); padding-top: 8px;
}
/* Staff sits near the right edge of the bar — open leftward so the menu
   doesn't run off the viewport. */
.topbar nav details.navdrop.staff .navdrop-menu { left: auto; right: 0; }

/* Shrinkable and wrappable, at every width — not `flex: none`.
   Fixed at max-content, an account carrying several chips at once (credits,
   Saved, Founder, name, ADMIN, Sign out) measured 362px and pushed the page
   into a sideways scroll: 3px over at 375, and 20px over at 641, just above
   the phone breakpoint where a fix confined to that media query would not
   have reached. Shrinking has to be allowed before wrapping can do anything —
   an item that is never squeezed never reaches the point of wrapping. An
   ordinary account still fits on one row and is unaffected. */
.me {
  display: flex; align-items: center; gap: var(--s2); font-size: var(--t-small);
  /* Shrink and scroll — never wrap, and never `flex: none`.
     `flex: none` fixed it at max-content and put the page into a sideways
     scroll (3px over at 375, 20px at 641). Wrapping fixed that but moved the
     cost onto the bar's HEIGHT, which is worse: .rail and .infobox stick at a
     hard-coded `top: 76px` that assumes one 56px row, so a taller bar puts the
     rails underneath it. Shrinking costs neither — the chips stay one row and
     the cluster scrolls if it truly cannot fit. */
  flex: 0 1 auto; min-width: 0;
  flex-wrap: nowrap; justify-content: flex-end;
  overflow-x: auto; scrollbar-width: none;
  /* Squeezing the cluster must not break a label in half: without this
     "Sign out" wrapped onto two lines and took the whole bar from 56px to
     62px, which is the same height creep by another route. */
  white-space: nowrap;
}
.me::-webkit-scrollbar { display: none; }
.who { color: var(--ink-2); }
.role {
  font-size: 10px; text-transform: uppercase; letter-spacing: 0.07em;
  padding: 2px 6px; border-radius: var(--r-sm);
  border: 1px solid var(--edge); color: var(--ink-3);
}
.role-admin  { color: var(--accent);   border-color: var(--accent-dim); }
.role-editor { color: var(--probable); border-color: #5C4A1A; }
/* /sitemap's badge for the one route that answers without an account. Its own
   colour rather than a warning one: the route is reachable signed out but it is
   not open — see the grant routes in src/web/routes/plugins.ts. */
.role-public { color: var(--neutral);  border-color: #23415C; }

/* The Staff dropdown's pending-count badge (staff-badge.ts fills the number
   in server-side; [hidden] is the only state a member or a staff account
   with nothing waiting ever sees). --probable, not --down/--nerf: waiting
   work is routine, not an error. */
.badge-count {
  display: inline-block; min-width: 1.4em; padding: 1px 5px;
  border-radius: 999px; background: var(--probable); color: var(--ground);
  font-size: 10px; font-weight: 700; text-align: center; line-height: 1.4;
  vertical-align: 1px;
}
/* The badge's colour carries "is anything wrong", separately from its number,
   which carries "how much is waiting". Amber (the default above) is the
   waiting state; these two are the fault states. See src/web/staff-badge.ts —
   this is the only channel that reaches a staffer who did not go looking. */
.badge-count.badge-fault { background: var(--down); color: var(--ground); }
.badge-count.badge-degraded { background: var(--probable); color: var(--ground); }

button.link {
  background: none; border: 0; color: var(--link); cursor: pointer;
  font: inherit; font-size: var(--t-small); padding: 0;
}
button.link:hover { text-decoration: underline; }

/* ── Provenance ──────────────────────────────────────────────────────
   The core visual language. A dot + label states where a value came from. */
.prov {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: var(--t-micro); text-transform: uppercase;
  letter-spacing: 0.07em; font-weight: 650; white-space: nowrap;
}
.prov::before {
  content: ''; width: 6px; height: 6px; border-radius: 50%;
  background: currentColor; flex: none;
}
.prov-verified { color: var(--verified); }
.prov-probable { color: var(--probable); }
.prov-observed { color: var(--observed); }
/* --unknown-text, not --unknown: the word "UNKNOWN" next to this dot is
   real content at 11px/650 weight, which needs the 4.5:1 AA minimum for
   text, not the 3:1 minimum a graphic gets — measured 3.39–4.14:1 on
   --unknown against this site's actual surfaces, a real fail. Same colour
   drives the dot too (currentColor, like every other tier here), which
   only makes the dot easier to see, never a problem the other direction. */
.prov-unknown  { color: var(--unknown-text); }

/* Left rule on a stat group, so tier reads before you get to the words.
   Each also sets border-top-color, which only .stat below ever uses (a
   plain .tier paragraph has no border-top-style, so the colour sits inert
   there) — one set of tier colours, reused wherever a rule needs one. */
.tier { border-left: 2px solid var(--edge); padding-left: var(--s4); }
.tier-verified { border-left-color: var(--verified); border-top-color: var(--verified); }
.tier-probable { border-left-color: var(--probable); border-top-color: var(--probable); }
.tier-observed { border-left-color: var(--observed); border-top-color: var(--observed); }
.tier-unknown  { border-left-color: var(--unknown); border-top-color: var(--unknown); }

/* ── Surfaces ────────────────────────────────────────────────────────── */
.panel {
  background: var(--raised); border: 1px solid var(--edge);
  border-radius: var(--r); padding: var(--s4) var(--s5); margin-bottom: var(--s4);
}
.panel.tight { padding: 0; overflow: hidden; }
.panel.flush { margin-bottom: 0; }

.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--s4); }
/* A HUD readout, not a KPI card: no box, just the rule that already carries
   tier meaning everywhere else on the site (see .tier above) moved to the
   top edge, where it reads as this number's own underline rather than a
   box wall. Neutral --edge when a stat isn't (or isn't yet) tiered. */
.stat {
  border-top: 2px solid var(--edge); padding-top: var(--s2);
}
.stat .n {
  font-family: var(--mono); font-variant-numeric: tabular-nums;
  font-size: 1.625rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1;
}
.stat .l { color: var(--ink-3); font-size: var(--t-micro); text-transform: uppercase;
           letter-spacing: 0.08em; margin-top: var(--s1); }

/* ── Entity cards ────────────────────────────────────────────────────── */
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: var(--s3); }

.card {
  position: relative; overflow: hidden;
  background: var(--raised); border: 1px solid var(--edge);
  border-radius: var(--r); padding: var(--s3);
  display: flex; gap: var(--s3); align-items: center;
  transition: border-color .14s ease, background .14s ease, transform .14s ease;
}
.card::after {
  content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 1;
  background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,.12) 50%, transparent 65%);
  transform: translateX(-160%) skewX(-12deg);
}
.card:hover::after { animation: tt-sweep .6s ease; }
.card:hover {
  border-color: color-mix(in srgb, var(--accent) 40%, var(--edge));
  background: var(--raised-2);
  text-decoration: none; transform: translateY(-2px);
}
.card:hover .card-art { animation: tt-art-wiggle .5s ease; }
@keyframes tt-art-wiggle {
  0%   { transform: scale(1) rotate(0deg); }
  30%  { transform: scale(1.08) rotate(-4deg); }
  60%  { transform: scale(1.08) rotate(3deg); }
  100% { transform: scale(1.06) rotate(0deg); }
}
/* The mini stat rules brighten and thicken along with the card they sit
   in, instead of just sitting there while everything around them moves. */
.card:hover .statmini { border-top-width: 3px; }
.card .nm { font-weight: 640; font-size: var(--t-body); color: var(--ink); line-height: 1.25; }
.card .sm { color: var(--ink-3); font-size: var(--t-micro); margin-top: 3px;
            display: flex; align-items: center; gap: 5px; }

/* Sprite sits on a quiet tile so the artwork carries, not the tile */
.card-art, .sigil {
  flex: none; display: grid; place-items: center; overflow: hidden;
  background: var(--sunken); border: 1px solid var(--edge-soft);
  transition: transform .14s ease;
}
.card-art { width: 44px; height: 44px; border-radius: var(--r-sm); }
.sigil    { width: 72px; height: 72px; border-radius: var(--r); font-weight: 700; font-size: 1.5rem; }
.sigil.sm { width: 40px; height: 40px; border-radius: var(--r-sm); font-size: 0.9rem; }
.card-art img, .sigil img {
  width: 84%; height: 84%; object-fit: contain;
  image-rendering: pixelated;
  filter: drop-shadow(0 1px 3px rgba(0,0,0,.6));
}

/* ── Compare tray ────────────────────────────────────────────────────
   No JavaScript exists on this site (CSP is script-src 'none'), so
   selection state lives in checkboxes and the tray reveals itself with
   :has(). Checking a card is a real form control, so keyboard and screen
   readers get it for free — this is better than a scripted tray, not a
   compromise for one. */

.pick {
  position: absolute; top: 6px; right: 6px; width: 18px; height: 18px;
  margin: 0; cursor: pointer; accent-color: var(--accent);
  opacity: 0; transform: scale(.6); transform-origin: center;
  transition: opacity .12s ease, transform .18s cubic-bezier(.34,1.56,.64,1);
}
.card-wrap { position: relative; }
.card-wrap:hover .pick,
.pick:focus-visible,
.pick:checked { opacity: 1; transform: scale(1); }

/* Selected card reads as selected */
.card-wrap:has(.pick:checked) .card {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 7%, var(--raised));
}

.tray {
  position: fixed; left: 50%; transform: translateX(-50%) translateY(120%);
  bottom: var(--s4); z-index: 60;
  display: flex; align-items: center; gap: var(--s3);
  padding: var(--s3) var(--s4);
  background: var(--raised-2); border: 1px solid var(--edge);
  border-radius: 999px; box-shadow: 0 8px 28px rgba(0,0,0,.5);
  transition: transform .18s cubic-bezier(.2,.8,.3,1);
  max-width: calc(100vw - var(--s5) * 2);
}
/* Reveal only when something is picked */
body:has(.pick:checked) .tray { transform: translateX(-50%) translateY(0); }
/* How many, via a CSS counter — a different mechanism from the :has() question
   below, and one that doesn't need nesting: counter-increment walks the DOM in
   document order regardless of how deep a .pick sits (a <table> cell or a
   card), so the total is exact by the time .tray-label::after reads it. A
   stale comment used to say no count was possible here at all — that was true
   only for the *gating* question ("is it >= 2") this file's own next comment
   covers, never for displaying the number itself. */
form:has(.pick) { counter-reset: selected; }
.pick:checked { counter-increment: selected; }
.tray-label { font-size: var(--t-small); color: var(--ink-2); white-space: nowrap; }
.tray-label::after { content: " " counter(selected); }

/* Counting "2 or more checked" in CSS needs nested :has(), which is invalid —
   an earlier attempt silently dropped this whole rule. So the button shows as
   soon as anything is picked and the compare page explains if only one was
   chosen. Fewer moving parts, and the failure is a sentence rather than a
   button that mysteriously does nothing. */
.tray .go { display: inline-block; }

@media (max-width: 640px) {
  .pick { opacity: 1; transform: scale(1); width: 22px; height: 22px; }
  .tray { bottom: var(--s2); padding: var(--s2) var(--s3); }
}

/* ── Tables ──────────────────────────────────────────────────────────── */
table { width: 100%; border-collapse: collapse; font-size: var(--t-body); }
th {
  text-align: left; color: var(--ink-3); font-weight: 650;
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: 0.08em;
  border-bottom: 1px solid var(--edge); padding: var(--s3) var(--s4);
  background: var(--sunken); white-space: nowrap;
}
td { padding: var(--s3) var(--s4); border-bottom: 1px solid var(--edge-soft); vertical-align: top; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: var(--raised-2); }
/* A row arrived at via #fragment (glossary terms, sprite-size rows linked
   from elsewhere) gets a visible landing cue — CSS-only, no JS to scroll-spy
   or toggle a class. Kept general rather than page-specific so any future
   `<tr id="...">` picks it up for free. */
tbody tr:target { background: color-mix(in srgb, var(--accent) 12%, var(--raised)); }
.table-scroll { overflow-x: auto; }
/* `.panel.tight` sets `overflow: hidden` to clip a table to the panel's
   rounded corners, and that shorthand expands to overflow-x too — at
   two-class specificity, which beats the one-class rule above no matter which
   comes first in the file. So every `panel tight table-scroll` on this site
   (twenty of them, across eight route files) was CLIPPING its overflow rather
   than scrolling it: on a narrow screen the right-hand columns were
   unreachable and no scrollbar hinted that anything was missing.

   Restored at matching specificity, next to the rule it repairs rather than
   further down the file, because the whole bug is one selector out-ranking
   another. overflow-y stays hidden, so the corner clipping the shorthand
   exists for still happens. */
.panel.tight.table-scroll { overflow-x: auto; overflow-y: hidden; }

/* A table shaped like "term: definition" (/glossary) or "path / what it is /
   notes" (/sitemap) is prose wearing a table for its row structure, not a
   data grid — so on a phone it should read the way a definition list would,
   not scroll sideways the way .ci-chart does. Those wide
   tables have a fixed-width label column too (`th style="width:200px"`,
   sometimes two of them), which quietly assumes there is at least that much
   viewport left over for the prose beside it. On a phone there usually
   isn't: /glossary's definitions wrapped to two or three words a line, and
   /sitemap's third column — Notes — was pushed off the right edge and
   never shown at all, both from the same cause once the fixed columns alone
   already summed past 375px. Below, .table-stack turns the row into a
   block instead: the mobile media query near the bottom of this file gives
   it its actual stacked layout, same as everywhere else viewport-specific
   behaviour lives in this file. */
.table-stack th, .table-stack td { vertical-align: top; }

/* Available to screen readers, invisible on screen. Data tables get a
   <caption> this way without altering the visual design. */
.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;
}

/* The sign-in and register decoy field (lib/bot-check.ts). Rendered, so a
   script that fills every visible input fills it too, but off-screen and
   aria-hidden, so no person sees, hears or tabs to it. */
.hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }

.stats-table td:first-child { color: var(--ink-2); width: 48%; }
.stats-table td:last-child  { font-family: var(--mono); font-variant-numeric: tabular-nums; font-weight: 650; }

/* Inline magnitude bar — recessive, never competing with the number.
   @starting-style fills it in from nothing on first paint rather than it
   just appearing at width. Every width is set inline per bar server-side
   (the real value), so this only supplies the "before" browsers with
   @starting-style animate from — no script, and browsers without it just
   show the final width immediately, same as always. */
.bar {
  height: 6px; border-radius: 3px; background: var(--accent-dim); min-width: 3px;
  transition: width .6s cubic-bezier(.2,.8,.3,1);
}
@starting-style { .bar { width: 0 !important; } }
.bar-track { background: var(--edge-soft); border-radius: 3px; height: 6px; width: 100%; }
/* Same bar, tinted by provenance. A bar drawn from a modelled or unnamed
   value must not look as solid as one drawn from a client-shipped number. */
.bar.p-probable { background: color-mix(in srgb, var(--probable) 45%, transparent); }
.bar.p-observed { background: color-mix(in srgb, var(--observed) 45%, transparent); }
.bar.p-unknown  { background: color-mix(in srgb, var(--unknown) 55%, transparent); }
/* NOT a provenance tint — a community-sourced number (/leveling) is not on
   the verified/probable/observed/unknown scale at all, so it gets its own
   class rather than borrowing "p-probable" and reading as that tier. Same
   hue as .badge-community, for the same reason: recognisably "not from the
   client" without inventing a fifth tier. */
.bar.b-community { background: color-mix(in srgb, var(--probable) 40%, transparent); }

/* ── Charts ──────────────────────────────────────────────────────────
   Every chart on this site is inline SVG with no script behind it. That is
   not a limitation to work around: a server-rendered chart is the same
   bytes for everyone, prints correctly, and cannot disagree with the table
   beside it, because both are drawn from the same query. */

.chart { display: block; width: 100%; height: auto; overflow: visible; }
.chart .axis   { stroke: var(--edge); stroke-width: 1; }
.chart .gridln { stroke: var(--edge-soft); stroke-width: 1; }
.chart .lbl    { fill: var(--ink-3); font-family: var(--mono); font-size: 9px; }
.chart .lbl-mid { text-anchor: middle; }
.chart .lbl-end { text-anchor: end; }

/* A growth curve: one polyline per stat, dots at the stages the client
   actually ships a value for. Gaps are gaps — never interpolated silently. */
.curve      { fill: none; stroke: var(--verified); stroke-width: 2;
              stroke-linejoin: round; stroke-linecap: round; }
.curve.p-probable { stroke: var(--probable); }
.curve.p-unknown  { stroke: var(--unknown); }
/* An inherited stretch — the client ships no new value, so the stat is
   carried over. Dashed, because nothing was measured here. */
.curve.inherited  { stroke-dasharray: 3 3; opacity: .55; }
.curve-dot  { fill: var(--verified); }
.curve-dot.p-probable { fill: var(--probable); }
.curve-dot.p-unknown  { fill: var(--unknown); }
.curve-area { fill: color-mix(in srgb, var(--verified) 11%, transparent); stroke: none; }

/* Sparkline — a curve small enough to sit inside a table cell. */
.spark { width: 84px; height: 22px; display: block; overflow: visible; }
.spark .curve { stroke-width: 1.5; }

/* Histogram — distribution of one field across every entity. */
.hbar     { fill: var(--accent-dim); }
.hbar.p-unknown { fill: var(--unknown); }
/* Where the entity you came from sits in that distribution. */
.hmark    { stroke: var(--probable); stroke-width: 2; }

/* ── Sprite atlas ────────────────────────────────────────────────────
   Artwork is never copied here. Every tile references the game's own
   origin, which is also why img-src allows taming.io and nothing else. */
.atlas { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: var(--s2); }

/* ── One asset's own page ──────────────────────────────────────────────
   Reached from every tile, and the reason every tile is now a link: the
   right-click panel was the only way to these facts, which put 96% of this
   catalogue out of a keyboard's reach. */
.asset-name { margin-bottom: var(--s1); overflow-wrap: anywhere; }
.asset-path { margin-top: 0; overflow-wrap: anywhere; }
/* The art at a size worth looking at, on a surface that shows transparency
   without fighting it. Contained rather than cropped: a 2581x1549 background
   and a 1x1 placeholder both have to be honest about their shape here. */
.asset-shot {
  display: flex; align-items: center; justify-content: center;
  margin: var(--s4) 0; padding: var(--s4);
  min-height: 180px; max-height: 60vh;
  background: var(--sunken); border: 1px solid var(--edge); border-radius: var(--r-sm);
}
.asset-shot img {
  max-width: 100%; max-height: 52vh;
  object-fit: contain;
  /* The game's art is pixel work; a browser's default smoothing turns a
     16px sprite into a blur at this size. */
  image-rendering: pixelated;
}
.asset-shot-gone { margin: var(--s4) 0; }
.asset-facts-grid { margin-bottom: var(--s4); }
.asset-twins { margin: 0 0 var(--s4); padding-left: 1.1rem; }
.asset-twins li { margin: 2px 0; font-size: var(--t-small); }
/* Previous / count / next under the picture. Three columns so the count stays
   centred even when there is no previous or no next. */
.asset-step {
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
  gap: var(--s2); margin: calc(var(--s2) * -1) 0 var(--s4);
}
.asset-step a[rel="next"] { justify-self: end; }
.asset-step a[rel="prev"], .asset-step a[rel="next"] {
  min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.asset-step-count { font-size: var(--t-small); color: var(--ink-3); font-variant-numeric: tabular-nums; }
.asset-actions { margin: 0 0 var(--s4); font-size: var(--t-small); }
/* The result count and the sort order, sharing one line on /atlas. */
.atlas-bar {
  display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
  gap: var(--s1) var(--s4); margin-bottom: var(--s3);
}
.atlas-bar .sub { margin: 0; }
.atlas-sort { margin: 0; display: flex; gap: var(--s3); font-size: var(--t-small); color: var(--ink-3); }
.atlas-sort a { color: var(--ink-2); }
.atlas-sort a.on { color: var(--ink); font-weight: 650; text-decoration: none; }
.atlas-sweep { margin: 0 0 var(--s3); font-size: var(--t-micro); }
/* A series heading over its own run of tiles. The whole point of the atlas
   having sections: the label says what you are looking at, and the link under
   it goes to the rest of that series instead of the next 120 of everything. */
.atlas-head {
  display: flex; align-items: baseline; gap: var(--s2);
  margin: var(--s5) 0 var(--s2); padding-top: var(--s3);
  border-top: 1px solid var(--edge);
  font-size: var(--t-small); font-weight: 650;
  letter-spacing: .02em; text-transform: none;
}
/* The first heading sits under the "N matching" line, which is already a
   division — a rule there would be two in a row. */
.atlas-head:first-of-type { margin-top: var(--s3); padding-top: 0; border-top: 0; }
.atlas-head a { font-family: var(--mono); color: var(--ink); }
.atlas-head .faint { font-family: var(--mono); font-size: var(--t-micro); font-weight: 400; }

.atlas-tile {
  background: var(--raised); border: 1px solid var(--edge); border-radius: var(--r-sm);
  padding: var(--s2); display: flex; flex-direction: column; gap: var(--s1);
  align-items: center; text-align: center; min-width: 0;
  transition: border-color .12s ease, background .12s ease;
}
a.atlas-tile:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--edge));
                     background: var(--raised-2); text-decoration: none; }
.atlas-shot {
  width: 100%; aspect-ratio: 1; display: grid; place-items: center;
  background: var(--sunken); border-radius: var(--r-sm); overflow: hidden;
}
.atlas-shot img {
  max-width: 82%; max-height: 82%; object-fit: contain;
  image-rendering: pixelated; filter: drop-shadow(0 1px 3px rgba(0,0,0,.6));
}
.atlas-cap {
  font-size: var(--t-micro); color: var(--ink-2); line-height: 1.3;
  overflow-wrap: anywhere; width: 100%;
}
.atlas-cap .faint { display: block; font-family: var(--mono); font-size: 10px; }

/* A path the client references but taming.io has confirmed doesn't resolve —
   see scripts/check-assets.ts. Deliberately calm rather than error-red: this
   is the game having moved on, not a fault in this page, and --unknown is the
   same muted tone the rest of the site uses for "captured but unnamed". No
   <img> is rendered at all, so there is nothing for the browser to fail to
   load and no broken-image glyph to explain away. */
.atlas-gone {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 4px; color: var(--unknown); text-align: center; padding: var(--s2);
}
.atlas-gone-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
/* The dot above stays on --unknown (graphical, 3:1 is enough); the word
   underneath is real content and needs --unknown-text's 4.5:1 instead. */
.atlas-gone-label {
  font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 650;
  line-height: 1.3; color: var(--unknown-text);
}

/* ── Result list (search) ────────────────────────────────────────────── */
.hits { list-style: none; margin: 0; padding: 0; }
.hits li { border-bottom: 1px solid var(--edge-soft); padding: var(--s3) 0; }
.hits li:last-child { border-bottom: 0; }
.hit-kind {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .08em;
  color: var(--ink-3); font-weight: 650;
}
.hit-line { display: flex; align-items: center; gap: var(--s3); min-width: 0; }
.hit-line .grow { min-width: 0; flex: 1; }

/* Two-column layout for a page that pairs a chart with its table. */
.split { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: var(--s5); align-items: start; }
@media (max-width: 820px) { .split { grid-template-columns: 1fr; } }

/* ═══════════════════════════════════════════════════════════════════════
   THE EDITORIAL LAYER

   A front page needs a headline that grabs, and this site needs every claim
   to be traceable. Those pull in opposite directions, so the design keeps
   them in separate registers rather than compromising both:

     · editorial  — a person wrote it. Big type, a kicker, a byline. Set in
                    the sans stack at display sizes.
     · evidence   — the database says so. Mono numbers, provenance dots, and
                    the same tiers used everywhere else on the site.

   A post may CITE a number; it may never introduce one. So an editorial
   headline never gets a provenance dot — a dot would imply the sentence had
   been verified as a value, which is a category error — and any figure inside
   a post carries its source instead.
   ═══════════════════════════════════════════════════════════════════════ */

/* Marks prose as written here, not read from the client. Deliberately a
   different shape from .prov: an outline chip, never a coloured dot, so it
   cannot be misread as a fifth provenance tier for values. */
.badge-editorial {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .08em;
  font-weight: 650; color: var(--ink-3);
  border: 1px solid var(--edge); border-radius: 3px; padding: 1px 6px;
}

/* Marks a number read off the community wiki rather than out of the client.
   Same outline shape as .badge-editorial and for the same reason: it must not
   read as a fifth provenance tier. The tiers describe how well a DATAMINED
   value is understood; this is a different source entirely, and the one thing
   a reader must never conclude is that it was verified against the client. */
.badge-community {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .08em;
  font-weight: 650; color: var(--probable);
  border: 1px solid color-mix(in srgb, var(--probable) 35%, var(--edge));
  border-radius: 3px; padding: 1px 6px;
}

/* The category line above a headline. Colour distinguishes the kinds of
   story at a glance without another control. */
.kicker {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .11em;
  font-weight: 700; color: var(--accent); display: inline-block;
  /* .story is a column flexbox, whose children default to align-items:
     stretch — an inline-block on its own sizes to content, but as a flex
     child it was stretching to the card's full width. Invisible for a
     plain-text kicker; on .kicker-leak, whose border makes the box visible,
     it drew as a bar spanning the whole card instead of hugging "LEAK". */
  align-self: flex-start;
}
.kicker-patch  { color: var(--neutral); }
.kicker-guide  { color: var(--probable); }
.kicker-notice { color: var(--observed); }

/* A leak is the only kind that is not a claim of fact, so it is the only
   kicker that gets a border as well as a colour — it should read differently
   at a glance, before anyone has processed the word. */
/* ── The composer's word-processor layer ───────────────────────────────────
   Toolbar, live preview and source picker. Every one of these elements ships
   with `hidden` set and is revealed by public/assets/composer.js, so none of
   this is ever seen by a page whose script did not run — the styling can
   therefore assume the behaviour exists. */

/* `hidden` is only a presentation HINT: the UA implements it as
   `[hidden] { display: none }` at the lowest precedence, so any rule below
   that sets `display` on the same element silently outranks it. That is not
   hypothetical — it shipped here and was caught in the browser: .fmt-toolbar's
   own `display:flex` beat the attribute, so the dead toolbar was drawn for
   anyone whose script had not run (the exact thing `hidden` was there to
   prevent), and #source-selects — a .split, `display:grid` — stayed on screen
   after composer.js hid it, showing the dropdowns AND the chips at once.
   An attribute selector lifts specificity above the bare class, which is all
   it takes. Any element this file reveals from JS belongs in this list. */
.fmt-toolbar[hidden],
.preview-box[hidden],
.source-picker[hidden],
.src-results[hidden],
.split[hidden] { display: none; }

.fmt-toolbar {
  display: flex; flex-wrap: wrap; align-items: center; gap: 4px;
  padding: 6px; margin-bottom: -1px;
  background: var(--raised); border: 1px solid var(--edge);
  border-radius: var(--r-sm) var(--r-sm) 0 0;
}
/* Square-ish, quiet, and NOT .btn: these sit above the textarea in a strip and
   .btn's margin-top/accent fill would read as a row of primary actions
   competing with Save. */
/* `button.` rather than `.` — and the same on the four rules below. The skin's
   `[data-skin="tio"] button` is (0,1,1), which outranks any bare single class,
   so every deliberately-NOT-a-button <button> on the site was getting the
   game's 4px near-black frame, 10px radius and inset bevel: this toolbar
   rendered 63px tall with its buttons overrunning the toolbar's own padding.
   Element+class ties that specificity and wins on source order, since all of
   these sit below the skin block. The comment under this rule explains why the
   quiet treatment is the point. */
button.fmt-btn {
  font: inherit; font-size: var(--t-small); line-height: 1;
  color: var(--ink-2); background: transparent;
  border: 1px solid transparent; border-radius: 4px; box-shadow: none;
  padding: 6px 9px; min-width: 30px; cursor: pointer;
  transition: background .1s ease, color .1s ease;
}
.fmt-btn:hover { background: var(--raised-2); color: var(--ink); }
.fmt-btn:active { background: var(--sunken); }
.fmt-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.fmt-btn.mono { font-family: var(--mono); }
.fmt-sep { width: 1px; align-self: stretch; margin: 2px 4px; background: var(--edge); }

/* The toolbar and the box it acts on read as one control, so the corners meet. */
.fmt-toolbar:not([hidden]) + textarea { border-radius: 0 0 var(--r-sm) var(--r-sm); }

.preview-box {
  margin: var(--s4) 0 var(--s3);
  border: 1px solid var(--edge); border-radius: var(--r-sm);
  background: var(--sunken); overflow: hidden;
}
.preview-head {
  display: flex; justify-content: space-between; gap: var(--s3); flex-wrap: wrap;
  padding: 6px var(--s3); border-bottom: 1px solid var(--edge-soft);
  background: var(--raised);
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .08em;
  font-weight: 650; color: var(--ink-2);
}
.preview-head .faint { text-transform: none; letter-spacing: 0; font-weight: 400; }
/* Capped rather than unbounded: this sits mid-form, and a 3000-word article
   rendering at full height would push Kind, Address and every button below it
   off the bottom of the page. */
.preview-box .post-body { padding: var(--s4); max-height: 460px; overflow: auto; }
.preview-box .post-body > :first-child { margin-top: 0; }

.source-picker { margin-bottom: var(--s4); position: relative; }
.src-chips { display: flex; flex-wrap: wrap; gap: var(--s2); margin: var(--s2) 0; }
.src-none { color: var(--ink-3); font-size: var(--t-small); }
.src-chip {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 4px 6px 4px 9px; max-width: 100%;
  background: var(--raised); border: 1px solid var(--edge);
  border-radius: 999px; font-size: var(--t-small);
}
.src-chip-kind {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .07em;
  font-weight: 650; color: var(--ink-3);
}
.src-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
button.src-chip-act, button.src-chip-x {
  font: inherit; background: transparent; border: 0; cursor: pointer; box-shadow: none;
  color: var(--ink-3); padding: 2px 4px; border-radius: 4px;
}
.src-chip-act { font-size: var(--t-micro); text-decoration: underline; }
.src-chip-x { font-size: 1rem; line-height: 1; }
.src-chip-act:hover, .src-chip-x:hover { color: var(--ink); background: var(--raised-2); }
.src-chip-act:focus-visible, .src-chip-x:focus-visible { outline: 2px solid var(--accent); }

/* Absolutely positioned so opening the result list does not shove the rest of
   the form down every time a key is pressed — the jump made it easy to lose
   your place mid-search. */
.src-results {
  position: absolute; z-index: 20; left: 0; right: 0;
  max-height: 300px; overflow: auto;
  background: var(--raised); border: 1px solid var(--edge);
  border-radius: var(--r-sm); box-shadow: 0 10px 30px rgba(0,0,0,.45);
}
button.src-result {
  display: flex; align-items: baseline; gap: var(--s3); width: 100%; box-shadow: none;
  font: inherit; font-size: var(--t-small); text-align: left;
  padding: 8px var(--s3); background: transparent; border: 0;
  border-bottom: 1px solid var(--edge-soft); color: var(--ink); cursor: pointer;
}
.src-result:last-child { border-bottom: 0; }
.src-result:hover, .src-result:focus-visible { background: var(--raised-2); outline: 0; }
.src-result-kind {
  flex: none; width: 4.2rem;
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .07em;
  font-weight: 650; color: var(--ink-3);
}
.src-result-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.src-result-meta { flex: none; color: var(--ink-3); font-size: var(--t-micro); }
.src-empty { padding: 10px var(--s3); color: var(--ink-3); font-size: var(--t-small); }

/* Formatting reference in the composer. Still a <details>: it is the BACKUP
   path now that the toolbar exists, reference material rather than the way in.
   Every other page that uses it still runs script-src 'none', where a
   <details> is the only disclosure widget there is. */
.fmt-help { margin: .5rem 0 1rem; font-size: .9rem; }
.fmt-help > summary { cursor: pointer; color: var(--ink-2); padding: .25rem 0; }
.fmt-help > summary:hover { color: var(--ink); }
.fmt-table { width: 100%; border-collapse: collapse; margin: .5rem 0; }
.fmt-table td { padding: .22rem .5rem .22rem 0; vertical-align: baseline; }
.fmt-table td:first-child { width: 16rem; white-space: nowrap; }
.fmt-table code { font-size: .88em; }

/* The copy-me snippet for embedding an uploaded image in the body. */
#embed-snippet { width: 100%; font-size: .85rem; padding: .4rem .5rem; }

.kicker-leak {
  color: var(--probable);
  border: 1px solid currentColor;
  border-radius: 3px;
  padding: 0 .35em;
}

/* The standing warning on a leak post. Deliberately not dismissible and not
   subtle: everything else on this site traces to a source, and this does not. */
.leak-warning {
  display: flex;
  gap: .6rem;
  align-items: flex-start;
  border: 1px solid var(--probable);
  border-left-width: 4px;
  border-radius: var(--r-sm);
  padding: .7rem .9rem;
  margin: 0 0 1.25rem;
  background: color-mix(in srgb, var(--probable) 8%, transparent);
  font-size: .92rem;
  line-height: 1.45;
}
.leak-warning strong { color: var(--probable); }

/* Headline scale. Tighter tracking and heavier weight than body headings —
   a headline is meant to be read across the room, an h2 is not. */
.hed { font-weight: 750; letter-spacing: -0.03em; line-height: 1.08; text-wrap: balance;
       margin: var(--s2) 0 0; color: var(--ink); }
.hed a { color: inherit; }
.hed a:hover { color: var(--accent); text-decoration: none; }
.hed-xl { font-size: clamp(2rem, 5.2vw, 3.25rem); }
.hed-lg { font-size: clamp(1.5rem, 3vw, 2rem); }
.hed-md { font-size: 1.25rem; }
.hed-sm { font-size: 1.0625rem; letter-spacing: -0.02em; }

/* Standfirst — the line that carries the story when the headline can only
   carry the hook. */
.dek { color: var(--ink-2); font-size: var(--t-lead); line-height: 1.5;
       margin: var(--s3) 0 0; max-width: 62ch; text-wrap: pretty; }
.dek-sm { font-size: var(--t-small); margin-top: var(--s2); }

.byline { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap;
          margin-top: var(--s3); font-size: var(--t-small); color: var(--ink-3); }
.byline .who { color: var(--ink-2); font-weight: 600; }

/* ── Front page ────────────────────────────────────────────────────────
   The lead story spans, everything else is a card. One column on a phone. */
.frontpage { display: grid; grid-template-columns: minmax(0,1fr) 320px;
             gap: var(--s7); align-items: start; }
@media (max-width: 900px) { .frontpage { grid-template-columns: 1fr; gap: var(--s6); } }

.lead {
  display: block; border-bottom: 1px solid var(--edge);
  padding-bottom: var(--s5); margin-bottom: var(--s5);
}
.lead:hover { text-decoration: none; }
.lead-art {
  /* The frame is for SPRITES — near-square game art, 170-240px on its long
     edge. It used to be full width at 21:9, which is a ratio of 2.33, and a
     0.85-ratio sprite cannot fill more than 36% of that width no matter what
     the caps below say: the ceiling is sprite-ratio ÷ frame-ratio, and no
     amount of max-width reaches past it. Measured, the art was covering 13%
     of the box and reading as a speck in a large empty panel.

     3:2 and capped in width instead. The frame is still fixed — a lead that
     changes shape per image makes the page jump — it is just a shape a sprite
     can actually occupy. The width cap is set from the art rather than chosen:
     max-width and max-height only ever SHRINK an image, so a 235px sprite sits
     at natural size in any frame taller than that and the frame has to come
     down to meet it. 420x280 puts a typical sprite at ~84% of the height
     without scaling it up at all, which keeps it exactly as sharp as it was
     drawn. Photos are untouched: .lead-art:has(img.is-photo)
     below drops the ratio and the cap entirely, because a screenshot is the
     evidence rather than decoration. */
  width: 100%; aspect-ratio: 3 / 2; max-width: 420px;
  margin-inline: auto; border-radius: var(--r-lg);
  /* --sunken (#080B10) sits almost on top of --ground (#0B0E14) — a few
     points of RGB apart, invisible as a "box" against the page itself. Pure
     black actually reads as a distinct frame around the art. */
  background: #000; border: 1px solid var(--edge);
  display: grid; place-items: center; overflow: hidden; margin-bottom: var(--s4);
}
.lead-art img {
  max-width: 78%; max-height: 90%; object-fit: contain;
  image-rendering: pixelated; filter: drop-shadow(0 6px 18px rgba(0,0,0,.65));
}
/* A game sprite is small pixel art and wants the crisp-block rendering above,
   floating small in its dark frame. An editor's own upload is an ordinary
   screenshot or photo — `pixelated` scaling turns its fine detail to mush, and
   the small-centered treatment leaves it looking like a broken image rather
   than a lead photo. is-photo (set from isUpload() at render time) opts an
   uploaded image out of both.

   It also drops the 21:9 frame rather than filling it. A sprite is decoration
   and can be cropped to a tidy shape; a screenshot is the EVIDENCE the post is
   about, and a fixed wide frame silently ate the top and bottom of every tall
   one — an upright phone or iPad capture kept only a middle band. So the frame
   takes the image's own proportions and the whole picture is shown. Capped in
   viewport height so a very tall capture can't push the article off screen;
   `contain` there means that cap letterboxes rather than crops. */
/* The base .lead-art box is width:100% with a --sunken fill — right for a
   sprite, which is meant to float small inside a deliberately oversized dark
   frame. A photo has no such frame: it fills whatever space it actually
   occupies, so a box that's still 100% of the column wide around an image
   narrower than that read as a large empty dark rectangle either side of the
   picture, not a frame around it. width:fit-content makes the box exactly as
   wide as the image it contains — for a photo, there is no "there" for the
   box to be dark at. */
.lead-art:has(img.is-photo) {
  aspect-ratio: auto; width: fit-content; max-width: 100%;
  background: none; border: none; margin-left: auto; margin-right: auto;
}
.lead-art img.is-photo {
  /* Both dimensions auto, both capped: the image picks its own size inside
     those bounds, so its aspect ratio is preserved by the layout itself and
     there is nothing for object-fit to letterbox or crop. Setting width:100%
     instead would force a full-width box around a narrow upright capture and
     pad it out with dead space to reach the height cap.

     min-width is the other half: a genuinely small upload — a game-sized
     screenshot, a sprite grabbed off the wiki, anything under a couple
     hundred pixels — rendered at auto width sat at its own tiny native size,
     which reads as broken as cropping did, just in the other direction.
     min() keeps that floor from forcing horizontal overflow on a phone-width
     column, where 100% is already narrower than the floor.

     The frame's own border-radius doesn't reach a photo any more — the box
     it lived on is gone — so it moves onto the image directly, along with a
     thin border, matching the site's other rounded panels rather than
     showing the image's own sharp rectangular edge. */
  width: auto; height: auto;
  /* 80vh let a wide screenshot fill the whole first screen, pushing the
     headline it illustrates below the fold on a laptop. */
  min-width: min(420px, 100%); max-width: 100%; max-height: min(60vh, 540px);
  object-fit: contain; image-rendering: auto; filter: none;
  display: block; border-radius: var(--r-lg); border: 1px solid var(--edge);
}
/* The lead image's own alt text ("Describe the image"), shown — reusing the
   same field rather than adding a second one to type into. Centred under a
   box that now sizes to the image (see .lead-art:has(img.is-photo) above),
   so the caption stays under the picture rather than stretching the full
   column width beside it. */
.lead-caption {
  max-width: 100%; margin: var(--s2) 0 var(--s4); text-align: center;
  font-size: var(--t-small); color: var(--ink-3); line-height: 1.5;
}

.stories { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
           gap: var(--s5) var(--s5); }
.story { display: flex; flex-direction: column; min-width: 0;
         border-top: 1px solid var(--edge); padding-top: var(--s3); }
.story:hover { text-decoration: none; }
.story-art {
  width: 100%; aspect-ratio: 16 / 9; border-radius: var(--r);
  background: var(--sunken); border: 1px solid var(--edge-soft);
  display: grid; place-items: center; overflow: hidden; margin-bottom: var(--s3);
}
/* Same problem one size down, and it is worst on /news where the frame is 508
   wide: a sprite covered 27% of it.

   The frame's RATIO is deliberately left alone — the rule below this one keeps
   every card the same height, and a grid that changes shape per image reads as
   broken. So the art is told to fill instead: height drives, width follows.
   max-height alone could never do this, because a cap only shrinks and these
   sprites are smaller than the frame to begin with.

   This does scale a sprite up, by about a third at the largest frame. That is
   the one place upscaling is right here — they are game sprites, `pixelated`
   keeps the edges crisp rather than smearing them, and the game itself draws
   the same art far larger than its source. */
.story-art img { height: 88%; width: auto; max-width: 88%; object-fit: contain;
                 image-rendering: pixelated; }
/* Cards keep their fixed frame — a grid of uneven heights reads as broken —
   but `contain`, not `cover`: a cropped thumbnail of a screenshot throws away
   the part that made it worth posting, and the reader can't tell it happened.
   Letterboxing against the sunken background is the honest version. */
/* A story with no picture still gets its frame, so a row of cards lines up.
   Its kind in the display face, faint, so it reads as a label not a picture. */
.story-art-blank span {
  font-family: var(--display); font-size: 1.6rem; letter-spacing: .04em;
  text-transform: uppercase; color: var(--ink-3); opacity: .55;
}
.story-art img.is-photo {
  /* Explicitly re-stated, not inherited by luck of source order: the sprite
     rule above now sets height/width, and a photo must go back to filling the
     frame on both axes. */
  max-width: 100%; max-height: 100%; width: 100%; height: 100%;
  object-fit: contain; image-rendering: auto;
}

/* ── Side rail ─────────────────────────────────────────────────────────
   Where the automatic stuff lives. Kept visually distinct from the stories
   because nobody wrote it — it's the database talking. */
/* A sticky box taller than the viewport is a trap: it pins at `top`, and
   whatever hangs below the fold can never be reached — the page scroll moves
   the article, not the pinned rail, and there is nothing to scroll INSIDE it
   either. So it is capped at the space it actually occupies and scrolls its
   own overflow when it needs to. */
.rail {
  position: sticky; top: var(--rail-top); display: flex; flex-direction: column; gap: var(--s4);
  max-height: calc(100vh - var(--rail-top) - var(--s4));
  max-height: calc(100dvh - var(--rail-top) - var(--s4));
  overflow-y: auto;
}
@media (max-width: 900px) { .rail { position: static; } }
.rail-head {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .09em;
  font-weight: 650; color: var(--ink-3);
  padding-bottom: var(--s2); border-bottom: 1px solid var(--edge); margin-bottom: var(--s3);
}
.rail-item { display: block; padding: var(--s2) 0; border-bottom: 1px solid var(--edge-soft);
             font-size: var(--t-small); }
.rail-item:last-child { border-bottom: 0; }
.rail-item:hover { text-decoration: none; }
.rail-item .t { color: var(--ink); line-height: 1.35; }
.rail-item:hover .t { color: var(--accent); }

/* ── Article ───────────────────────────────────────────────────────────── */
.post-head { max-width: 68ch; margin-bottom: var(--s6); }
.post-body { max-width: 66ch; font-size: var(--t-lead); line-height: 1.68; }
/* var(--s5) is 24px against a computed line-height of ~28.6px at this
   font-size — a blank line in the draft was landing SMALLER than one actual
   line of text, so the gap read as a tight paragraph break, not the blank
   line it was typed as. var(--s6) clears a full line, so the space between
   paragraphs reads as what it is: a blank line, kept. */
.post-body p { margin: 0 0 var(--s6); }
.post-body h3 { font-size: 1.375rem; letter-spacing: -0.02em; margin: var(--s7) 0 var(--s3); }
.post-body ul { margin: 0 0 var(--s5); padding-left: var(--s5); }
.post-body li { margin-bottom: var(--s2); }
/* First paragraph gets a lead-in, the way a print article does. */
.post-body > p:first-child { font-size: 1.1875rem; color: var(--ink); }

/* Where a post's figures came from. Every post that quotes a number ends
   with one of these, and it links to the record rather than restating it. */
.sources { border-left: 2px solid var(--edge); padding-left: var(--s4); margin-top: var(--s7); }
.sources .rail-head { border: 0; margin-bottom: var(--s1); }

/* ── Newsroom + support forms ──────────────────────────────────────────── */
.composer textarea { min-height: 320px; font-size: var(--t-body); line-height: 1.65; }
.composer input[type=text] { font-size: var(--t-lead); }

/* ── Image drop zone ──────────────────────────────────────────────────
   A file input IS a drop target in every browser — dropping a file onto one
   sets it, with no script involved. So the input is stretched to fill a large
   dashed box and made transparent: the whole box is the control, which means
   the drop area and the click area are the same element rather than a styled
   div pretending to be one.

   What this cannot do without JavaScript is react to drag-over. Rather than
   fake it, the label says what to do — an affordance that doesn't depend on
   feedback the page is unable to give. */
.dropzone {
  position: relative; display: grid; place-items: center; text-align: center;
  min-height: 96px; padding: var(--s4);
  border: 1.5px dashed var(--edge); border-radius: var(--r);
  background: var(--sunken); transition: border-color .12s ease, background .12s ease;
}
.dropzone:hover { border-color: var(--ink-3); }
/* :focus-within is the keyboard equivalent of the hover state above — the
   input itself is invisible, so without this a tabbed-to drop zone would show
   no focus ring at all. */
.dropzone:focus-within { border-color: var(--accent); background: var(--raised); }
.dropzone input[type=file] {
  position: absolute; inset: 0; width: 100%; height: 100%;
  opacity: 0; cursor: pointer;
}
.dropzone-hint { color: var(--ink-3); font-size: var(--t-small); max-width: 42ch; }
/* Confirming a file was actually picked is the other half of "no script": a
   transparent input gives no native feedback either, since the OS-drawn
   filename text it would normally show is exactly what opacity:0 hides. The
   input is marked `required`, which is otherwise meaningless here (the choice
   really is optional), purely so :valid means "has a file" and :invalid means
   "doesn't" — a state CSS can read without any JS reading the input itself. */
.dropzone:has(input:valid) { border-style: solid; border-color: var(--accent); }
.dropzone:has(input:valid) .dropzone-hint { display: none; }
/* upload-preview.js's local-blob-URL thumbnail -- absent with JS off/blocked, */
.dropzone-preview {
  display: block; margin-top: var(--s3); max-width: 100%; max-height: 160px;
  border-radius: var(--r-sm); border: 1px solid var(--edge);
}

/* Same :valid signal, read from further down the form: Save/Publish/Archive
   submit the composer, which cannot carry a file (see the note beside where
   this is used) — so a file staged above and forgotten is silently dropped by
   every button in this row. Warns right where the hand about to click Save
   already is, not just at the dropzone it already scrolled past. */
.stage-warning {
  display: none; border-left: 3px solid var(--probable); background: var(--raised);
  padding: var(--s3) var(--s4); border-radius: 0 var(--r-sm) var(--r-sm) 0;
  margin: var(--s5) 0 0; font-size: var(--t-small);
}
#composer:has(#image_file:valid) .stage-warning { display: block; }

.upload-actions {
  display: flex; align-items: center; gap: var(--s3);
  flex-wrap: wrap; margin: calc(-1 * var(--s2)) 0 var(--s5);
}

/* The image already attached, shown big enough to recognise at a glance —
   a 56px thumbnail beside a hash filename was easy to miss entirely. */
.image-current {
  margin-top: var(--s3); padding: var(--s3); background: var(--sunken);
  border: 1px solid var(--edge-soft); border-radius: var(--r-sm);
}
.image-preview {
  display: block; width: 100%; max-height: 260px; object-fit: contain;
  background: var(--ground); border-radius: var(--r-sm); border: 1px solid var(--edge);
}
.image-preview-sm { width: 120px; height: 80px; flex: none; }
.image-current-row {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s3);
  flex-wrap: wrap; margin-top: var(--s3);
}
.image-added { color: var(--up); font-weight: 650; font-size: var(--t-small); }

/* An AI proposal. Never styled as content — it's an offer until someone
   accepts it, and it has to look like one. */
.proposal {
  border: 1px dashed var(--observed); border-radius: var(--r);
  background: color-mix(in srgb, var(--observed) 6%, var(--raised));
  padding: var(--s3) var(--s4); margin-bottom: var(--s3);
}
.proposal-head {
  display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap;
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .08em;
  color: var(--observed); font-weight: 650; margin-bottom: var(--s2);
}

/* Budget meter for the AI panel — warns before the wall, not at it. */
.meter { height: 6px; border-radius: 3px; background: var(--edge-soft); overflow: hidden; }
.meter > span { display: block; height: 100%; background: var(--accent); }
.meter.warn > span { background: var(--probable); }
.meter.over > span { background: var(--down); }

/* ── /datamine/run: the run button, live log and results ─────────────────── */
.dm-title { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); flex-wrap: wrap; }
.dm-title h1 { margin: 0; }
.dm-start { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; margin: 0 0 var(--s5); }
.dm-job { display: flex; flex-direction: column; gap: var(--s3); }
.dm-head { display: flex; align-items: baseline; gap: var(--s2) var(--s3); flex-wrap: wrap; }
.dm-head h2 { margin: 0; }
.dm-steps { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.dm-steps li {
  font-size: var(--t-small); padding: 3px 10px; border-radius: 20px;
  border: 1px solid var(--edge); color: var(--ink-3);
}
.dm-steps li.dm-step-done { color: var(--verified); border-color: var(--accent-dim); }
.dm-steps li.dm-step-done::before { content: "✓ "; }
.dm-steps li.dm-step-now { color: var(--ink); border-color: var(--accent); }
.dm-steps li.dm-step-now::before {
  content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%;
  background: var(--accent); vertical-align: 1px; animation: dm-pulse 1s ease-in-out infinite alternate;
}
.dm-steps li.dm-step-failed { color: var(--down); border-color: var(--down); }
.dm-steps li.dm-step-failed::before { content: "✗ "; }
.dm-steps li.dm-step-warn { color: var(--probable); border-color: var(--probable); }
.dm-steps li.dm-step-warn::before { content: "! "; }
@keyframes dm-pulse { from { opacity: .35; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .dm-steps li.dm-step-now::before { animation: none; } }
.dm-progress { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: var(--s3); }
.dm-progress[hidden] { display: none; }
.dm-progress .meter > span { transition: width .3s linear; }
.dm-log {
  background: var(--sunken); border: 1px solid var(--edge-soft); border-radius: var(--r-sm);
  font: 12.5px/1.55 var(--mono); padding: var(--s2) var(--s3); max-height: 460px; overflow: auto;
  font-variant-numeric: tabular-nums;
}
.dm-line { display: grid; grid-template-columns: 6ch 13rem minmax(0, 1fr); gap: var(--s2); color: var(--ink-2); }
.dm-line .dm-t { color: var(--ink-3); text-align: right; }
.dm-line .dm-stage { color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dm-line .dm-msg { overflow-wrap: anywhere; white-space: pre-wrap; }
.dm-line.dm-step { color: var(--ink); font-weight: 600; margin-top: 6px; }
.dm-line.dm-ok .dm-msg { color: var(--verified); }
.dm-line.dm-warn .dm-msg { color: var(--probable); }
.dm-line.dm-error .dm-msg { color: var(--down); font-weight: 600; }
.dm-line.dm-wait { color: var(--ink-3); }
@media (max-width: 640px) {
  .dm-line { grid-template-columns: 5ch minmax(0, 1fr); }
  .dm-line .dm-stage { display: none; }
}
.dm-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: var(--s2); }
.dm-tile { display: flex; flex-direction: column; gap: 2px; padding: var(--s2) var(--s3); border: 1px solid var(--edge-soft); border-radius: var(--r-sm); }
.dm-tile-n { font-size: var(--t-title); font-weight: 600; font-variant-numeric: tabular-nums; color: var(--ink); }
.dm-tile-k { font-size: var(--t-small); color: var(--ink-3); }
.dm-changes { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--s4); }
.dm-change h3 { margin: 0 0 var(--s2); display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
.dm-items { list-style: none; margin: 0 0 var(--s2); padding: 0; font: 12.5px/1.6 var(--mono); }
.dm-items li { overflow-wrap: anywhere; }
.dm-items li.dm-add { color: var(--verified); }
.dm-items li.dm-add::before { content: "+ "; }
.dm-items li.dm-del { color: var(--down); }
.dm-items li.dm-del::before { content: "− "; }
.dm-items-cols { columns: 18rem; column-gap: var(--s5); }
.dm-items-cols li { break-inside: avoid; margin: 0 0 4px; }
.dm-items li[hidden] { display: none; }
.dm-listhead { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); flex-wrap: wrap; margin: 0 0 var(--s3); }
.dm-listhead h2 { margin: 0; }
.dm-filter { flex: 0 1 320px; min-width: 0; }
.dm-thumbs {
  list-style: none; margin: 0 0 var(--s2); padding: 0;
  display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: var(--s2);
}
.dm-thumbs li {
  display: flex; flex-direction: column; align-items: center; gap: 4px; text-align: center;
  padding: var(--s2) 4px; border: 1px solid var(--edge-soft); border-radius: var(--r-sm);
  font-size: var(--t-small); overflow-wrap: anywhere;
}
.dm-thumbs li[hidden] { display: none; }
.dm-thumbs img, .dm-thumb-none { width: 72px; height: 72px; object-fit: contain; }
.dm-thumb-none {
  display: flex; align-items: center; justify-content: center; text-align: center;
  border-radius: var(--r-sm); background: var(--sunken); color: var(--ink-3); font-size: var(--t-micro);
}
.dm-thumbs li.dm-add { border-color: var(--verified); }
.dm-thumbs li.dm-del { border-color: var(--down); opacity: .65; }
.dm-group { margin: var(--s4) 0 var(--s2); font-size: var(--t-small); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-3); }
.dm-group:first-of-type { margin-top: 0; }
.dm-cats { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: var(--s2); }
.dm-cattile {
  display: grid; grid-template-columns: auto 1fr; column-gap: var(--s2); align-items: baseline;
  padding: var(--s2) var(--s3); border: 1px solid var(--edge-soft); border-radius: var(--r-sm);
  text-decoration: none;
}
.dm-cattile:hover { border-color: var(--ink-3); text-decoration: none; }
.dm-cat-n { font-weight: 600; font-variant-numeric: tabular-nums; }
.dm-cat-k { font-weight: 600; }
.dm-cat-h { grid-column: 1 / -1; font-size: var(--t-small); color: var(--ink-3); }

/* ── Support threads ───────────────────────────────────────────────────── */
.thread { border-left: 2px solid var(--edge); padding-left: var(--s4); margin-bottom: var(--s5); }
.msg { padding: var(--s3) 0; border-bottom: 1px solid var(--edge-soft); }
.msg:last-child { border-bottom: 0; }
.msg-head { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap;
            font-size: var(--t-small); color: var(--ink-3); margin-bottom: var(--s2); }
/* A staff answer is the one people scroll to find. */
.msg.staff { border-left: 2px solid var(--accent); padding-left: var(--s3); margin-left: -2px;
             background: color-mix(in srgb, var(--accent) 4%, transparent); }
.msg.staff .msg-head { color: var(--accent); }

.status-open     { color: var(--probable); }
.status-answered { color: var(--accent); }
.status-resolved { color: var(--ink-3); }
.status-closed   { color: var(--ink-3); }

@media (max-width: 640px) {
  .lead-art { aspect-ratio: 16 / 10; }
  .lead-art img { max-width: 54%; }
  .lead-art img.is-photo { max-width: 100%; }
  .stories { grid-template-columns: 1fr; gap: var(--s4); }
  /* One column of full-width 16:9 frames was a screen of art per story, and a
     whole screen for a blank "GUIDE" tile. A phone gets a list instead: a
     square thumbnail beside the text. */
  /* Only cards that HAVE a frame: /news and the entity pages also draw
     text-only cards, which would otherwise keep an empty 96px column. */
  .story:has(> .story-art) { display: grid; grid-template-columns: 96px minmax(0, 1fr);
                             column-gap: var(--s3); align-content: start; }
  .story > .story-art { grid-row: 1 / span 4; aspect-ratio: 1; margin: 0; }
  .story:has(> .story-art) > :not(.story-art) { grid-column: 2; }
  .story:has(> .story-art) > span { justify-self: start; } /* the kicker badge, not a full-width bar */
  .story-art-blank span { font-size: .75rem; }
  .post-body { font-size: var(--t-body); }
}

/* ── Tags ────────────────────────────────────────────────────────────── */
.tag {
  display: inline-block; font-size: var(--t-micro); padding: 2px var(--s2);
  border-radius: 20px; border: 1px solid var(--edge); color: var(--ink-2);
  white-space: nowrap; line-height: 1.55;
}
/* TOKENS, not literals. These four shipped as dark-mode pastels with no light
   override anywhere in the file, so under the Plain skin in a light theme they
   painted #86EFAC / #FDA4AF / #93C5FD / #FCD34D onto a white surface and
   measured 1.4–1.9:1 — invisible. Only the tio layer below was safe, because it
   had already re-pointed them at tokens; the base layer never followed.
   Pointing them at the same tokens lets the light-theme redefinitions apply, so
   every skin/theme pair gets a colour chosen for its own ground. */
.tag-new, .tag-buff {
  color: var(--verified);
  border-color: var(--accent-dim);
  background: rgb(var(--accent-rgb) / .08);
}
.tag-nerf {
  color: var(--down);
  border-color: color-mix(in oklab, var(--down) 45%, var(--raised));
  background: color-mix(in oklab, var(--down) 8%, transparent);
}
.tag-fix {
  color: var(--neutral);
  border-color: color-mix(in oklab, var(--neutral) 45%, var(--raised));
  background: color-mix(in oklab, var(--neutral) 8%, transparent);
}
.tag-change {
  color: var(--probable);
  border-color: color-mix(in oklab, var(--probable) 45%, var(--raised));
  background: color-mix(in oklab, var(--probable) 8%, transparent);
}
/* Marks a changelog line that stated an actual value rather than "increased
   its effectiveness" — rare enough to be worth pointing at. */
.tag-num    { font-family: var(--mono); color: var(--ink-2); letter-spacing: .02em; }

.chips { display: flex; gap: var(--s2); flex-wrap: wrap; margin-bottom: var(--s5); }
.chips a {
  position: relative; overflow: hidden;
  padding: 6px var(--s3); border-radius: 20px; border: 1px solid var(--edge);
  color: var(--ink-2); font-size: var(--t-small); background: var(--raised);
  transition: border-color .12s ease, color .12s ease, transform .12s ease;
}
.chips a::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,.25) 50%, transparent 65%);
  transform: translateX(-160%) skewX(-12deg);
}
.chips a:hover::after { animation: tt-sweep .5s ease; }
.chips a:hover { border-color: var(--ink-3); color: var(--ink); text-decoration: none; transform: translateY(-1px); }
.chips a:active { transform: translateY(0); }
.chips a.on { background: var(--ink); color: var(--ground); border-color: var(--ink); font-weight: 650; }
/* The selected chip INVERTS — near-black fill, page-coloured text — but a
   `.faint` count inside it kept `--ink-3`, which is a dark tone meant for a
   light background. On the inverted chip that is dark on dark: measured
   1.88:1 in the taming.io skin, and about 3.4:1 in the original palette, so
   this was always wrong and only became obvious once the fill got warmer.
   Deriving from the chip's own colour keeps it correct in every theme and
   skin rather than fixing one of them. */
.chips a.on .faint { color: inherit; opacity: .75; }

/* ── A chip row that does not become a wall on a phone ─────────────────────
   /atlas carries three of these at once — nine categories, up to 28 series,
   three status filters — and at 375px they wrapped to ELEVEN lines. You
   scrolled past the whole navigation twice before reaching a single piece of
   art, on the device most people read this site on.

   So below the fold-width they become one swipeable row each. Nothing is
   hidden and nothing needs JavaScript: the chips are all still there, in the
   same order, and the row scrolls instead of the page. Opt-in per row
   (`.chips-scroll`), because a row of three chips elsewhere on the site wraps
   perfectly well and should not learn to scroll. */
@media (max-width: 760px) {
  /* TWO SELECTORS, and the second one is not redundant.
     [data-skin="tio"] .chips:has(a.on) turns a chip group with a selection
     into a tab strip, and it sets flex-wrap there — a SKIN declaring layout,
     not just colour. Its specificity is (0,3,1) against this rule's (0,1,0),
     so on the brown skin the strip kept wrapping and only the properties the
     skin did not mention (overflow-x, the margins) came through. That reads
     as "half my rule applied", which is not a thing CSS does — it was two
     rules all along. Matching the skin's own shape ties the specificity, and
     coming later in the file settles it. */
  .chips-scroll,
  [data-skin="tio"] .chips-scroll:has(a.on) {
    flex-wrap: nowrap;
    overflow-x: auto;
    /* Let the strip bleed to both edges of the phone while its first and last
       chips still clear the page gutter — a row that stops short of the edge
       reads as "that is all of them". */
    margin-inline: calc(var(--s4) * -1);
    padding-inline: var(--s4);
    /* A chip should never end up half-cut at rest. */
    scroll-snap-type: x proximity;
    /* iOS shows an overlay scrollbar and then hides it, which is the only
       affordance here; the row must not also reserve a gutter for one. */
    scrollbar-width: none;
  }
  .chips-scroll::-webkit-scrollbar { display: none; }
  .chips-scroll > * { scroll-snap-align: start; flex: none; }
}

/* The hit area, not the chip. Same trick as .why > summary below: padding
   grows the target and a negative margin hands the space back to the layout,
   so a phone gets a 44px target without the row getting taller. */
@media (pointer: coarse) {
  .chips a {
    padding-block: 11px;
    margin-block: -5px;
  }
}

/* The second chip row — the series under a category. Lighter than the row
   above it so the two read as levels rather than as two equal choices, and
   tighter, because it is the longer of the two. */
.chips-sub { margin-top: calc(var(--s5) * -1 + var(--s2)); font-size: var(--t-small); }
.chips-sub a { padding: 4px var(--s2); font-family: var(--mono); font-size: var(--t-micro); }
.chips-sub .faint { font-size: var(--t-micro); }
/* Back out to the level above. Reads as a direction, not as another option. */
.chips-sub a.back { background: none; border-style: dashed; font-family: inherit; }

/* ── Forms ───────────────────────────────────────────────────────────── */
label { display: block; font-size: var(--t-small); color: var(--ink-2); margin: var(--s4) 0 var(--s1); }
/* `input[type=search]` belongs in this list. Without it the eight search
   fields on the site (players, clans, guides, chat, newsroom x2, db, plugins)
   fell through to the browser's native control — no background, no border, no
   text colour, no width — while the taming.io skin listed the selector and
   therefore looked right. Only the opt-in Plain skin showed it, which is why
   it survived this long. */
input[type=text], input[type=search], input[type=password], input:not([type]), textarea, select {
  width: 100%; padding: 10px var(--s3); border-radius: var(--r-sm);
  border: 1px solid var(--edge); background: var(--sunken); color: var(--ink);
  font: inherit; transition: border-color .12s ease;
}
input::placeholder { color: var(--ink-3); }
/* The ring pulses in from nothing rather than just appearing — the same
   "this just became active" beat the noob puzzle's armed tile uses. */
@keyframes tt-ring-in { from { box-shadow: 0 0 0 0 rgb(var(--accent-rgb) / .35); } }
input:focus, textarea:focus, select:focus {
  outline: none; border-color: var(--accent-dim);
  box-shadow: 0 0 0 3px rgb(var(--accent-rgb) / .13);
  animation: tt-ring-in .18s ease-out;
}

.search { display: flex; gap: var(--s2); margin-bottom: var(--s4); }
.search input { padding: 12px var(--s4); font-size: var(--t-lead); border-radius: var(--r); }

/* One shared "light passes over it" sweep, reused on every clickable
   surface below (buttons, chips, tabs, nav links, dropdown items, cards) —
   a single motion vocabulary rather than a different animation per
   component. Each user adds position:relative + overflow:hidden on the
   element itself and this ::after/:hover pair; see .btn for the fullest
   example. */
@keyframes tt-sweep { to { transform: translateX(160%) skewX(-12deg); } }

.btn {
  position: relative; overflow: hidden;
  display: inline-block; margin-top: var(--s4); padding: 10px var(--s4);
  background: var(--accent); color: var(--accent-ink); border: 0;
  border-radius: var(--r-sm); font: inherit; font-weight: 650; cursor: pointer;
  transition: filter .12s ease, transform .12s ease;
}
.btn::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,.4) 50%, transparent 65%);
  transform: translateX(-160%) skewX(-12deg);
}
.btn:hover::after { animation: tt-sweep .5s ease; }
.btn:hover { filter: brightness(1.08); transform: translateY(-1px); }
.btn:active { filter: brightness(.96); transform: translateY(0); }
.btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--edge); }
.btn.ghost:hover { border-color: var(--ink-3); }
.btn.small { padding: 5px var(--s3); font-size: var(--t-small); margin: 0; }
/* Solid, not outlined. This rule used to say `background: transparent; color:
   var(--down)` and a second `.btn.danger` 2,800 lines further down said
   `background: var(--down); color: #fff`. Same selector, same specificity, so
   the far one won everywhere and the two declarations here were dead — the
   border was the only part of them still doing anything. Folded into one
   rule with the values that were actually rendering, so nothing moved. */
.btn.danger { background: var(--down); border: 1px solid #5C2430; color: #fff; }
/* A secondary action sitting next to a primary one — "Test this key" beside
   "Save". Quieter than .ghost because it appears in rows, not on its own. */
.btn.btn-quiet {
  background: var(--raised-2); color: var(--ink-2); border: 1px solid var(--edge);
  padding: 5px var(--s3); font-size: var(--t-small); margin: 0; font-weight: 550;
}
.btn.btn-quiet:hover { color: var(--ink); border-color: var(--ink-3); filter: none; }

.err { background: #2A1519; border: 1px solid #5C2430; color: #FDA4AF;
       padding: 10px var(--s3); border-radius: var(--r-sm); font-size: var(--t-small); margin-top: var(--s4); }
.ok  { background: #0E2418; border: 1px solid var(--accent-dim); color: #86EFAC;
       padding: 10px var(--s3); border-radius: var(--r-sm); font-size: var(--t-small); margin-top: var(--s4); }
.switch { margin-top: var(--s5); font-size: var(--t-small); color: var(--ink-2); text-align: center; }

/* ── Auth ────────────────────────────────────────────────────────────── */
body.auth { display: flex; flex-direction: column; min-height: 100vh; }
/* Widened from 390 so the jigsaw card can reach ~400. The puzzle is the
   content of this page, and at the old width the 3x3 grid had barely more
   room than the paragraph above it. The sign-in and register forms sit in
   the same box and simply get a little more air. */
.authbox { max-width: 448px; margin: auto; padding: var(--s7) var(--s5); width: 100%; }
.authbox .brand.big { margin-bottom: var(--s6); }

/* ── Article layout ──────────────────────────────────────────────────── */
.article { display: grid; grid-template-columns: minmax(0,1fr) 320px; gap: var(--s7); align-items: start; }
.article-main { min-width: 0; }

/* Same trap as .rail, and this is the one that bit: on a tall entity the
   infobox ran past the bottom of the window, and `overflow: hidden` — there
   for the rounded corners — meant the Calculated block at the end of it
   (taming chance, expected attempts) was not merely off-screen but
   unreachable by any amount of scrolling.
   `hidden auto` keeps the horizontal clipping the radius needs and gives the
   vertical axis somewhere to go. */
.infobox {
  background: var(--raised); border: 1px solid var(--edge);
  border-radius: var(--r-lg); position: sticky; top: var(--rail-top);
  max-height: calc(100vh - var(--rail-top) - var(--s4));
  max-height: calc(100dvh - var(--rail-top) - var(--s4));
  overflow: hidden auto;
  overscroll-behavior: contain;
}
/* Below the sticky breakpoint it is just a box in the flow — no cap, no
   inner scrollbar, because the page itself scrolls it. */
@media (max-width: 900px) {
  .infobox { position: static; max-height: none; overflow: hidden; }
}
.infobox-head {
  padding: var(--s3) var(--s4); font-weight: 700; font-size: var(--t-lead);
  letter-spacing: -0.02em; border-bottom: 1px solid var(--edge);
  display: flex; align-items: center; justify-content: space-between; gap: var(--s2);
}
.infobox-art {
  padding: var(--s5); display: grid; place-items: center; min-height: 170px;
  background: var(--sunken); border-bottom: 1px solid var(--edge);
}
.infobox-art img {
  max-width: 148px; max-height: 148px;
  image-rendering: pixelated; filter: drop-shadow(0 4px 10px rgba(0,0,0,.6));
}
.infobox table { font-size: var(--t-small); }
.infobox th {
  background: transparent; border-bottom: 1px solid var(--edge-soft);
  padding: var(--s2) var(--s4); width: 50%; text-transform: none;
  letter-spacing: 0; font-size: var(--t-small); color: var(--ink-2); font-weight: 500;
}
.infobox td {
  padding: var(--s2) var(--s4); border-bottom: 1px solid var(--edge-soft);
  font-family: var(--mono); font-variant-numeric: tabular-nums;
  font-size: var(--t-small); font-weight: 650;
}
.infobox tbody tr:hover { background: transparent; }
.infobox-band {
  padding: var(--s2) var(--s4); background: var(--sunken);
  border-bottom: 1px solid var(--edge-soft);
  display: flex; align-items: center; gap: var(--s2);
}

/* Contents — quiet, never competing with the article */
.toc { margin: 0 0 var(--s6); }
.toc ol {
  margin: var(--s2) 0 0; padding: 0; list-style: none;
  display: flex; flex-wrap: wrap; gap: var(--s1) var(--s4);
  font-size: var(--t-small);
}
.toc a { color: var(--ink-2); }
.toc a:hover { color: var(--ink); }

/* An entity page can carry a dozen-plus jump links (Overview, Skill,
   Strategy, Stats, Growth stages, Levelling up…). Wrapped across several
   lines on a phone that's more vertical space spent on navigation than on
   the Overview paragraph it's pointing at. Scrolling sideways in one line
   keeps it out of the reader's way instead — the same "own its overflow,
   don't shrink the content" call the wide tables and charts on this site
   already make (.table-scroll, .ci-chart). */
@media (max-width: 640px) {
  .toc { overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 0 0 var(--s5); }
  .toc ol { flex-wrap: nowrap; width: max-content; padding-bottom: 2px; }
  /* On the desktop wrap this is a dense, mouse-driven index and stays quiet
     on purpose (see the comment above .toc). On a phone it's the ONLY way to
     jump around a long entity page, and every other touch control this site
     ships gives itself real padding for that (.chips a: 6px var(--s3), the
     nav-toggle button, .tb-btn) — this was the one left at bare text, whose
     hit box was only the ~13px glyph line itself, under both the 24px CSS
     target-size floor and the 44px comfortable one. Padding added only in
     this single-row breakpoint, where there is no second row for the extra
     height to collide with. */
  .toc a { display: inline-block; padding: 6px 1px; }
}
/* Heading for a list inside a panel — "Entities in this update". Sits at label
   scale so it introduces the list without competing with the page's own h2s. */
.toc-title {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: 0.09em;
  color: var(--ink-3); font-weight: 650;
}

.section-head {
  display: flex; align-items: baseline; gap: var(--s3);
  border-bottom: 1px solid var(--edge); padding-bottom: var(--s2);
  margin: var(--s7) 0 var(--s4);
}
/* The per-section edit link. It renders at 23x21, which is under the 24x24
   minimum target size and was the smallest thing to hit anywhere on the site —
   13 of them on a single entity page. The padding grows the hit area and the
   equal negative margin takes the growth back out of the layout, so nothing
   moves and the link still looks like a word rather than a button. */
.section-head .edit {
  font-size: var(--t-small);
  margin-left: auto;
  /* Hit area grows LEFT and vertically only. A symmetric negative margin was
     the obvious version and pushed the link 8px past the right edge of the
     heading rule it is supposed to line up with — the padding has to come off
     the side that has room, not the side that is aligned to something. */
  padding: 6px 0 6px 10px;
  margin: -6px 0 -6px auto;
}
.section-empty { color: var(--ink-3); font-size: var(--t-small); padding: var(--s2) 0 var(--s4); }

/* Body-text links get a drawn-in underline instead of the browser default
   appearing flat — the same "something is happening" beat as the sweep
   elsewhere, sized for inline text rather than a button or pill. */
.prose a {
  position: relative; text-decoration: none;
  background-image: linear-gradient(var(--link), var(--link));
  background-repeat: no-repeat; background-position: 0 100%;
  background-size: 0% 1px; transition: background-size .18s ease;
}
.prose a:hover { background-size: 100% 1px; }
.prose { max-width: 66ch; }
.prose p { margin: 0 0 var(--s4); }
.prose h3 { margin: var(--s5) 0 var(--s2); }
.prose ul { margin: 0 0 var(--s4); padding-left: var(--s5); }
.prose li { margin-bottom: var(--s1); }
.prose-img {
  display: block; max-width: 100%; height: auto;
  /* Same floor as .lead-art img.is-photo, same reason: a small upload at its
     own native size — a sprite, a cropped detail shot — read as a broken
     image sitting in the article rather than an intentionally small one. */
  min-width: min(420px, 100%); max-height: 80vh; object-fit: contain;
  border-radius: var(--r-sm); border: 1px solid var(--edge);
  margin: 0 0 var(--s4);
}
/* A captioned image is a <figure> wrapping the same .prose-img — the margin
   that used to sit on the image moves to the figure, so the caption sits
   snug under the picture instead of one paragraph-gap below it. */
.prose-figure { margin: 0 0 var(--s4); }
.prose-figure .prose-img { margin: 0; }
.prose-figure figcaption {
  margin-top: var(--s2); font-size: var(--t-small); color: var(--ink-3);
  line-height: 1.5;
}

/* The one place this site quotes rather than states — see wiki_skill_quotes
   in schema.sql. Deliberately NOT styled like .panel/.prose: everywhere else
   on this page is this site's own verified facts or its own original prose,
   and this block needs to read as neither at a glance, not just on close
   reading. --probable (amber) is already this site's colour for "labelled,
   not yet confirmed" — reused here for the same reason: it's provenance
   this site didn't establish itself. */
.quote-block {
  margin: var(--s4) 0; padding: var(--s4);
  background: color-mix(in srgb, var(--probable) 6%, var(--sunken));
  border: 1px solid color-mix(in srgb, var(--probable) 35%, var(--edge));
  border-left: 3px solid var(--probable); border-radius: 0 var(--r-sm) var(--r-sm) 0;
}
.quote-block-label {
  margin: 0 0 var(--s3); font-size: var(--t-micro); text-transform: uppercase;
  letter-spacing: .06em; font-weight: 650; color: var(--probable);
}
.quote-block blockquote {
  margin: 0; font-style: italic; color: var(--ink); line-height: 1.6;
}
.quote-block-cite {
  margin: var(--s3) 0 0; font-size: var(--t-small); color: var(--ink-3);
}

/* ![a](path) lines with no blank line between them — prose.ts groups them
   into one of these instead of stacking each at full width. auto-fit wraps
   naturally past three or four rather than needing a fixed column count per
   size, so "two or three across the top, more below" is just how many image
   lines were typed in a row, no layout decision to make separately. Each
   tile is bounded and never cropped — same reasoning as everywhere else here
   that an editor's own screenshot is evidence, not decoration. */
.prose-gallery {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: var(--s3); margin: 0 0 var(--s4);
}
.prose-gallery .prose-img {
  min-width: 0; max-height: none; height: 220px; margin: 0;
  object-fit: contain; background: var(--sunken);
}
/* A captioned tile in a gallery row is still one grid cell — the figure, not
   the bare img, is what auto-fit is placing side by side — so its own margin
   has to give up the same ground .prose-gallery > * normally would. */
.prose-gallery .prose-figure { margin: 0; }
.prose-gallery .prose-figure figcaption { text-align: center; }

/* ── Timeline ────────────────────────────────────────────────────────── */
.timeline { position: relative; padding-left: var(--s5); }
.timeline::before {
  content: ''; position: absolute; left: 5px; top: 6px; bottom: 6px;
  width: 2px; background: var(--edge);
}
.tl-item { position: relative; padding-bottom: var(--s5); }
.tl-item::before {
  content: ''; position: absolute; left: calc(-1 * var(--s5) + 1px); top: 5px;
  width: 9px; height: 9px; border-radius: 50%;
  background: var(--ink-3); border: 2px solid var(--ground);
}
.tl-item.buff::before, .tl-item.new::before { background: var(--up); }
.tl-item.nerf::before   { background: var(--down); }
.tl-item.fix::before    { background: var(--neutral); }
.tl-item.change::before { background: var(--probable); }
.tl-date { font-family: var(--mono); font-size: var(--t-small); color: var(--ink-3); }

/* ── States ──────────────────────────────────────────────────────────── */
.empty {
  color: var(--ink-3); text-align: center; padding: var(--s7) var(--s5);
  font-size: var(--t-small);
}
.notice {
  border-left: 3px solid var(--probable); background: var(--raised);
  padding: var(--s3) var(--s4); border-radius: 0 var(--r-sm) var(--r-sm) 0;
  margin: var(--s5) 0; font-size: var(--t-small);
}
.notice.info { border-left-color: var(--neutral); }
.notice.good { border-left-color: var(--accent); }
/* Something failed and the reader has to act — a key that didn't authenticate,
   a refresh that errored. Louder than .info deliberately. */
/* The colour is a TOKEN. It was #FDA4AF — a dark-mode rose, hardcoded, with no
   light override — so the site's loudest banner measured 1.65:1 against a
   near-white surface in the Plain skin's light theme. A failure notice that
   cannot be read is the one thing on this site that must never happen quietly.
   The background is raised to (0,2,0) below for the same reason the tags were:
   `[data-skin="tio"] .notice` out-specifies a single class and was flattening
   the red wash to an ordinary notice's background under the default skin. */
.notice-bad {
  border-left-color: var(--down);
  background: color-mix(in srgb, var(--down) 8%, var(--raised));
  color: var(--down);
}
.notice.notice-bad {
  background: color-mix(in srgb, var(--down) 8%, var(--raised));
}

/* Meta-refresh pages: show work is in flight */
.working {
  display: inline-flex; align-items: center; gap: var(--s2);
  color: var(--probable); font-size: var(--t-small);
}
.working::before {
  content: ''; width: 7px; height: 7px; border-radius: 50%;
  background: currentColor; animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .3 } }

/* JS-only state (live-list.js): a fetch is in flight for a [data-live]
   filter/pagination container, refetching in place instead of a full page
   reload. Dims the stale content underneath a .working indicator rather than
   blanking the list, so a slow query never reads as a dead click. */
[data-live].is-loading { opacity: .55; pointer-events: none; transition: opacity .12s ease; }
.live-loading { margin: 0 0 var(--s3); }

/* JS-only (instant-nav.js): sitewide link-click navigation, fetched and
   swapped in over the current page instead of a real reload. The bar is a
   fixed sliver at the very top of the viewport -- reads as "something is
   loading" without implying which part of the (about to be replaced)
   content it applies to, which a dimmed-region treatment would. */
#tt-nav-progress {
  position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 9999;
  overflow: hidden; opacity: 0; pointer-events: none; transition: opacity .15s ease;
}
#tt-nav-progress.active { opacity: 1; }
#tt-nav-progress.active::after {
  content: ''; position: absolute; top: 0; left: -30%; height: 100%; width: 30%;
  background: var(--accent); animation: tt-nav-slide 1s ease-in-out infinite;
}
@keyframes tt-nav-slide { 0% { left: -30%; } 100% { left: 100%; } }
#main.tt-nav-loading { opacity: .6; transition: opacity .15s ease; }
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: .001ms !important; transition-duration: .001ms !important; }
  .card:hover, .btn:hover, .chips a:hover { transform: none; }
}

.source {
  font-family: var(--mono); font-size: var(--t-small); color: var(--ink-2);
  background: var(--sunken); border: 1px solid var(--edge);
  border-radius: var(--r-sm); padding: var(--s2) var(--s3); margin-top: var(--s2);
  white-space: pre-wrap; word-break: break-word;
}
.private-tag {
  font-size: 10px; text-transform: uppercase; letter-spacing: 0.07em;
  color: var(--probable); border: 1px solid #5C4A1A; border-radius: 4px;
  padding: 1px 5px; margin-left: var(--s1);
}

footer {
  border-top: 1px solid var(--edge); margin-top: var(--s8); padding: var(--s5);
  color: var(--ink-3); font-size: var(--t-small); text-align: center;
  /* One line of links between the tagline and the disclaimer, rather than a
     column of single words down the middle of the screen. */
  display: flex; flex-wrap: wrap; justify-content: center; align-items: center;
  gap: var(--s2) var(--s4);
}
footer > span { flex-basis: 100%; }


/* ── Mobile ──────────────────────────────────────────────────────────
   The infobox previously used order:-1, which put the card above the <h1>
   and made the title appear twice. It now follows the article in source
   order and simply stacks. */
@media (max-width: 900px) {
  .infobox { position: static; }
}

/* On a phone the picture and the stats are what people open the page for, and
   in source order they came after every section — 12,800px down on the Bear.
   So the title block stays first, the infobox follows it, and the article
   resumes after. `display: contents` lets the infobox sit between the title
   and the rest without moving it in the markup, which keeps reading order for
   screen readers the same as the desktop layout. The infobox's own name row
   only repeats the <h1> right above it here, so it goes. */
@media (max-width: 900px) {
  .article { display: flex; flex-direction: column; gap: 0; }
  .article-main { display: contents; }
  .article-head { order: 0; }
  .article > .infobox { order: 1; margin: 0 0 var(--s5); }
  .article-main > :not(.article-head) { order: 2; }
  .article > .infobox > .infobox-head { display: none; }
}

/* ── Print ───────────────────────────────────────────────────────────
   Someone printing an entity page wants the stats table beside them while
   they play. So: ink on white, no chrome, and — because provenance is the
   point of this site and colour won't survive a mono printer — the
   confidence tier is spelled out in brackets after each value. */
@media print {
  /* THE SKIN'S SELECTORS HAVE TO BE NAMED HERE, because a media query adds no
     specificity. `:root` alone is (0,1,0) and `:root[data-skin="tio"]` is
     (0,2,0) and is not media-gated — so every token this block reset kept its
     skin value and pages printed in the game's browns on a colour printer, or
     as mud on a mono one. The whole reset was dead from the day the skin
     shipped. Same reason the un-gated skin rules for main, .panel/.card and the
     body texture are turned off explicitly below. */
  :root,
  :root[data-skin="tio"],
  :root[data-skin="tio"][data-theme="dark"],
  :root[data-skin="tio"][data-theme="light"] {
    --ground: #fff; --sunken: #fff; --raised: #fff; --raised-2: #fff;
    --edge: #bbb; --edge-soft: #ddd;
    --ink: #000; --ink-2: #333; --ink-3: #555;
    --link: #000;
  }
  body { background: #fff; color: #000; font-size: 11pt; }
  [data-skin="tio"] body { background-image: none; }
  [data-skin="tio"] main { background: none; border: 0; box-shadow: none; }
  [data-skin="tio"] .panel,
  [data-skin="tio"] .card { background: none; box-shadow: none; }
  .topbar, .tray, .pick, footer, .toc, .btn, .edit, .section-empty, .notice { display: none !important; }
  main { max-width: none; padding: 0; }
  .article { display: block; }
  .infobox { position: static; break-inside: avoid; margin-bottom: 12pt; page-break-inside: avoid; }
  /* Paper does not scroll. The viewport cap that makes a tall rail reachable
     on screen would print only the part that happened to be visible, so it is
     lifted here — the whole box goes on the page and breaks naturally. */
  .infobox, .rail { max-height: none; overflow: visible; }
  .rail { position: static; }
  .panel { border: 1px solid #bbb; break-inside: avoid; page-break-inside: avoid; }
  a { color: #000; text-decoration: none; }
  a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 8pt; color: #555; }
  .bar, .bar-track { display: none; }

  /* Charts do print — they're the reason someone prints a stat page. But
     colour can't carry meaning on a mono printer, so curves go to ink and
     the dashed "inherited" stretch keeps doing the work colour was doing. */
  .chart { break-inside: avoid; page-break-inside: avoid; }
  .curve, .curve.p-probable, .curve.p-unknown { stroke: #000; }
  .curve-dot, .curve-dot.p-probable, .curve-dot.p-unknown { fill: #000; }
  .curve-area { fill: #eee; }
  .hbar, .hbar.p-unknown { fill: #666; }
  .hmark { stroke: #000; }
  .chart .lbl { fill: #333; }
  .chart .axis, .chart .gridln { stroke: #bbb; }
  h1, h2 { page-break-after: avoid; }
  table { page-break-inside: auto; }
  tr { page-break-inside: avoid; }

  /* Provenance survives without colour */
  .prov::before { display: none; }
  .prov { font-weight: 700; }
  .prov-verified::after { content: ' [datamined]'; }
  .prov-probable::after { content: ' [unconfirmed]'; }
  .prov-observed::after { content: ' [player-measured]'; }
  .prov-unknown::after  { content: ' [unidentified]'; }
  .prov-verified, .prov-probable, .prov-observed, .prov-unknown { color: #000; font-size: 8pt; }
}

/* ── Between the phone menu and a bar that fits ───────────────────────────
   The nav needs 359px of links at its tightest. Above about 780px the flex
   layout gives it that; below 640px the .nav-toggle menu takes the links away
   entirely. In the band between it needs 359 and is offered less, and because
   it is no longer allowed to wrap (see .topbar nav) or to scroll — scrolling
   is what clipped the Explore menu — the overflow went sideways onto the PAGE.
   Tightening the links is the cheap way out. It keeps the bar one row, keeps
   the dropdowns unclipped, and does not drag the phone treatment (a full-width
   Menu button, a wrapping bar, rails that stop sticking) up into widths that
   do not need it. */
@media (max-width: 800px) {
  .topbar { gap: var(--s3); }
  .topbar nav a,
  .topbar nav details.navdrop > summary { padding-left: 7px; padding-right: 7px; }
}

@media (max-width: 640px) {
  :root { --t-display: 1.625rem; --t-title: 1.1875rem; }
  main { padding: var(--s5) var(--s4) var(--s7); }
  .topbar { height: auto; padding: var(--s2) var(--s4); flex-wrap: wrap; gap: var(--s2); }
  .me { margin-left: auto; }

  /* Below 640px the seven-plus links in .topbar nav no longer fit on one
     row and used to wrap onto a second, permanently pushing every page's
     content down by roughly a quarter of a phone screen before any of it
     was visible. The .nav-toggle <details> sitting beside nav (see
     layout.ts, and the long comment on .nav-toggle-btn above for why it
     is a sibling rather than a wrapper) turns that into a closed-by-default
     "Menu" disclosure instead, so a visitor sees the page first and opens
     the nav only if they need it — the same trade the Explore/Staff
     dropdowns already make, just for the whole bar. */
  .nav-toggle { display: block; order: 3; width: 100%; }
  .nav-toggle-btn {
    display: flex; align-items: center; justify-content: space-between;
    list-style: none; cursor: pointer; user-select: none;
    padding: 8px var(--s3); margin-top: var(--s1);
    border-radius: var(--r-sm); border: 1px solid var(--edge);
    color: var(--ink-2); font-size: var(--t-small); font-weight: 600;
  }
  .nav-toggle-btn::-webkit-details-marker { display: none; }
  .nav-toggle-btn:hover { background: var(--raised-2); color: var(--ink); }
  .nav-toggle-btn::after {
    content: ''; width: 0; height: 0; flex: none;
    border: 4px solid transparent; border-top-color: currentColor; border-bottom: 0;
    opacity: .65; transition: transform .12s ease;
  }
  .nav-toggle[open] .nav-toggle-btn { border-color: var(--ink-3); color: var(--ink); }
  .nav-toggle[open] .nav-toggle-btn::after { transform: scaleY(-1); }
  /* Closed by default (no `open` attribute in the markup): hide nav until
     the toggle says otherwise. Higher specificity than the unconditional
     `.topbar nav { display: flex }` rule near the top of this file, which
     is what keeps that same rule showing nav outside this breakpoint. */
  .topbar .nav-toggle:not([open]) ~ nav { display: none; }
  .nav-toggle[open] ~ nav {
    order: 4; flex-direction: column; align-items: stretch; width: 100%;
    gap: 1px; margin-top: var(--s1); padding-bottom: var(--s1);
    /* A column stacks whatever flex-wrap says, but the base rule's horizontal
       scroll has no business on a full-width menu panel. */
    overflow: visible;
  }
  .nav-toggle[open] ~ nav a,
  .nav-toggle[open] ~ nav details.navdrop { width: 100%; }
  .nav-toggle[open] ~ nav details.navdrop > summary { width: 100%; }
  .cards { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
  .grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
  th, td { padding: var(--s2) var(--s3); }
  /* The field gets a row of its own; the buttons share the next one rather
     than each becoming another full-width bar (Database had three). */
  .search { flex-wrap: wrap; }
  .search > :not(.btn) { flex: 1 1 100%; }
  .infobox-art { min-height: 140px; padding: var(--s4); }
  .infobox-art img { max-width: 116px; max-height: 116px; }

  /* Magnitude bars are decorative — the rank beside them carries the
     information. On a phone they only push the table off-screen. */
  .standings th:last-child, .standings td:last-child { display: none; }
  .standings td, .standings th { white-space: nowrap; }

  /* /glossary and /sitemap — see the .table-stack comment further up this
     file for why these need to stack rather than scroll. thead is dropped
     because its headings become redundant once every cell carries its own
     label (the glossary's is the visible <th> already in each row; the
     sitemap's comes from `data-label`, since its header sits in a real
     <thead> instead). */
  .table-stack thead { display: none; }
  .table-stack, .table-stack tbody, .table-stack tr,
  .table-stack th, .table-stack td { display: block; width: auto !important; }
  .table-stack tr {
    padding: var(--s3) 0; border-bottom: 1px solid var(--edge-soft);
  }
  .table-stack tr:first-child { padding-top: 0; }
  .table-stack tr:last-child { border-bottom: 0; padding-bottom: 0; }
  .table-stack th, .table-stack td { border-bottom: 0; padding: 2px 0; }
  .table-stack td[data-label]::before {
    content: attr(data-label); display: block; margin-top: var(--s2);
    font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .07em;
    color: var(--ink-3); font-weight: 650;
  }
}

/* ── Per-feature styles ───────────────────────────────────────────────────
   Each block below belongs to one page. They are appended here rather than
   filed by topic so that concurrent builds each own a region and never edit
   the same lines. Fold them into the sections above once things settle. */

/* ── Where one entity sits in a stat's distribution ──────────────────────
   An entity page could always say Bear's health is 15. The strip beside the
   number is what says whether 15 is a big one: the whole population of that
   stat drawn at sparkline scale, lowest on the left, highest on the right,
   with a tick under the median and a pin where this entity lands.

   The pin carries provenance like every other number on this site. A position
   read off an unconfirmed label is no more confirmed than the label is, so it
   takes the same colour the growth curves take, and the confirmed green is
   never lent to a probable reading. */

.dist { width: 150px; max-width: 100%; height: auto; display: block; }
/* The population: bars once there are enough values to bin honestly, one mark
   per value when there aren't. Recessive — the pin is the subject. */
.dist-bar    { fill: var(--accent-dim); }
.dist-tick   { stroke: var(--ink-3); stroke-width: 1; opacity: .55; }
.dist-median { stroke: var(--ink-3); stroke-width: 1; }
.dist-pin              { stroke: var(--verified); stroke-width: 1.5; }
.dist-pin.p-probable   { stroke: var(--probable); }
.dist-pin.p-unknown    { stroke: var(--unknown); }
.dist-dot              { fill: var(--verified); }
.dist-dot.p-probable   { fill: var(--probable); }
.dist-dot.p-unknown    { fill: var(--unknown); }

.dist-rank { font-size: var(--t-small); color: var(--ink-2); margin-top: 3px; }
.dist-note { font-size: var(--t-micro); color: var(--ink-3); line-height: 1.5; max-width: 44ch; }

/* A stats table that carries a distribution column. `.stats-table` styles its
   last cell as the value — mono, heavy — which with a third column would land
   on the sentence instead, so the value keeps that treatment by name. */
.stats-table.dist-table td:first-child { width: 26%; }
.dist-table td:last-child { font-family: var(--sans); font-weight: 400; }
.dist-table td.dist-val {
  font-family: var(--mono); font-variant-numeric: tabular-nums;
  font-weight: 650; color: var(--ink); width: 96px;
}
.dist-table td.dist-cell { min-width: 178px; }

@media (max-width: 640px) {
  /* The rank and the population sentence carry the information; the strip is
     the picture of it. On a phone the picture is what has to give. */
  .dist { display: none; }
  .dist-table td.dist-cell { min-width: 0; }
  .stats-table.dist-table td:first-child { width: auto; }
}

@media print {
  /* Colour can't carry the pin on a mono printer, so it goes to ink and stays
     the heaviest mark on the strip. */
  .dist-bar { fill: #999; }
  .dist-tick, .dist-median { stroke: #666; }
  .dist-pin, .dist-pin.p-probable, .dist-pin.p-unknown { stroke: #000; stroke-width: 1.8; }
  .dist-dot, .dist-dot.p-probable, .dist-dot.p-unknown { fill: #000; }
}

/* ── /history ────────────────────────────────────────────────────────
   The forward-accruing stat record. Two chart idioms and one hero panel.

   The colour rule is the site's usual one, applied to TIME rather than to a
   value: a mark drawn from something the client was observed to say is
   --verified, and a mark drawn from something we reconstructed by rule is
   --probable and dashed. That is the same solid/dashed distinction the growth
   curves use for measured versus carried-forward stats, and it has to hold
   here too, because "we looked" and "we inferred" are different claims about
   a moment. */

/* The real-time ribbon is 960×74 in a container up to 1300px wide, so it is
   scaled UP rather than down and its 9px chart labels arrive at roughly 12px.
   Setting them smaller here keeps the date row at the same optical weight as
   the axis labels on every other chart, which are drawn at 1:1. */
.hist-ribbon .lbl { font-size: 7.5px; }

.hist-run { stroke: var(--verified); stroke-width: 2; }
.hist-run-derived { stroke: var(--probable); stroke-dasharray: 3 3; }

/* Time nobody looked at. Recessive on purpose — it is context for the ticks,
   not a finding of its own. */
.hist-gap { fill: color-mix(in srgb, var(--unknown) 16%, transparent); }
.hist-day { stroke: var(--edge); stroke-width: 1; stroke-dasharray: 2 4; }

/* Coverage bars: series a run actually read. */
.hist-bar { fill: var(--accent-dim); }
.hist-bar-derived { fill: color-mix(in srgb, var(--probable) 40%, transparent); }
/* Cumulative reach — every series the record has ever seen. A step, not a
   curve: coverage jumps when a run introduces a series, it does not glide. */
.hist-cum { stroke: var(--ink-3); stroke-width: 1.5; stroke-dasharray: 4 3; }

/* Legend for the ribbon. Each swatch is the mark it explains, so the key
   cannot drift away from the chart's own colours. */
.hist-key {
  display: flex; flex-wrap: wrap; gap: var(--s2) var(--s4);
  margin-top: var(--s2); font-size: var(--t-micro); color: var(--ink-3);
}
.hist-key-item { display: inline-flex; align-items: center; gap: 6px; }
.hist-key-item::before {
  content: ''; width: 12px; height: 3px; border-radius: 2px; flex: none;
}
.hist-key-run::before     { background: var(--verified); }
.hist-key-derived::before { background: var(--probable); }
.hist-key-gap::before     { background: color-mix(in srgb, var(--unknown) 45%, transparent);
                            height: 10px; border-radius: 2px; }

/* "Nothing has moved" is the page's central finding, so it is set as a
   finding rather than as an apology. The accent rule says the panel is a
   result; the sentence under it carries the number. */
.hist-flat { border-left: 2px solid var(--verified); }
.hist-flat-line {
  font-family: var(--mono); font-size: var(--t-lead); font-weight: 650;
  color: var(--ink); margin: var(--s2) 0 var(--s3);
}

/* The field and entity tables run to 60 and 154 rows. Capping them keeps the
   page navigable without hiding anything behind a control that would need
   JavaScript to open. */
.hist-scroll { max-height: 560px; overflow-y: auto; }
/* `/history` uses `.hist-scroll` next to `.panel.tight.table-scroll` (the
   same three classes the overflow-x fix above is for), and that combination
   sets `overflow-y: hidden` at three-class specificity — one rank above
   `.hist-scroll`'s one class. So the capped panel above had no scrollbar:
   everything past 560px was clipped and unreachable, the same invisible bug
   as the overflow-x one, just on the vertical axis this time. Same repair,
   named for the actual combination in use. */
.panel.tight.table-scroll.hist-scroll { overflow-y: auto; }

/* A list-shaped value is a whole array in one cell — shown truncated, never
   summarised into a count we would have had to guess at. */
.hist-list {
  font-family: var(--mono); font-size: var(--t-micro); color: var(--ink-2);
  word-break: break-all;
}

/* /matrix: 153 rows is too tall to scan without a cap, same fix and same
   specificity reason as .hist-scroll above — this class needs the identical
   override or `.panel.tight.table-scroll` wins and the cap clips instead of
   scrolling. The entity name is the only thing that says which row is which
   once you've scrolled sideways past a dozen stat columns, so it gets the
   same sticky-first-column treatment /compare's .cmp-tbl uses. */
.matrix-scroll { max-height: 640px; overflow-y: auto; }
.panel.tight.table-scroll.matrix-scroll { overflow-y: auto; }
.matrix-tbl th:first-child,
.matrix-tbl td:first-child { position: sticky; left: 0; }
.matrix-tbl th:first-child { z-index: 2; min-width: 150px; }
.matrix-tbl td:first-child { z-index: 1; background: var(--raised); }
.matrix-tbl tbody tr:hover td:first-child { background: var(--raised-2); }

@media (max-width: 640px) {
  .hist-scroll { max-height: 420px; }
}

/* ── /updates: the change index as a timeline ──────────────────────────
   One chart carries the whole record: stacked columns are change lines per
   month, the bricks under the axis are one patch each, and a tinted band is a
   month in which nothing shipped. Direction colours come from the same map
   the mix bars in the tables use, so the two never disagree. */

/* A month with no patch. Deliberately a band behind the plot rather than a
   zero-height bar — a bar of height 0 is indistinguishable from a very small
   one, and "nothing shipped" is not a small number. */
.ci-gap { fill: var(--edge); opacity: .65; }

/* One patch. Neutral ink, because a patch has no direction of its own — the
   direction lives in the column above it. */
.ci-patch { fill: var(--ink-3); }
a:hover .ci-patch { fill: var(--accent); }
a:focus-visible .ci-patch { fill: var(--accent); }

/* Year labels are links to the year filter, so they have to read as links
   without turning the axis into a row of blue. `.chart .lbl` sets the fill,
   so these need the same two-class weight to override it. */
.chart .ci-yr { fill: var(--ink-2); }
.chart a:hover .ci-yr { fill: var(--accent); }
.chart .ci-yr.on { fill: var(--ink); font-weight: 700; }

/* Legend. Doubles as a total-by-direction readout, so the colours are
   labelled with the counts they stand for rather than with a colour name. */
.ci-key {
  display: flex; flex-wrap: wrap; gap: var(--s2) var(--s4);
  margin-top: var(--s3); font-size: var(--t-micro);
  text-transform: uppercase; letter-spacing: .07em; color: var(--ink-3);
}
.ci-key-item { display: inline-flex; align-items: center; gap: 6px; }
.ci-sw { width: 9px; height: 9px; border-radius: 2px; flex: none; }
.ci-sw-gap { background: var(--edge); }
.ci-sw-patch { background: var(--ink-3); height: 4px; }

/* Seventy months squeezed into a phone screen is a smear. The chart keeps a
   readable minimum width and its wrapper scrolls, which is the no-JavaScript
   equivalent of a responsive chart — and the tables underneath carry every
   number for anyone who would rather not scroll at all. */
@media (max-width: 760px) {
  .ci-chart { min-width: 700px; }
}

@media print {
  /* Colour can't carry the direction split on a mono printer, but the shape
     of the record still prints usefully. */
  .ci-gap { fill: #eee; opacity: 1; }
  .ci-patch { fill: #000; }
  .chart .ci-yr, .chart .ci-yr.on { fill: #333; }
  .ci-chart { min-width: 0; }
}

/* ── The wide tables on a phone ────────────────────────────────────────

/* /compare is a matrix: the row label is the only thing that says what a
   column of numbers means, so it is the one cell that must not scroll away.
   Sticky needs an opaque background or the scrolled columns show through, and
   it has to keep up with the row hover state to avoid a two-tone row. */
.cmp-tbl th:first-child,
.cmp-tbl td:first-child { position: sticky; left: 0; }
.cmp-tbl th:first-child { z-index: 2; min-width: 150px; }
.cmp-tbl td:first-child { z-index: 1; background: var(--raised); }
.cmp-tbl tbody tr:hover td:first-child { background: var(--raised-2); }

@media (max-width: 640px) {
  /* /ranking joins the tables that drop their magnitude bar on a phone — the
     value and the rank are both already in their own columns, so the bar is
     the only cell carrying nothing the reader would lose. The rule for that
     lives with `.standings` above; this table opts in by wearing the class.

     What `.standings` also does is set every cell to nowrap, which is right
     for a name or a number and wrong for the stage note, the one cell holding
     a short sentence. Overriding it needs the extra class to outweigh
     `.standings td`, not `!important`. */
  .rank-tbl td:nth-child(4) { white-space: normal; }

  /* A sticky column is only worth its width when there is width to spare. */
  .cmp-tbl th:first-child { min-width: 104px; }
}

/* ── Field identification workbench (/fields, /fields/:key) ──────────────
   The index is a triage table: eighty rows, scanned for the one worth an
   hour. So the two columns that decide that — what naming a field would
   reach, and what shape its numbers have — are the ones given room, and
   everything else is kept quiet enough to skim past. */

/* Reach is a number stacked over its own bar, so the column reads as one
   quantity rather than as two. Fixed width keeps the bars comparable down
   the column; a bar that resizes per row measures nothing. */
.fieldlist td { vertical-align: middle; }
.fieldlist .bar-track { width: 92px; }

/* The shape class is a controlled vocabulary, not prose — it gets the same
   treatment as the provenance labels so it reads as a category. It is
   deliberately NOT coloured: a shape is the site's own reading of the
   numbers, and the four provenance colours are reserved for claims about
   how well a value is known. */
.shape-cell {
  font-family: var(--mono); font-size: var(--t-micro);
  text-transform: uppercase; letter-spacing: .05em;
  color: var(--ink-3); white-space: nowrap;
}

/* The case file's section headings carry a provenance chip on the same
   baseline as the heading, which .section-head already aligns. Pushing the
   chip to the right edge keeps the tier legible as a column down the page,
   so a reader can see at a glance which panels are arithmetic over stored
   values and which are the site's own inference. */
.section-head .prov { margin-left: auto; }

/* A tiered stat gets both rules at the same weight — top from .stat, left
   from .tier, same colour on each courtesy of .tier-verified etc setting
   both border-left-color and border-top-color. Reads as an open corner
   bracket around the number instead of a closed box. */
.stat.tier {
  border-left-width: 3px; border-top-width: 3px;
  padding-left: calc(var(--s4) - 3px + var(--s1));
}

/* ── Review dashboard ──────────────────────────────────────────────────
   The review queue spends most of its life empty, and an empty queue is the
   normal state of a site where nothing publishes without a human. So the
   empty state gets the typographic care usually spent on the full one: it is
   left-aligned prose with a heading and a list of routes, not a centred grey
   shrug like .empty. A reader should leave it knowing what to go and do. */

.qempty { padding: var(--s4) var(--s2) var(--s3); max-width: 68ch; }
.qempty p { margin: 0 0 var(--s3); color: var(--ink-2); font-size: var(--t-small); }
.qempty p:last-child { margin-bottom: 0; }
.qempty-hed { color: var(--ink) !important; font-weight: 650; font-size: var(--t-body) !important; }
.qfeeds { margin: 0 0 var(--s3); padding-left: var(--s5); color: var(--ink-2); font-size: var(--t-small); }
.qfeeds li { margin-bottom: var(--s2); }

/* One waiting item. Was a run of inline styles on three elements; a rule so
   the divider colour is defined in one place — an earlier build spelled this
   token wrongly here, and because an invalid custom property is dropped
   silently the dividers drew as bright text-coloured rules until someone
   happened to look. */
.qitem { border-bottom: 1px solid var(--edge-soft); padding: var(--s3) 0; }
.qitem:last-child { border-bottom: 0; }
.qitem-head { display: flex; justify-content: space-between; gap: var(--s4); flex-wrap: wrap; }
.qitem-main { flex: 1; min-width: 260px; }

/* Age leads the row rather than trailing it. Staleness is the signal that
   decides what to open next, and a number you have to scan right to find is a
   number you will not use. */
.qage {
  font-family: var(--mono); font-size: var(--t-micro); color: var(--ink-3);
  border: 1px solid var(--edge); border-radius: var(--r-sm);
  padding: 1px 6px; margin-right: var(--s2); white-space: nowrap;
}

.qact { display: flex; gap: var(--s2); align-items: flex-start; flex-wrap: wrap; }
.qact input[type=text] { width: 200px; padding: 5px var(--s2); font-size: var(--t-small); }

/* The two queues decided on this page, marked inside a census that also lists
   four decided elsewhere. Without this, "here" is a word in a chip and the
   table reads as six equal links away from the page you are on. */
.qrow-here td:first-child { box-shadow: inset 2px 0 0 var(--accent); }

/* An age bucket past the week mark on the queue-age chart.
   Deliberately NOT one of the four provenance colours. Every other colour on
   this site says how much a number is trusted, and how long a row has sat in a
   queue says nothing about that — a month-old datamined value is still
   datamined. So staleness borrows the change-index's downward red, which is
   already the site's word for "this went the wrong way". */
.hbar-stale { fill: var(--down); }
/* This slot sits after the print block, so it would otherwise print red on a
   mono printer. Stale bars go darker than the rest instead — the same trick
   the growth curves use, where the distinction survives without colour. */
@media print { .hbar-stale { fill: #333; } }

.qnote-head { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; margin-bottom: var(--s2); }
.qnote {
  border-left: 2px solid var(--edge); padding-left: var(--s3);
  color: var(--ink-2); font-size: var(--t-small); white-space: pre-wrap; word-break: break-word;
}

@media (max-width: 640px) {
  /* The note field and its two buttons wrap onto their own line below the
     item rather than squeezing the statement into a column of one word. */
  .qact { width: 100%; }
  .qact input[type=text] { width: 100%; }
}

/* ── Database page: toolbar, stat cards, data table ───────────────────────
   Every control here is a link, not a button with a handler: the site runs
   under script-src 'none', so sorting, filtering and switching view all go
   through the query string and a page load. */

.toolbar {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--s3) var(--s5); margin: var(--s4) 0 var(--s3);
  padding-bottom: var(--s3); border-bottom: 1px solid var(--edge-soft);
}
.tb-right { display: flex; flex-wrap: wrap; gap: var(--s3) var(--s5); margin-left: auto; }
.tb-group { display: flex; align-items: center; gap: var(--s1); flex-wrap: wrap; }
.tb-label {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .07em;
  color: var(--ink-3); margin-right: var(--s2);
}
.tb-btn {
  position: relative; overflow: hidden;
  display: inline-flex; align-items: center; gap: 5px;
  padding: 5px 11px; border-radius: 999px;
  font-size: var(--t-small); color: var(--ink-2);
  border: 1px solid transparent; white-space: nowrap;
  transition: background .12s ease, color .12s ease;
}
.tb-btn::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,.25) 50%, transparent 65%);
  transform: translateX(-160%) skewX(-12deg);
}
.tb-btn:hover::after { animation: tt-sweep .5s ease; }
.tb-btn:hover { background: var(--raised); color: var(--ink); text-decoration: none; }
.tb-btn.on {
  background: var(--raised-2); border-color: var(--edge);
  color: var(--ink); font-weight: 620;
}
.tb-btn .faint { color: var(--ink-3); font-variant-numeric: tabular-nums; }
.sarr { color: var(--accent); font-weight: 700; display: inline-block; }
table.db th a:hover .sarr { animation: tt-arrow-bounce .4s ease; }
@keyframes tt-arrow-bounce {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-3px); }
}
.resultline { margin: 0 0 var(--s4); }

/* Cards carrying a stat block: the head is the identity, the list is the data */
.statcards { grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); }
.statcards .card { flex-direction: column; align-items: stretch; gap: var(--s3); }
/* The card's gap already spaces its lines; a paragraph's own margin on top
   of it left tower cards mostly air. */
.statcards .card > p { margin: 0; }
.card-head { display: flex; align-items: center; gap: var(--s3); min-width: 0; }
.card-head .nm { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Three mini stat cards per creature card, same idea as .stat elsewhere on
   the site: mono number, micro label, a rule instead of a box. Colour here
   is magnitudeTint(frac) — this creature's value's own position between
   the lowest and highest anything in the database ships for that stat —
   which is the same number the old bar-track used to spend a whole row
   drawing as a width; this makes it a colour instead, so 3 stats fit in the
   width a table cell would need for one. */
.statlist { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--s2); }
.statmini { border-top: 2px solid var(--edge); padding-top: var(--s1); min-width: 0; }
.statmini .v {
  font-family: var(--mono); font-variant-numeric: tabular-nums;
  font-size: var(--t-body); font-weight: 700; color: var(--ink-2);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.statmini .k {
  font-size: var(--t-micro); color: var(--ink-3); margin-top: 2px;
  text-transform: uppercase; letter-spacing: .05em;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The column being sorted on is the reason the reader is here — say so */
.statmini.lit .v { color: var(--ink); }
.statmini.lit .k { color: var(--ink-2); }
.gap { color: var(--ink-3); }

/* Table view */
.tablewrap { overflow-x: auto; margin-bottom: var(--s5); }
table.db { font-size: var(--t-small); }
table.db th { white-space: nowrap; }
table.db th a { color: var(--ink-2); display: inline-flex; align-items: center; gap: 4px; }
table.db th a:hover { color: var(--ink); text-decoration: none; }
table.db tbody tr { position: relative; transition: background .12s ease; }
table.db tbody tr::before {
  content: ''; position: absolute; left: 0; top: 1px; bottom: 1px; width: 3px;
  background: var(--accent); transform: scaleY(0); transform-origin: center;
  transition: transform .16s ease;
}
table.db tbody tr:hover::before { transform: scaleY(1); }
table.db tbody tr:hover { background: var(--raised); }
table.db .c-pick { width: 30px; padding-right: 0; }
table.db .c-name a { display: flex; align-items: center; gap: var(--s2); font-weight: 600; }
table.db .c-stat { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
table.db .c-stat .bar-track {
  display: block; height: 3px; margin-top: 4px; margin-left: auto; max-width: 84px;
}
table.db .c-stat .bar { height: 3px; display: block; }
/* The compare picker's table has a column per stat, more of them than any
   other .db table — .mini shrinks the bar further so that many columns don't
   read as a wall of bars. */
table.db .c-stat .bar-track.mini { height: 2px; max-width: 56px; }
table.db .c-stat .bar-track.mini .bar { height: 2px; }
.row-art {
  width: 26px; height: 26px; border-radius: 5px; flex: none;
  background: var(--raised-2); overflow: hidden;
}
.row-art img { width: 100%; height: 100%; object-fit: contain; }
/* In a table the checkbox is a real column, so it is always visible — the
   hover-to-reveal trick that works on cards would leave an empty cell. */
.pick.tpick { position: static; opacity: 1; }
/* The header box that ticks every row (newsroom bulk archive). */
.pick-all { width: 18px; height: 18px; margin: 0; cursor: pointer; accent-color: var(--accent); }

.notes {
  margin-top: var(--s5); padding-top: var(--s4); border-top: 1px solid var(--edge-soft);
  color: var(--ink-3); font-size: var(--t-small); max-width: 68ch;
  display: flex; flex-direction: column; gap: var(--s2);
}
.notes p { margin: 0; }
.vh {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap;
}

@media (max-width: 640px) {
  .toolbar { gap: var(--s2) var(--s3); }
  .tb-right { margin-left: 0; }
  .statcards { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
}

/* "Random →" wrapped onto a second line and, because the search row is a
   stretch flex, dragged the Search button to 53px tall with it. */
.search .btn { white-space: nowrap; }
/* …and once it stopped wrapping it SHRANK instead: flex-shrink took it to
   80px around 93px of text, clipping the arrow, and as a block box its label
   sat at the top of the stretched row. Never shrink; centre the label. */
.search .btn {
  flex: none;
  display: inline-flex; align-items: center; justify-content: center;
}

/* An entry with none of the card stats shows what it is instead of three
   dashes. Same voice as the stat labels it replaces. */
.card-kind {
  font-size: var(--t-micro); color: var(--ink-3);
  text-transform: uppercase; letter-spacing: .05em;
  border-top: 2px solid var(--edge); padding-top: var(--s1);
}
@media (max-width: 640px) {
  .search .btn { flex: 1 1 0; }
}

/* The compare checkbox is revealed by :hover, which a touch device never
   fires — so on a phone the checkbox was permanently invisible and the whole
   compare feature unreachable. Show it outright where there is no pointer. */
@media (hover: none) {
  /* :not(.tpick) makes explicit what used to be true only by accident of
     specificity: .pick.tpick (table view) is already opacity:1 unconditionally
     and must stay that way, so this touch fallback — for the card view's
     hover-revealed .pick — is scoped off it rather than merely outranked. */
  .pick:not(.tpick) { opacity: .5; }
  .card-wrap:has(.pick:checked) .pick { opacity: 1; }
}

/* ── The jigsaw gate ──────────────────────────────────────────────────────
   Every tile is a submit button: there is no JavaScript on this site, so a
   tap is a POST and the server does the swapping. noob.js makes that instant
   where it is allowed to run, but nothing below depends on it. */

/* The card. Wider than the default panel because the puzzle is the content —
   at the old width the grid had barely more room than the paragraph above it,
   and the page read as a form with a picture in it rather than a small game. */
.noob { width: min(400px, 100%); margin-inline: auto; }

/* HIERARCHY. The old version put the whole instruction in one dim line, so
   the thing you are meant to DO and the thing you are meant to KNOW had the
   same weight. Split: the task reads first, the mechanic second. */
.noob-title { font-size: var(--t-title); margin: 0 0 var(--s2); }
.noob-ask {
  margin: 0 0 var(--s1); font-size: 1.02rem; font-weight: 560;
  color: var(--ink); line-height: 1.35;
}
.noob-ask strong { color: var(--accent); font-weight: 680; }
.noob-how { margin: 0 0 var(--s4); font-size: var(--t-small); color: var(--ink-3); }

/* The text challenge's equivalent of .noob-grid — no image, so the letters
   themselves have to carry the same "here's the puzzle" visual weight. */
.noob-scrambled {
  margin: 0 0 var(--s4); padding: var(--s4);
  background: var(--sunken); border: 1px solid var(--edge); border-radius: var(--r-sm);
  text-align: center; font-family: var(--mono); font-size: var(--t-title);
  font-weight: 700; letter-spacing: .3em; text-indent: .3em; /* re-center the trailing tracking */
}

/* The tile size is duplicated in auth.ts, which needs the number to compute
   background-size and background-position for each piece. If you change one,
   change the other or the picture will not line up. */
.noob-grid {
  display: grid; grid-template-columns: repeat(3, 76px);
  gap: 3px; justify-content: center;
}
button.noob-tile {
  width: 76px; height: 76px; padding: 0; border: 0; cursor: pointer; box-shadow: none;
  border-radius: 4px; background-repeat: no-repeat;
  background-color: var(--raised);
  outline: 2px solid transparent; outline-offset: -2px;
  /* Safari waits ~300ms on a tap in case a second one is coming, to decide
     whether you meant to zoom. On a puzzle solved in eight taps that delay is
     most of what made it feel like it was ignoring you. */
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  transition: outline-color .12s ease, transform .12s cubic-bezier(.2,.8,.3,1.2),
              box-shadow .12s ease;
}
/* Hover, only where hovering is a thing. A touch browser synthesises a hover
   on first tap; against a rule that moves the element, the first tap goes to
   hovering it and the second to pressing it — the other half of "I had to tap
   it twice". */
@media (hover: hover) {
  .noob-tile:hover { outline-color: var(--ink-3); transform: scale(1.04); z-index: 1; }
}
.noob-tile:focus-visible { outline-color: var(--link); }
/* Pressing a piece should feel like picking it up. */
.noob-tile:active { transform: scale(.96); }
/* The armed piece — the one waiting for a partner. Lifted and lit, so it is
   obvious which one you are holding. */
.noob-tile.armed {
  outline-color: var(--accent);
  transform: scale(1.07); z-index: 2;
  box-shadow: 0 0 0 1px var(--accent), 0 4px 14px rgb(0 0 0 / .45);
}
/* Applied by noob.js to both tiles as they exchange places. */
.noob-tile.swapped { animation: noob-swap .26s cubic-bezier(.2,.8,.3,1.2); }
@keyframes noob-swap {
  0%   { transform: scale(.9); filter: brightness(1.35); }
  60%  { transform: scale(1.05); }
  100% { transform: scale(1); filter: brightness(1); }
}
/* Solved: drop the seams so it reads as one picture again, and let it land. */
.noob-grid.done { gap: 0; animation: noob-solved .45s ease both; }
.noob-grid.done .noob-tile {
  border-radius: 0; cursor: default; outline-color: transparent;
  transform: none; box-shadow: none;
}
@keyframes noob-solved {
  0%   { transform: scale(.98); }
  55%  { transform: scale(1.03); }
  100% { transform: scale(1); }
}

/* Nobody asked for a light show while signing in. */
@media (prefers-reduced-motion: reduce) {
  .noob-tile, .noob-grid.done { transition: none; animation: none; }
  .noob-tile:hover, .noob-tile.armed, .noob-tile:active { transform: none; }
}

/* Falling copies of the puzzle art, pinned behind the panel. z-index: -1
   rather than reordering .authbox — the panel already paints above the
   page background at the default stacking level, so this only needs to
   sit below that, not above anything. */
.noob-rain {
  position: fixed; inset: 0; overflow: hidden; z-index: -1; pointer-events: none;
  /* Gives the tumble variant's rotateX something to foreshorten against —
     without it a 3D rotation just squashes flat instead of looking like it's
     turning through space. */
  perspective: 900px;
}
.noob-drop {
  position: absolute; top: -20%;
  /* Box aspect is set from the same 126x220 ratio in auth.ts, so the card
     fills it exactly — no crop, unlike the old cover-a-square version. */
  background-repeat: no-repeat; background-size: 100% 100%; border-radius: 6px;
  opacity: .18; filter: saturate(.7);
  backface-visibility: hidden;
  /* Eased rather than linear so motion settles into and out of each
     waypoint below instead of moving at one constant robotic speed. */
  animation-name: noob-fall-drift; animation-timing-function: ease-in-out; animation-iteration-count: infinite;
  /* Overridden per-element by auth.ts's inline style; these are just the
     fallback if that's ever missing. */
  --noob-rot: 0deg; --noob-drift: 0px;
}
/* A gentle S-curve rather than one stiff diagonal: wanders out to the drift
   point by the midpoint, then eases back off it, so the path bows instead
   of ruling a straight line. */
@keyframes noob-fall-drift {
  0%   { transform: translateY(-12vh) translateX(0) rotate(0deg); }
  45%  { transform: translateY(46vh) translateX(var(--noob-drift)) rotate(var(--noob-rot)); }
  100% { transform: translateY(118vh) translateX(calc(var(--noob-drift) * .55)) rotate(calc(var(--noob-rot) * .7)); }
}
/* A real pendulum swing: seven eased waypoints instead of the old five
   linear ones, so it reads as swaying like a leaf caught on the wind rather
   than zigzagging on rails. scaleX pinches at each extreme to suggest it's
   turning edge-on as it swings through, the way a falling card would. */
@keyframes noob-fall-sway {
  0%   { transform: translateY(-12vh) translateX(0) rotate(calc(var(--noob-rot) * -1)) scaleX(1); }
  17%  { transform: translateY(9vh) translateX(var(--noob-drift)) rotate(0deg) scaleX(.8); }
  34%  { transform: translateY(31vh) translateX(0) rotate(var(--noob-rot)) scaleX(1); }
  51%  { transform: translateY(53vh) translateX(calc(var(--noob-drift) * -1)) rotate(0deg) scaleX(.8); }
  68%  { transform: translateY(75vh) translateX(0) rotate(calc(var(--noob-rot) * -1)) scaleX(1); }
  85%  { transform: translateY(97vh) translateX(var(--noob-drift)) rotate(0deg) scaleX(.8); }
  100% { transform: translateY(118vh) translateX(0) rotate(var(--noob-rot)) scaleX(1); }
}
/* A genuine end-over-end tumble instead of a flat shrink-and-grow: rotateX
   flips it edge-on and back each pass — foreshortened by .noob-rain's
   perspective above, so it actually looks like it's turning through the
   air — while rotateZ keeps it spinning on the other axis at the same time. */
@keyframes noob-fall-tumble {
  0%   { transform: translateY(-12vh) translateX(0) rotateZ(0deg) rotateX(0deg); }
  25%  { transform: translateY(29vh) translateX(calc(var(--noob-drift) * .3)) rotateZ(var(--noob-rot)) rotateX(180deg); }
  50%  { transform: translateY(53vh) translateX(calc(var(--noob-drift) * .6)) rotateZ(calc(var(--noob-rot) * 2)) rotateX(360deg); }
  75%  { transform: translateY(78vh) translateX(calc(var(--noob-drift) * .8)) rotateZ(calc(var(--noob-rot) * 3)) rotateX(540deg); }
  100% { transform: translateY(118vh) translateX(var(--noob-drift)) rotateZ(calc(var(--noob-rot) * 4)) rotateX(720deg); }
}
@media (prefers-reduced-motion: reduce) {
  .noob-rain { display: none; }
}

/* SWAPS 0 — a counter, styled like one. It was a sentence ("0 swaps so far.")
   competing with the instruction above it for the same attention. */
.noob-hint {
  margin: var(--s3) 0 0; text-align: center; min-height: 1.4em;
  font-family: var(--mono); font-size: var(--t-micro);
  letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3);
}
.noob-hint b { color: var(--ink-2); font-weight: 700; }
.noob-done {
  margin: var(--s3) 0 0; font-size: var(--t-small); color: var(--accent);
  text-align: center; font-weight: 650;
}
.noob-actions { margin-top: var(--s4); display: flex; justify-content: center; }
.noob-actions .btn { margin: 0; }
/* Deliberately quieter than the puzzle: it is an escape hatch, not the task. */
/* .btn.noob-reroll, not just .noob-reroll: the button also carries .ghost,
   and .btn.ghost's own `color: var(--ink)` is the same 2-class specificity —
   source order alone was deciding, silently, until check:styles' shadow
   detector caught it. Matching specificity here, after .btn.ghost in the
   file, is what actually makes this dimmer color win. */
.btn.noob-reroll { color: var(--ink-3); }
.noob-reroll .spin { display: inline-block; transition: transform .35s ease; }
.btn.noob-reroll:hover { color: var(--ink-2); }
.noob-reroll:hover .spin { transform: rotate(-180deg); }
@media (prefers-reduced-motion: reduce) { .noob-reroll .spin { transition: none; } }

/* Filter row. Wraps, because four chips plus their counts do not fit a phone
   and a row that cannot wrap is a row that pushes the page sideways. */
.dfilters {
  display: flex; flex-wrap: wrap; gap: var(--s2);
  margin: var(--s4) 0;
}

/* ── Discord capture log ─────────────────────────────────────────────────
   Two hard rules here, both learned from this page overflowing its own
   viewport: every flex row wraps, and every text child gets min-width:0.
   A flex item's default min-width is auto, so one long unbroken string —
   an ad URL, a 19-digit snowflake — refuses to shrink and pushes the whole
   page sideways. */
   A conversation, not a table: the reply context has to sit visually above
   the message that answers it or the thread cannot be followed. */
.dlog { margin: 0; padding: 0; }
.dmsg {
  padding: .55rem .7rem;
  border-left: 2px solid transparent;
  border-bottom: 1px solid var(--edge-soft);
}
.dmsg:hover { background: var(--raised); }
@media (max-width: 640px) {
  .dwhen { margin-left: 0; }
  .dwho { max-width: 100%; }
}
/* The developer is the reason this page exists — findable by eye, without
   reading a single name. */
.dmsg-dev {
  border-left-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 6%, transparent);
}
.dhead {
  display: flex; gap: .4rem .55rem; align-items: baseline;
  flex-wrap: wrap; font-size: .88rem;
}
/* The name can be arbitrarily long — people put server tags in them. It
   truncates rather than shoving the timestamp off the row; the full value is
   on the title attribute, and the id it belongs to is not something anyone
   reads at a glance. */
.dwho {
  font-weight: 640; min-width: 0; max-width: min(28rem, 60%);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Pushed to the end of the row so it lines up down the page. */
.dwhen { margin-left: auto; color: var(--ink-3); font-size: .78rem; white-space: nowrap; }
/* pre-wrap keeps the line breaks people actually typed; anywhere is what
   stops a pasted URL from setting the page's minimum width. */
.dbody {
  margin-top: .2rem; font-size: .95rem; line-height: 1.45;
  white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0;
}
/* Quoted, indented and dimmer: it is somebody else's message, shown for
   context only, and must not read as part of the message below it. */
.dreply {
  font-size: .82rem;
  color: var(--ink-3);
  border-left: 2px solid var(--edge);
  padding-left: .55rem;
  margin-bottom: .3rem;
  /* One line, always. It is context, not content — if it wrapped it would
     compete with the message it belongs to. */
  min-width: 0; max-width: 100%;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dmsg { min-width: 0; }
.dlog, .dlog > .dmsg { max-width: 100%; }

/* ═══════════════════════════════════════════════════════════════════════
   THE ADMIN SPINE — one look for "something is wrong"

   Every admin surface reports faults, and before this each one reported them
   differently: a red panel here, the word "degraded" in a table cell there, a
   mono paragraph on /db/health. Same severity, three looks, none scannable
   against the other. These classes are the shared ones — src/web/views/
   problem.ts renders them and every staff page should reuse it rather than
   inventing a fourth red box.

   Colour follows the palette's existing semantics rather than adding to it:
   --down for broken, --probable for degrading, --accent for clear,
   --ink-3 for waiting (a queue is not a fault and must not read like one).
   ═══════════════════════════════════════════════════════════════════════ */

/* ── The one-line strip that goes under any admin <h1> ──────────────────
   The unprompted channel: someone who opened /drafts for an unrelated reason
   should still learn from this line that the stat tables have been empty
   since Tuesday. */
.statusbar {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--s2) var(--s3);
  padding: 10px var(--s4); border-radius: var(--r-sm);
  border: 1px solid var(--edge); background: var(--raised);
  font-size: var(--t-small); margin: 0 0 var(--s5);
}
.statusbar a { font-weight: 650; }
.statusbar-dot {
  width: 8px; height: 8px; border-radius: 50%; flex: none;
  background: var(--ink-3);
}
.statusbar-crit { border-color: #5C2430; background: rgba(251,113,133,.07); }
.statusbar-crit .statusbar-dot { background: var(--down); }
.statusbar-crit a { color: var(--down); }
.statusbar-warn { border-color: #5C4A1A; background: rgba(251,191,36,.07); }
.statusbar-warn .statusbar-dot { background: var(--probable); }
.statusbar-warn a { color: var(--probable); }
.statusbar-ok { border-color: var(--accent-dim); background: rgb(var(--accent-rgb) / .06); }
.statusbar-ok .statusbar-dot { background: var(--accent); }
.statusbar-ok a { color: var(--accent); }
/* The worst problem's own words, inline — a count alone ("2 broken") tells
   you to go looking, which is the behaviour this page exists to remove. */
.statusbar-lead { color: var(--ink); min-width: 0; }
.statusbar-when, .statusbar-extra { color: var(--ink-3); margin-left: auto; white-space: nowrap; }
.statusbar-extra { margin-left: 0; }

/* ── One problem ───────────────────────────────────────────────────────── */
.prob-list { display: flex; flex-direction: column; gap: var(--s3); margin-bottom: var(--s5); }
.prob {
  border: 1px solid var(--edge); border-left-width: 3px;
  border-radius: var(--r-sm); background: var(--raised);
  padding: var(--s4) var(--s5);
}
.prob-crit { border-left-color: var(--down); }
.prob-warn { border-left-color: var(--probable); }
.prob-wait { border-left-color: var(--ink-3); }

.prob-head { display: flex; align-items: baseline; gap: var(--s3); flex-wrap: wrap; }
.prob-title { font-size: var(--t-lead); margin: 0; line-height: 1.35; }
.prob-sev {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .08em;
  font-weight: 700; padding: 2px var(--s2); border-radius: 20px;
  border: 1px solid var(--edge); white-space: nowrap; flex: none;
}
.prob-sev-crit { color: var(--down); border-color: #5C2430; background: rgba(251,113,133,.1); }
.prob-sev-warn { color: var(--probable); border-color: #5C4A1A; background: rgba(251,191,36,.1); }
.prob-sev-wait { color: var(--ink-2); }
/* "accepted" reads as settled rather than alarming on purpose — the condition
   is understood and has no repair, so a warning colour would be a standing
   false alarm. It keeps a border so it still reads as a status chip. */
.prob-sev-ok { color: var(--up); border-color: #1E4A32; background: rgb(var(--accent-rgb) / .08); }

.prob-since { font-size: var(--t-small); color: var(--ink-2); margin: var(--s2) 0 0; }
.prob-since strong { font-family: var(--mono); font-weight: 600; color: var(--ink); }
/* An unknown start time is stated, never omitted — a missing line would let a
   reader assume the fault is new. */
.prob-since-unknown { color: var(--probable); }

.prob-affects { margin: var(--s3) 0 0; max-width: 78ch; }
.prob-detail {
  margin: var(--s3) 0 0; padding-left: var(--s4);
  font-size: var(--t-small); color: var(--ink-2);
}
.prob-detail li { margin: 2px 0; overflow-wrap: anywhere; }

.prob-fixes {
  margin-top: var(--s4); padding-top: var(--s3);
  border-top: 1px solid var(--edge-soft);
}
.prob-fixes-label {
  display: block; font-size: var(--t-micro); text-transform: uppercase;
  letter-spacing: .08em; color: var(--ink-3); margin-bottom: var(--s2);
}
.prob-fix { margin: var(--s2) 0; }
/* Selectable, wrapping terminal text. No copy button — the site ships no
   JavaScript, so the command has to be draggable instead. */
.prob-cmd {
  display: block; font-family: var(--mono); font-size: var(--t-small);
  background: var(--sunken); border: 1px solid var(--edge);
  border-radius: var(--r-sm); padding: var(--s2) var(--s3);
  overflow-wrap: anywhere; user-select: all; color: var(--ink);
}
.prob-fixlink { font-size: var(--t-small); font-weight: 600; }
.prob-fixnote { font-size: var(--t-small); color: var(--ink-2); }

.prob-foot {
  display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap;
  margin-top: var(--s3); font-size: var(--t-small);
}
/* Which check produced this verdict. Never invent a number: a problem that
   cannot name where it came from should look unfinished. */
.prob-src { color: var(--ink-3); font-family: var(--mono); font-size: var(--t-micro); margin-left: auto; }

/* An empty group states what was checked, never a bare "none". */
.prob-none {
  color: var(--ink-2); font-size: var(--t-small);
  border-left: 2px solid var(--accent-dim); padding-left: var(--s4);
  margin: 0 0 var(--s5);
}
/* h2 carries no bottom margin site-wide, so this note supplies the gap under
   the heading it explains. */
.prob-groupnote { font-size: var(--t-small); max-width: 78ch; margin: var(--s2) 0 var(--s4); }

/* ── The ledger: what ran and found nothing, what did not run ──────────── */
.checkledger { margin-bottom: var(--s5); }
.checkledger ul { list-style: none; padding: 0; margin: 0; }
.checkledger li {
  font-size: var(--t-small); color: var(--ink-2);
  padding: 5px 0; border-bottom: 1px solid var(--edge-soft);
}
.checkledger-id {
  font-family: var(--mono); font-size: var(--t-micro); color: var(--ink-3);
  display: inline-block; min-width: 15ch;
}
/* The passing half: evidence behind "nothing wrong", deliberately recessive
   — it is not the headline, it is what stops the headline being a guess. */
.checkledger-clean { border-left: 2px solid var(--accent-dim); padding-left: var(--s4); }
/* A skipped check reads amber, not grey: it is an unanswered question, and
   letting it look like a pass is the exact failure this ledger exists for. */
.checkledger-skipped { border-left: 2px solid var(--probable); padding-left: var(--s4); margin-top: var(--s3); }
.checkledger-skipped li { color: var(--probable); }

/* ── The staff directory ───────────────────────────────────────────────── */
.staffdir {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: var(--s4); margin-bottom: var(--s5);
}
.staffdir-sec { border-top: 2px solid var(--edge); padding-top: var(--s3); }
.staffdir-title {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .1em;
  color: var(--ink-3); margin: 0 0 var(--s1);
}
.staffdir-list { list-style: none; padding: 0; margin: 0; }
.staffdir-list li { margin-bottom: var(--s3); }
.staffdir-list a { font-weight: 650; }
.staffdir-what { display: block; font-size: var(--t-small); color: var(--ink-3); }
.staffdir-flag {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .06em;
  padding: 1px var(--s2); border-radius: 20px; margin-left: var(--s2);
  border: 1px solid var(--edge);
}
.staffdir-flag-crit { color: var(--down); border-color: #5C2430; }
.staffdir-flag-warn { color: var(--probable); border-color: #5C4A1A; }
.staffdir-flag-wait { color: var(--ink-3); }

/* ── "What uses this, and can it run" ───────────────────────────────────
   /keys and /youtube both have to answer the same shape of question, and
   before this neither could: a credential or a pipeline stage is CONFIGURED,
   which is a different claim from ENABLED. The Anthropic key is set and all
   nine features that spend it are switched off; /keys said "configured" and
   listed what it unlocks, which read as "these work".

   So each consumer states itself and why it is or isn't live. Green means it
   would really run if something asked; grey means deliberately off, which is
   NOT a fault and must not borrow the amber a real warning uses. */
.uses-head {
  font-size: var(--t-small); color: var(--ink-2);
  margin: var(--s4) 0 var(--s2);
}
.uses { list-style: none; padding: 0; margin: 0; }
.uses li {
  font-size: var(--t-small); padding: 5px 0 5px var(--s4);
  border-bottom: 1px solid var(--edge-soft); position: relative;
}
.uses li::before {
  content: ""; position: absolute; left: 0; top: 11px;
  width: 6px; height: 6px; border-radius: 50%;
}
.uses-on { color: var(--ink); }
.uses-on::before { background: var(--accent); }
/* Off is grey, never amber: a feature someone switched off is a state, and
   painting it as a warning is how real warnings get ignored. */
.uses-off { color: var(--ink-2); }
.uses-off::before { background: var(--ink-3); }
.uses-why {
  display: block; font-size: var(--t-micro); color: var(--ink-3);
}
/* The case the old page could not express at all. */
.uses-none {
  font-size: var(--t-small); color: var(--ink-2);
  border-left: 2px solid var(--edge); padding-left: var(--s4);
  margin: var(--s3) 0 0;
}

/* ── How a table is written ─────────────────────────────────────────────
   /db shows a row count and a delta, and both are unreadable without knowing
   whether the table accumulates or is rebuilt. stat_snapshots going up is the
   pipeline working; stat_stages going down is normal because it is rewritten
   each run; stat_snapshots going DOWN would mean something outside the
   pipeline deleted history. Same arrow, three meanings.

   Only `unknown` gets amber — it is the one that is an unanswered question
   rather than an answer. */
.wpat {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .06em;
  padding: 1px var(--s2); border-radius: 20px; white-space: nowrap;
  border: 1px solid var(--edge); color: var(--ink-2);
}
.wpat-append { color: var(--ink); border-color: var(--edge); }
.wpat-rebuilt { color: var(--ink-2); }
.wpat-upsert { color: var(--ink-2); }
.wpat-manual { color: var(--ink-3); }
.wpat-unknown { color: var(--probable); border-color: #5C4A1A; }
/* The evidence behind the verdict — a file:line, so it can be checked rather
   than trusted. Never shown without it. */
.wpat-why {
  display: block; font-size: var(--t-micro); color: var(--ink-3);
  font-family: var(--mono); margin-top: 2px; overflow-wrap: anywhere;
}
/* A delta that moved the wrong way for the table's own write pattern. */
.wpat-odd { color: var(--down); font-weight: 650; }

/* ── The table finder ───────────────────────────────────────────────────
   70 tables in one list is not something you read, it is something you search.
   No JavaScript: a GET form that filters server-side. */
.finder {
  display: flex; gap: var(--s2); flex-wrap: wrap; align-items: flex-end;
  margin-bottom: var(--s4);
}
.finder input[type="search"] {
  min-width: 240px; flex: 1; margin: 0;
}
.finder-count { font-size: var(--t-small); color: var(--ink-3); }

/* ── The desk strip ─────────────────────────────────────────────────────
   Five pages are one pipeline — /discord and /submit are how work arrives,
   /review and /drafts are where a human rules on it, /newsroom is where it is
   published — and each one used to describe only itself. This strip carries
   the same five-desk answer on all of them, marks where you are, and prints
   what each number counted so a zero reads as a measurement.

   Deliberately quieter than .statusbar: a backlog is not a fault, and the two
   sit one above the other on every one of these pages. */
.desks {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: var(--s2); margin: 0 0 var(--s2);
}
.desk {
  display: flex; flex-direction: column; gap: 2px;
  padding: var(--s3); border: 1px solid var(--edge); border-radius: var(--r-sm);
  background: var(--raised); color: inherit; text-decoration: none;
}
.desk:hover { border-color: var(--ink-3); }
/* Where you are, marked with the accent rather than a colour of its own. */
.desk-on { border-color: var(--accent-dim); background: rgb(var(--accent-rgb) / .05); }
.desk-top { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s2); }
.desk-l { font-weight: 650; }
.desk-n { font-family: var(--mono); font-size: var(--t-lead); font-weight: 650; }
/* Nothing waiting is grey, never green: a queue at zero is not a pass, and
   this project has been burned by badges that could only ever look good. */
.desk-zero { color: var(--ink-3); font-weight: 400; }
.desk-stage {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .08em;
  color: var(--ink-3);
}
.desk-what { font-size: var(--t-micro); color: var(--ink-2); line-height: 1.4; }
.desk-to { font-size: var(--t-micro); color: var(--ink-3); }

/* ── The facts a desk should say before its table does ──────────────────
   A wall of rows cannot say "that draft has sat for nine days" or "every
   published post cites nothing". This is where a page states those in
   sentences, above the table they were derived from. */
.deskfacts { list-style: none; padding: 0; margin: 0 0 var(--s5); max-width: 90ch; }
.deskfacts li {
  font-size: var(--t-small); color: var(--ink-2);
  padding: var(--s2) 0 var(--s2) var(--s4);
  border-left: 2px solid var(--edge); margin-bottom: 2px;
}
.deskfacts li strong { color: var(--ink); }

/* ═══════════════════════════════════════════════════════════════════════
   VERDICT CHIPS — a status word that is allowed to be neither good nor bad

   /db/pipeline, /db/freshness and /db/system all report the same kind of
   thing: a job or a table, and how it is doing against something it is
   measured by. Those pages each had their own inline-styled status word
   before, and one of them ("ok" from `lastRun ? 'ok' : 'never run'`) could
   not go red at all.

   The reason this is not .prob-sev from the admin spine: severity has three
   values, all of them about faults, and the important state here is a fourth
   — "nothing is watching this". A hand-run scrape with no schedule cannot be
   late and must not be painted green for it. That is what -idle is for, and
   giving it the same grey as a waiting queue is deliberate.

   Colours are the palette's existing semantics, no new ones: --down broken,
   --probable degrading, --accent clear, --ink-3 unwatched.
   ═══════════════════════════════════════════════════════════════════════ */
.vchip {
  display: inline-block; font-size: var(--t-micro); text-transform: uppercase;
  letter-spacing: .06em; font-weight: 700; padding: 2px var(--s2);
  border-radius: 20px; border: 1px solid var(--edge); white-space: nowrap;
}
.vchip-bad { color: var(--down); border-color: #5C2430; background: rgba(251,113,133,.1); }
.vchip-warn { color: var(--probable); border-color: #5C4A1A; background: rgba(251,191,36,.1); }
.vchip-ok { color: var(--accent); border-color: var(--accent-dim); background: rgb(var(--accent-rgb) / .08); }
/* Not a pass and not a fault: nothing is measuring this one. */
.vchip-idle { color: var(--ink-3); }

/* ── The row under a pipeline row ───────────────────────────────────────
   Evidence and the command that fixes it, attached to the source it belongs
   to rather than in a legend somewhere else on the page. A healthy source
   with nothing to say does not get one of these. */
.pipe-note > td {
  border-top: 0; padding-top: 0; padding-bottom: var(--s4);
  background: var(--sunken);
}
.pipe-detail {
  margin: 0; padding-left: var(--s4);
  font-size: var(--t-small); color: var(--ink-2);
}
.pipe-detail li { margin: 2px 0; overflow-wrap: anywhere; }
.pipe-links {
  display: flex; flex-wrap: wrap; gap: var(--s3);
  margin-top: var(--s2); font-size: var(--t-small);
}

/* ── Job scheduling controls ────────────────────────────────────────────
   An interval box and its Save button, sitting on one line inside a table
   cell. The site ships no JavaScript, so this is a real <form> per row and
   the layout has to work as one: `label` is display:block with top margin
   by default, which would push the control off its own row -- the label
   here is .sr-only and the field carries its own width, since the global
   input rule is width:100%. */
.jobctl { display: flex; gap: var(--s2); align-items: center; }
.jobctl-num {
  width: 84px; padding: 5px var(--s2);
  font-family: var(--mono); font-size: var(--t-small);
  font-variant-numeric: tabular-nums; text-align: right;
}
.jobctl .btn { margin: 0; }

/* ── Plugins ──────────────────────────────────────────────────────────── */


.plug-warn { border-left: 3px solid var(--probable); }
.plug-warn p { margin: var(--s2) 0 0; }

/* Allowed / Never, as two coloured columns rather than a table: the point is
   the contrast between them, and semantic colour is how the rest of this site
   already carries meaning. */


/* Three across on a wide screen, one on a phone. Cards are short: name, one
   sentence, and the chips that answer "what does it touch" — nothing unfolds
   under them any more (see pluginCard's removal note in routes/plugins.ts). */
.plug-grid {
  display: grid; gap: var(--s3); margin: var(--s3) 0 var(--s5);
  grid-template-columns: repeat(3, minmax(0, 1fr));
  /* `align-items: start` let every card be its own height, so a row of three
     was three different shapes decided by how long somebody's description
     happened to be. Stretch makes a row one height; the card's own layout
     below is what stops that turning into a ragged block of white space. */
  align-items: stretch;
}
@media (max-width: 1100px) { .plug-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px)  { .plug-grid { grid-template-columns: 1fr; } }

.plug-card {
  border: 1px solid var(--edge); border-radius: var(--r-sm);
  background: var(--raised); padding: var(--s4);
  /* FLEX COLUMN, not grid. The card was `display: grid; align-content: start`,
     and in a grid `margin-top: auto` on the last child pushes within its own
     row — it cannot claim the container's leftover space, so the chip row
     floated at a different height in every card. A flex column is the layout
     where that margin means what it reads as. */
  display: flex; flex-direction: column; gap: var(--s2);
  /* A grid item's default min-width is auto, i.e. min-content, and the
     min-content of a <pre> is its longest unwrapped line. Without this the
     card refuses to be narrower than a 200-character line of JavaScript and
     drags the whole page sideways when a script is expanded — measured at
     986px inside a 320px track. The track's own minmax(0, 1fr) does not help:
     that frees the TRACK, not the item sitting in it. */
  min-width: 0;
}
/* Same release one level down, since the card is itself a grid. */
.plug-card > * { min-width: 0; }
/* The like/donate bar is the card's floor. Pushing it down with auto margin
   means every card in a row ends on the same line whatever is above it, which
   is what makes a stretched row read as uniform rather than as padded.
   It was `.plug-card > .plug-chips` until the chips came off the card — a
   selector pinning an element that is no longer there, which check:styles
   cannot catch because .plug-chips still exists in the review queue. */
.plug-card > .socialbar { margin-top: auto; }
/* Two lines of description, the same on every card. A one-line blurb and a
   four-line blurb were the single biggest reason two cards looked unrelated.
   The full text is on the plugin's own page. */
.plug-card-blurb {
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden;
}
/* The folded price/terms editor that used to sit on the card is gone — it is
   a proper form on the plugin's own page now (.plug-editor, below), where it
   can edit the name and description too. */
/* Only rendered when a plugin's terms differ from the page-level default. */
.plug-terms { font-size: var(--t-small); }
.plug-card[hidden] { display: none; }
/* THE FULL-WIDTH-WHEN-OPEN RULE IS GONE.
   It read `.plug-card:has(details[open]) { grid-column: 1 / -1; }` and it was
   right when it was written: a card held a <details> containing the script's
   source, and a 200-character line of JavaScript squeezed into a third of the
   page is unreadable.
   That fold was removed — a reader no longer sees source at all — and the rule
   outlived it, matching ANY open <details> in a card. The tip control is one,
   so opening it made the whole card jump to the full width of the grid and
   shove every card after it. A rule keyed on a tag rather than on the thing it
   meant. Nothing in a card is a <details> now except the tip popover, and that
   is a dropdown: it floats, and the card must not move at all. */
/* Title, category, copy. Centre-aligned rather than baseline now that a
   control shares the row: a button's baseline is its label's, which sits it
   visibly low against a heading. The title takes the slack so the two things
   on the right stay pinned there however long the name is. */
.plug-card-head {
  display: flex; align-items: center; gap: var(--s2);
  justify-content: flex-start; flex-wrap: wrap;
}
.plug-card-head h3 { flex: 1 1 auto; min-width: 0; }

/* PINNED, not in flow. In flow the button lands wherever the title stops
   wrapping, so it sat in a different place on every card — beside a short
   name, on a second row under a long one. A copy control is worth finding
   without looking, which means the same corner every time. The header is
   padded to match so a long name wraps before it reaches the button rather
   than running underneath it. */
.plug-card { position: relative; }
.plug-card-actions { position: absolute; top: var(--s3); right: var(--s3); display: flex; }
/* Room for the pinned Copy button. The 78px this used to be was measured
   against the resting label and nothing else, so every other state — "Copied"
   at 84px, "Press Ctrl+C" at 117 — ran over the category chip beside it, most
   visibly on a phone. plugin-copy.js now writes the button's real width here
   whenever the label changes; the fallback is the no-script case, where there
   is no button and the reservation only has to not be negative. */
.plug-card-head {
  /* Overwritten inline by plugin-copy.js with the button's measured width. The
     declared value is the no-script case: no button, so nothing to clear. */
  --plug-copy-w: 0px;
  padding-right: calc(var(--plug-copy-w) + var(--s2));
}
.plug-card-head h3 { margin: 0; font-size: var(--t-body); }
.plug-card-blurb { margin: 0; font-size: var(--t-small); color: var(--ink-2); }
/* How long the script is. Quieter than the description and on its own line,
   because it is a fact about the thing rather than part of what it says. */
.plug-card-meta {
  margin: var(--s1) 0 0; font-size: var(--t-micro); color: var(--ink-3);
  font-variant-numeric: tabular-nums;
}
/* Tags are links — every one is a search you can run, so they get the hover a
   link gets rather than sitting there looking clickable and not being. */
/* The count beside a category name. It was `opacity: .55`, which is the wrong
   tool: transparency de-emphasises by DESTROYING CONTRAST, and on the skin's
   tan chip that put it at 2.18:1 — decorative rather than readable. Measured,
   even opacity 1 only reaches 4.68 here, so there was no value of it that both
   muted the number and kept it legible. Weight and size do the same job
   without spending contrast to do it. */

.plug-search { display: flex; gap: var(--s2); margin: var(--s5) 0 var(--s2); flex-wrap: wrap; }
.plug-search input[type="search"] {
  flex: 1 1 20rem; font: inherit; padding: 8px var(--s3); color: var(--ink);
  background: var(--sunken); border: 1px solid var(--edge); border-radius: var(--r-sm);
}
.plug-search input[type="search"]:focus { outline: 2px solid var(--accent-dim); outline-offset: 1px; }
.plug-search-clear { align-self: center; font-size: var(--t-small); }
.plug-count { margin: 0 0 var(--s3); }

.plug-head { display: flex; align-items: baseline; gap: var(--s3); flex-wrap: wrap; }
.plug-head h2, .plug-head h3 { margin: 0; }
.plug-chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .06em;
  padding: 2px 8px; border-radius: 20px; border: 1px solid var(--edge);
  color: var(--ink-3); white-space: nowrap;
}
.chip-ok { color: var(--verified); border-color: var(--accent-dim); }
/* A chip that is a LINK. There used to be one of these for plugin tags; it
   went with them, and then an asset page reached for the class by name and got
   an undecorated anchor inside a pill — which check:styles caught, because a
   reference with no rule behind it renders wrong rather than failing. */
a.chip-tag { text-decoration: none; transition: color .12s ease, border-color .12s ease; }
a.chip-tag:hover { color: var(--ink); border-color: var(--ink-3); text-decoration: none; }


/* The same three questions for every plugin, aligned so they can be compared
   down the page rather than read as prose. */
.plug-facts { list-style: none; margin: var(--s3) 0 0; padding: 0; display: grid; gap: 6px; }
.plug-facts li { display: grid; grid-template-columns: 5rem 1fr; gap: var(--s3); font-size: var(--t-small); color: var(--ink-2); }
.plug-facts span {
  color: var(--ink-3); text-transform: uppercase; font-size: var(--t-micro);
  letter-spacing: .06em; padding-top: 3px;
}


/* .plug-steps (the numbered "open console / paste it / undo" list) is gone:
   it only ever lived inside the public card's per-script source fold, and
   that fold is gone — see the removal note in pluginCard(), routes/plugins.ts. */
kbd {
  font-family: var(--mono); font-size: var(--t-micro); padding: 1px 5px;
  border: 1px solid var(--edge); border-bottom-width: 2px; border-radius: 4px;
  background: var(--raised-2); color: var(--ink);
}

/* The button sits ABOVE the source, not floating over it: an overlay would
   cover the first line of the very code the page is asking you to read. */
/* The Copy button's home on a card: above the fold, so it is reachable
   without expanding the source. */
.plug-card-actions:empty { display: none; }

.plug-card-actions .plugin-copy { margin: 0; }

/* Sized to sit in a card header beside a heading and a chip, not to be the
   loudest thing on the page: it is the common action, not an announcement.
   The icon is an inline SVG rather than a text glyph — see plugin-copy.js for
   why the glyph was wrong. */
.plugin-copy {
  display: inline-flex; align-items: center; gap: 5px;
  margin: 0; padding: 4px 10px;
  font: inherit; font-size: var(--t-micro); font-weight: 650; cursor: pointer;
  letter-spacing: .02em;
  color: var(--accent-ink); background: var(--accent); border: 1px solid var(--accent);
  border-radius: 999px;
  line-height: 1.5;
}
.plugin-copy svg { display: block; flex: none; }
/* The label swaps between "Copy", "Copied" and "Press Ctrl+C", and the longest
   of those is nearly twice the resting width — without this the button reflows
   its own header row at the moment it is clicked. */
.plugin-copy-label { white-space: nowrap; }
.plugin-copy:hover { filter: brightness(108%); }
/* Confirmation has to survive the button's own colour. The first attempt set
   background/border to --verified and left the ink alone, which painted exactly
   nothing: --verified and --accent are the SAME hex in both palettes (#15803D
   light, #4ADE80 dark), so all three declarations resolved to the values already
   in force. Un-filling is the one change guaranteed to read differently against
   a filled accent button in every palette, and it mirrors .is-bad below. */
.plugin-copy.is-ok {
  background: transparent; border-color: var(--verified); color: var(--verified);
}
/* A failure UNFILLS the button rather than filling it red: the ink token that
   reads on the accent fill is a dark green, and there is no matching ink for a
   red fill in either theme. Outlined needs no new token and stays legible in
   both — the detail goes in .plugin-copy-why below, because the label is nowrap
   and a sentence in this pill would reflow the card header it sits in. */
.plugin-copy.is-bad {
  background: transparent; border-color: var(--down); color: var(--down);
}
/* The status line under a card header. It reports success as well as failure —
   it is the only live region on the card, so a screen reader hears nothing at
   all without it — which is why the red belongs to a modifier and not to the
   element. */
.plugin-copy-why {
  margin: var(--s2) 0 0; font-size: var(--t-micro); color: var(--ink-2);
}
.plugin-copy-why.is-bad { color: var(--down); }
/* The clipboard fallback: an insecure context, or a browser that said no. The
   loader is not otherwise on the page — unlike the source, which is — so it has
   to be put somewhere selectable before it can be selected. */
.plugin-copy-fallback {
  margin: var(--s2) 0 0; padding: var(--s2);
  border: 1px dashed var(--edge); border-radius: var(--r-sm);
  background: var(--sunken); color: var(--ink);
  font-family: var(--mono); font-size: var(--t-micro);
  white-space: pre-wrap; word-break: break-all;
  max-height: 12em; overflow: auto;
}
/* The fallback box is a <pre>, and the skin styles every <pre> at specificity
   0,1,1 — one higher than this class — so the dashed border marking it as a
   temporary holder rather than source was silently replaced by the solid frame
   of the code block beside it. Restated in the skin layer (search plug-warn
   there) so the distinction survives. */
.plugin-copy-fallback-cap {
  margin: var(--s3) 0 calc(var(--s2) * -1); font-size: var(--t-micro); color: var(--ink-3);
}

/* .plug-lede (the safety-panel lead paragraph width) and .plug-more/
   .plug-more-label/.plug-more-body (the "What that means, in full" fold under
   it) are gone: the owner had the whole preamble panel removed from /plugins
   (see routes/plugins.ts, where it used to sit). Dropped from here too —
   check-styles fails a rule nobody's markup uses anymore. */

/* The live filter's empty state. plugin-search.js had always set .is-empty on
   the grid when nothing matches, and nothing in this file or any template ever
   rendered anything for it — so the scripted path, which is what nearly
   everyone gets, showed a blank space where the no-script path shows a panel
   with suggestions and a "show all" link. Both paths now toggle .plug-none,
   the one panel the server renders hidden; the grid class stays because it is
   what collapses the empty grid's own gap. */
.plug-grid.is-empty { min-height: 0; }
.plug-none { border-left: 3px solid var(--unknown); }

/* .plug-src (the per-script "Read the script" <details> fold, collapsed by
   default), .plug-src-label, .plug-code and .plug-prov are gone the same way:
   the owner asked that a reader never see the script they are pasting, nor
   the loader, on the public plugin card — see the removal note inside
   pluginCard() in routes/plugins.ts for whose call. Dropped from here too —
   check-styles fails a rule nobody's markup uses anymore. The admin review
   queue keeps its own, separate source view (reviewSource(), same file,
   rendered by .rev-src below) untouched, which is why .plugin-src itself is
   still defined further down: that class still has a caller. */

.plug-staff { display: flex; align-items: center; gap: var(--s4); margin-top: var(--s3); }
.chip-warn { color: var(--probable); border-color: #5C4A1A; }
.chip-crit { color: var(--down); border-color: #5C2430; }

/* A critical row is the one a tired reviewer must not skim past. */
.plug-findings tr.is-crit td { color: var(--ink); }
.plug-findings tr.is-crit code { color: var(--down); }
.plug-ack {
  display: flex; align-items: center; gap: var(--s2); flex: 1 1 100%;
  font-size: var(--t-small); color: var(--probable);
  padding: var(--s2) var(--s3); border: 1px solid #5C4A1A; border-radius: var(--r-sm);
  background: rgba(251,191,36,.06);
}
.plug-ack input { accent-color: var(--probable); }

/* ── The review queue ─────────────────────────────────────────────────── */

.rev-stats { display: flex; gap: var(--s3); flex-wrap: wrap; margin: var(--s4) 0; }
.rev-stat {
  border: 1px solid var(--edge); border-radius: var(--r-sm); background: var(--raised);
  padding: var(--s3) var(--s4); min-width: 7rem;
}
.rev-stat b { display: block; font-size: var(--t-title); line-height: 1.1; font-variant-numeric: tabular-nums; }
.rev-stat span { font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-3); }
.rev-stat.is-live b { color: var(--probable); }

/* The border is the triage signal — a reviewer scrolling a queue should be
   able to see which one to open without reading a word of it. */
.rev-card {
  border: 1px solid var(--edge); border-left: 3px solid var(--edge);
  border-radius: var(--r-sm); background: var(--raised);
  padding: var(--s4); margin-bottom: var(--s4);
}
.rev-card.is-critical { border-left-color: var(--probable); }
.rev-card.is-contradicted { border-left-color: var(--down); }

.rev-lie { border-left: 3px solid var(--down); }
.rev-lie ul { margin: var(--s2) 0 0; padding-left: 1.1rem; display: grid; gap: 4px; }
.rev-lie li { font-size: var(--t-small); }

/* Line-numbered source. Flagged lines are tinted the width of the block so the
   eye lands on them while scrolling, and each one is an anchor the findings
   table links to. */
.rev-src { margin: var(--s3) 0; }
.rev-src .plugin-src { padding: var(--s3) 0; }
.rev-line { display: block; padding: 0 var(--s3); }
.rev-line.is-warn { background: rgba(251,191,36,.08); box-shadow: inset 2px 0 0 var(--probable); }
.rev-line.is-crit { background: rgba(251,113,133,.10); box-shadow: inset 2px 0 0 var(--down); }
.rev-line:target { background: rgb(var(--accent-rgb) / .10); box-shadow: inset 2px 0 0 var(--accent); }
.rev-n {
  display: inline-block; width: 2.5rem; margin-right: var(--s3);
  text-align: right; color: var(--ink-3); user-select: none;
  font-variant-numeric: tabular-nums;
}

.rev-checklist { flex: 1 1 100%; font-size: var(--t-small); color: var(--ink-2); }
.rev-checklist ol { margin: var(--s2) 0 0; padding-left: 1.2rem; display: grid; gap: 3px; }

/* The two reviewer-only grant-override fields on the approve form are gone —
   a copy has no lifetime and no run count to set now that Copy hands over the
   script itself. */

.plug-err { border-left: 3px solid var(--down); }
.plug-err ul { margin: var(--s2) 0 0; padding-left: 1.1rem; }
.plug-findings { width: 100%; border-collapse: collapse; margin-top: var(--s3); font-size: var(--t-small); }
.plug-findings th, .plug-findings td { text-align: left; padding: 4px var(--s3) 4px 0; border-bottom: 1px solid var(--edge-soft); }

.plug-form { display: grid; gap: var(--s3); max-width: 46rem; margin-bottom: var(--s5); }
.plug-form label { display: grid; gap: 4px; font-size: var(--t-small); color: var(--ink-2); }
.plug-form input, .plug-form textarea, .plug-form select {
  font: inherit; padding: 6px var(--s3); color: var(--ink);
  background: var(--sunken); border: 1px solid var(--edge); border-radius: var(--r-sm);
}
.plug-form textarea { resize: vertical; }
.plug-source-input { font-family: var(--mono); font-size: var(--t-small); line-height: 1.5; white-space: pre; overflow-wrap: normal; overflow-x: auto; }
.plug-form .btn { justify-self: start; }

.plug-review-form { display: flex; gap: var(--s2); flex-wrap: wrap; align-items: center; margin-top: var(--s3); }
/* Same free-text field, three forms: the review form's own note (optional),
   the Decided list's takedown reason (required), and its delete-confirmation
   input (also required) — one rule, since all three sit in a
   .plug-review-form and need the same grow-to-fill treatment. */
.plug-review-form input[name="note"], .plug-review-form input[name="reason"],
.plug-review-form input[name="confirm"] {
  flex: 1 1 18rem; font: inherit; padding: 6px var(--s3); color: var(--ink);
  background: var(--sunken); border: 1px solid var(--edge); border-radius: var(--r-sm);
}
.btn-quiet { background: var(--raised-2); color: var(--ink-2); }
.plug-decided { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; margin-bottom: var(--s2); }

/* Shown whole so it can be read before it is pasted. No max-height: a scroll
   box hiding half the script would defeat the page's only safety property. */
.plugin-src {
  font-family: var(--mono); font-size: var(--t-small); line-height: 1.6;
  color: var(--ink-2); margin: 0; padding: var(--s4);
  white-space: pre; tab-size: 2;
  background: var(--sunken); border: 1px solid var(--edge); border-radius: var(--r-sm);
  /* Scrolls sideways rather than pushing the page sideways. Horizontal scroll
     keeps every character reachable, which max-height would not — the code has
     to stay readable in full, and on a phone the longest line here is 113
     characters. */
  overflow-x: auto;
}
/* Readability here IS the safety property — see views/highlight.ts. Comments
   are lifted rather than dimmed on purpose: in these snippets the comments are
   the explanation of what the code does to your browser. */
.tok-comment { color: var(--ink-3); font-style: italic; }
.tok-str, .tok-tpl { color: var(--accent); }
.tok-kw { color: var(--observed); }
.tok-num { color: var(--probable); }
.tok-fn { color: var(--ink); }
.tok-punc { color: var(--ink-3); }

/* ── Chat ─────────────────────────────────────────────────────────────────
   Discord's shape, because that is the shape people already know: a channel
   rail, a topic line, a scrolling log, a composer pinned under it. The
   grouping — one avatar and one name for a run of messages from the same
   person — is the part that actually makes a message list readable, so it is
   done in markup (msgRow in routes/chat.ts) rather than faked with CSS. */
.chat-shell {
  display: grid; grid-template-columns: 190px minmax(0, 1fr) 170px;
  gap: var(--s4); align-items: start;
}
@media (max-width: 980px) { .chat-shell { grid-template-columns: 190px minmax(0, 1fr); } }
.chat-rail { position: sticky; top: var(--s4); display: flex; flex-direction: column; gap: 2px; }
.chat-railhead { font-size: var(--t-body); margin: 0 0 var(--s2); }
.chat-chan {
  display: flex; align-items: center; gap: 6px;
  padding: 5px var(--s3); border-radius: var(--r-sm);
  color: var(--ink-2); text-decoration: none; font-size: var(--t-small);
  transition: background .12s ease, color .12s ease;
}
.chat-chan:hover { background: var(--raised-2); color: var(--ink); }
.chat-chan.on { background: var(--raised-2); color: var(--ink); font-weight: 650; }
.chat-hash { color: var(--ink-3); font-weight: 700; }
.chat-chan .badge-count { margin-left: auto; }
.chat-railnote { font-size: var(--t-micro); margin-top: var(--s3); line-height: 1.5; }

.chat-main {
  display: flex; flex-direction: column; min-width: 0;
  border: 1px solid var(--edge); border-radius: var(--r); background: var(--raised);
  overflow: hidden;
}
.chat-topic {
  display: flex; align-items: baseline; gap: var(--s3); flex-wrap: wrap;
  padding: var(--s3) var(--s4); border-bottom: 1px solid var(--edge);
  background: var(--raised-2); font-size: var(--t-small);
}
.chat-scroll {
  /* A fixed height, not a growing page: a chat that pushes the composer off
     the bottom of a long conversation is a chat you cannot type into. */
  height: min(58vh, 560px); overflow-y: auto; overflow-x: hidden;
  padding: var(--s3) 0;
}
.chat-empty { padding: var(--s5) var(--s4); text-align: center; }

.chat-msg { display: grid; grid-template-columns: 52px minmax(0, 1fr); padding: 2px var(--s3) 2px 0; }
.chat-msg:not(.is-grouped) { margin-top: var(--s3); }
.chat-msg:hover { background: var(--raised-2); }
.chat-gutter { display: flex; justify-content: center; padding-top: 2px; }
.chat-avatar {
  /* Overwritten inline per message with a hue derived from the account id, so
     one person is one colour everywhere. Declared here as the no-inline-style
     fallback — a bare `var(--h)` with nothing behind it makes the whole
     `background` declaration invalid and the avatar renders transparent. */
  --h: 0;
  width: 34px; height: 34px; border-radius: 50%;
  display: grid; place-items: center;
  font-weight: 700; font-size: var(--t-small);
  color: #fff; background: hsl(var(--h) 45% 38%);
}
/* The timestamp only appears on hover for a grouped line — the same trick
   Discord uses to keep a run of messages from being a wall of clocks. */
.chat-hovertime {
  font-size: 10px; color: var(--ink-3); opacity: 0;
  font-variant-numeric: tabular-nums; transition: opacity .1s ease;
}
.chat-msg:hover .chat-hovertime { opacity: 1; }
.chat-body { min-width: 0; position: relative; }
.chat-head { display: flex; align-items: baseline; gap: var(--s2); flex-wrap: wrap; }
.chat-who { --h: 0; font-weight: 700; color: hsl(var(--h) 55% 62%); }
.chat-time { font-size: var(--t-micro); color: var(--ink-3); font-variant-numeric: tabular-nums; }
.chat-text { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5; }
.chat-msg.is-gone .chat-body { padding-left: 2px; }

/* Only on hover, and only on your own. A remove button on every line turns a
   conversation into a control panel. */
.chat-del { position: absolute; top: 0; right: var(--s2); opacity: 0; }
.chat-msg:hover .chat-del, .chat-del:focus-within { opacity: 1; }
.chat-del button { font-size: var(--t-micro); color: var(--ink-3); }
.chat-del button:hover { color: var(--down); }

/* ── The composer ─────────────────────────────────────────────────────────
   ONE bar, not a box with a button parked beside it.
   It was a textarea and a full-size green Send sitting outside it, which made
   the whole thing look lopsided — and gave the loudest element on the page to
   the least-used way of sending, since Enter already does it. Now the controls
   live inside the bar's own border and Send is a compact icon at the end. It
   stays a real submit button: a touch keyboard has no Enter to press. */
.chat-composer {
  display: flex; gap: var(--s2); align-items: flex-end;
  padding: var(--s2); margin-bottom: var(--s3);
  border: 1px solid var(--edge); border-radius: var(--r);
  background: var(--raised);
  transition: border-color .12s ease;
}
.chat-composer:focus-within { border-color: var(--accent); }
.chat-composer textarea {
  flex: 1 1 auto; min-width: 0; resize: none; font: inherit;
  min-height: 30px; max-height: 180px; line-height: 1.45;
  /* The bar is the frame now, so the field inside it must not draw a second
     one — two nested borders is what made the old composer look like a form
     rather than a place to type. */
  border: 0; background: none; padding: 5px 2px; color: var(--ink);
}
.chat-composer textarea:focus { outline: none; }
.chat-sendbtn {
  flex: none; width: 30px; height: 30px; border-radius: 50%;
  display: grid; place-items: center; cursor: pointer;
  border: 0; background: var(--edge); color: var(--ink-2);
  font-size: 15px; line-height: 1;
  transition: background .12s ease, color .12s ease, transform .1s ease;
}
/* Lit only when there is something to send — the button tells you whether
   pressing it will do anything, which the old one never did. */
.chat-composer.has-text .chat-sendbtn { background: var(--accent); color: var(--accent-ink); }
.chat-composer.has-text .chat-sendbtn:hover { transform: scale(1.08); }
.chat-sendbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ── Message body: the formatted marks ────────────────────────────────
   Everything below styles what src/lib/chat-format.ts emits. The rules are
   deliberately quiet — a chat message is read, not admired, and the only two
   that draw attention (a spoiler, a mention of you) are the two that carry
   information the reader would otherwise miss. */
.chat-text strong { font-weight: 700; }
.chat-text em { font-style: italic; }
.chat-text s { opacity: .7; }
.chat-tick {
  font-family: var(--mono); font-size: .92em;
  background: var(--edge-soft); border-radius: 4px; padding: 1px 5px;
}
.chat-code {
  font-family: var(--mono); font-size: var(--t-micro);
  background: var(--sunken); border: 1px solid var(--edge);
  border-radius: var(--r-sm); padding: var(--s2) var(--s3);
  margin: 4px 0; overflow-x: auto; white-space: pre;
}
.chat-code code { background: none; padding: 0; }
/* A quote is a left rule, not a box: it sits inside a line of conversation
   and a bordered card would break the flow of the run it belongs to. */
.chat-quote {
  display: block; border-left: 3px solid var(--edge);
  padding-left: var(--s3); color: var(--ink-2);
}
.chat-link { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
.chat-chanref {
  color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent);
  border-radius: 4px; padding: 0 4px; text-decoration: none; font-weight: 600;
}
.chat-chanref:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); }
.chat-mention {
  color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent);
  border-radius: 4px; padding: 0 4px; font-weight: 600;
}
/* Being named is the one thing in a room you must not be able to scroll past,
   so the whole line is tinted and the mention itself is inverted. */
.chat-mention.is-me { background: var(--accent); color: var(--raised); }
.chat-msg.is-ping { background: color-mix(in srgb, var(--accent) 7%, transparent); box-shadow: inset 2px 0 0 var(--accent); }
.chat-msg.is-ping:hover { background: color-mix(in srgb, var(--accent) 11%, transparent); }
/* "(edited)" is a quiet aside; "(edited by somebody-else)" is not — a message
   changed by a hand other than its author's has to read as a fact about the
   message, not a footnote. */
.chat-edited { font-size: var(--t-micro); color: var(--ink-3); margin-left: 6px; }
.chat-edited:not(:empty) { white-space: nowrap; }
/* A message that is only emoji reads as a reaction rather than as a tiny line
   of text — the one piece of pure decoration worth keeping. */
.chat-text.is-jumbo { font-size: 34px; line-height: 1.2; }

/* Spoilers. Blurred rather than blacked out, so the shape of the sentence is
   visible and you can tell how much you are choosing to reveal. */
.chat-spoiler {
  background: var(--edge); color: transparent; border-radius: 4px;
  padding: 0 4px; cursor: pointer;
  filter: blur(4px); transition: filter .22s ease, color .22s ease, background .22s ease;
  user-select: none;
}
.chat-spoiler.shown {
  background: var(--edge-soft); color: inherit; filter: none; user-select: auto; cursor: auto;
}
.chat-spoiler:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* ── Replies ──────────────────────────────────────────────────────────────
   One line above the message naming what it answers. Small, dimmed, and
   clipped to a single row: a header is a pointer, and a quote that competes
   with the reply itself is the failure mode of quoting in full. */
.chat-replyhead {
  display: flex; align-items: baseline; gap: 6px;
  font-size: var(--t-micro); color: var(--ink-3);
  text-decoration: none; margin-bottom: 2px;
  min-width: 0; max-width: 100%;
}
a.chat-replyhead:hover .chat-replytext { color: var(--ink-2); }
a.chat-replyhead:hover .chat-replywho { text-decoration: underline; }
.chat-replyarrow { color: var(--ink-3); flex: none; }
.chat-replywho { font-weight: 650; color: var(--ink-2); flex: none; }
/* The one place on this page that truncates rather than wraps — a two-line
   header would push the reply itself down and undo the point of it. */
.chat-replytext { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.chat-replyhead.is-gone { cursor: default; }

/* Where a reply lands after you follow its header. Fades on its own, because a
   highlight that stays is a highlight you then have to dismiss. */
.chat-msg.is-target { animation: tt-flash 1.6s ease; }
@keyframes tt-flash {
  0%, 55% { background: color-mix(in srgb, var(--accent) 20%, transparent); }
  100%    { background: transparent; }
}

.chat-replybtn { font-size: var(--t-micro); color: var(--ink-3); text-decoration: none; }
.chat-replybtn:hover { color: var(--ink); text-decoration: underline; }

/* The bar and the composer, stacked. The bar is a SIBLING of the form, not a
   member of its flex row — putting it inside meant wrapping that row, which
   dropped the Send button onto its own line as soon as the textarea grew. */
.chat-send {
  border-top: 1px solid var(--edge); background: var(--raised-2);
  padding: var(--s3) var(--s3) 0;
}
.chat-replying {
  display: flex; align-items: baseline; gap: 6px;
  font-size: var(--t-micro); color: var(--ink-2);
  padding: 4px var(--s2); margin-bottom: var(--s2);
  border-left: 2px solid var(--accent); background: var(--raised);
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
}
.chat-replying[hidden] { display: none; }
.chat-cancelreply { margin-left: auto; color: var(--ink-3); font-size: var(--t-micro); }
.chat-cancelreply:hover { color: var(--down); }

/* ── Reactions ────────────────────────────────────────────────────────── */
.chat-reacts { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; align-items: center; }
/* The `+` form sits in the hover row, so it must not claim a line of its own
   under the message the way the pill bar does. */
.chat-addform { margin-top: 0; }
.chat-pill {
  display: inline-flex; align-items: center; gap: 4px;
  font-size: var(--t-micro); line-height: 1;
  padding: 3px 7px; border-radius: 10px;
  border: 1px solid var(--edge); background: var(--raised-2); color: var(--ink-2);
  cursor: pointer; transition: transform .1s ease, border-color .12s ease, background .12s ease;
}
.chat-pill:hover { border-color: var(--ink-3); }
.chat-pill.on {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 16%, transparent);
  color: var(--ink);
}
.chat-pill-n { font-variant-numeric: tabular-nums; font-weight: 650; }
/* The count popping when it changes is the whole satisfaction of a reaction:
   you pressed a thing and the thing answered. */
.chat-pill.pop { animation: tt-pop .34s cubic-bezier(.2, 1.5, .4, 1); }
@keyframes tt-pop {
  0% { transform: scale(1); }
  40% { transform: scale(1.28); }
  100% { transform: scale(1); }
}

/* `+` opens the palette. Hidden until the message is hovered, so a quiet
   channel is a conversation rather than a wall of buttons. */
.chat-addreact { position: relative; }
.chat-addreact > summary {
  list-style: none; cursor: pointer;
  width: 22px; height: 22px; border-radius: 50%;
  display: grid; place-items: center;
  color: var(--ink-3); border: 1px dashed var(--edge);
  font-size: var(--t-micro); opacity: 0; transition: opacity .12s ease, color .12s ease;
}
.chat-addreact > summary::-webkit-details-marker { display: none; }
.chat-msg:hover .chat-addreact > summary,
.chat-addreact[open] > summary,
.chat-addreact > summary:focus-visible { opacity: 1; }
.chat-addreact > summary:hover { color: var(--ink); border-color: var(--ink-3); }
/* Opens DOWNWARD and right-aligned, because the `+` sits in the hover row at
   the top-right of the message. Opening upward would put the palette above the
   first message in the log, outside the scroll box, where it gets clipped. */
.chat-palette {
  position: absolute; top: calc(100% + 6px); right: 0; z-index: 5;
  display: flex; gap: 2px; padding: 4px;
  background: var(--raised); border: 1px solid var(--edge);
  border-radius: var(--r-sm); box-shadow: 0 12px 32px rgba(0,0,0,.5);
}
@starting-style { .chat-palette { opacity: 0; transform: translateY(-4px); } }
.chat-palette { opacity: 1; transform: none; transition: opacity .14s ease, transform .14s ease; }
.chat-palbtn {
  border: 0; background: none; cursor: pointer; font-size: 16px;
  padding: 3px 5px; border-radius: var(--r-sm); line-height: 1;
  transition: transform .1s ease, background .1s ease;
}
.chat-palbtn:hover { background: var(--raised-2); transform: scale(1.25); }

/* ── Editing, and the row of small verbs under a message ──────────────── */
.chat-tools {
  /* WHY IT LOOKED LIKE A JUMBLE.
     .chat-tools lives inside .chat-body, which is positioned — so `top: 0`
     resolved to the top of the message's content column, which is exactly
     where .chat-head sits with the author's name and the timestamp. The
     toolbar was also fully transparent, so hovering laid four controls
     directly over that text with nothing between them: two sets of words in
     the same place, which is what reads as overlapping lines.
     Now it has a surface of its own and sits just ABOVE the content, so it
     covers the gap between messages rather than the message it belongs to. */
  position: absolute;
  top: -12px;
  right: var(--s2);
  z-index: 3;
  opacity: 0;
  background: var(--raised-2);
  border: 1px solid var(--edge);
  border-radius: var(--r-sm);
  padding: 2px 4px;
  box-shadow: 0 2px 8px rgb(0 0 0 / .22);
    
  display: flex; gap: var(--s2); align-items: center; 
  transition: opacity .12s ease;
}
/* The first message has nothing above it to overlap, so its toolbar stays
   inside its own row rather than being clipped by the scroller's top edge. */
.chat-scroll > .chat-msg:first-child .chat-tools { top: 2px; }
/* The open palette keeps the row visible: a picker that vanishes because the
   pointer travelled the six pixels from `+` to the emoji is a picker nobody
   can use. */
.chat-msg:hover .chat-tools,
.chat-tools:focus-within,
.chat-tools:has(details[open]) { opacity: 1; }
.chat-tools .chat-del { position: static; opacity: 1; }
.chat-del button { font-size: var(--t-micro); color: var(--ink-3); }
.chat-del button:hover { color: var(--down); }
.chat-edit > summary {
  list-style: none; cursor: pointer;
  font-size: var(--t-micro); color: var(--ink-3);
}
.chat-edit > summary::-webkit-details-marker { display: none; }
.chat-edit > summary:hover { color: var(--ink); }
/* The editor drops out of the tools row and spans the message, because you are
   rewriting a paragraph, not filling in a field. */
.chat-edit[open] {
  position: absolute; top: 0; right: 0; left: 0; z-index: 4;
  background: var(--raised); border: 1px solid var(--edge);
  border-radius: var(--r-sm); padding: var(--s2);
}
.chat-edit form { display: flex; gap: var(--s2); align-items: flex-end; margin-top: 4px; }
.chat-edit textarea { flex: 1 1 auto; min-width: 0; resize: vertical; font: inherit; }

/* ── The "new messages" rule ──────────────────────────────────────────── */
.chat-newline {
  display: flex; align-items: center; gap: var(--s2);
  margin: var(--s3) var(--s3) 0 0; padding-left: 52px;
  color: var(--down); font-size: var(--t-micro); font-weight: 650;
}
.chat-newline::after { content: ""; flex: 1 1 auto; height: 1px; background: var(--down); opacity: .5; }
.chat-newline span { text-transform: uppercase; letter-spacing: .06em; }

/* A message that arrived while you were looking. Short and small — a live
   list that animates hard is a list you cannot read while it updates.
   No opacity: see the entrance rule further down this file. A frozen frame of
   this is a message six pixels low, not a message that is not there. */
.chat-msg.is-fresh { animation: tt-msg-in .28s ease; }
@keyframes tt-msg-in {
  from { transform: translateY(6px); }
  to   { transform: none; }
}

/* ── Jump to present ──────────────────────────────────────────────────── */
/* Only ever shown when the reader has scrolled away AND something arrived —
   a permanent button would be a scrollbar with extra steps. */
.chat-jump {
  position: absolute; left: 50%; bottom: 76px; transform: translateX(-50%);
  z-index: 6; border: 1px solid var(--accent);
  background: var(--raised); color: var(--ink);
  border-radius: 999px; padding: 5px 12px; font-size: var(--t-micro);
  cursor: pointer; box-shadow: 0 12px 32px rgba(0,0,0,.5);
  animation: tt-rise .2s ease;
}
@keyframes tt-rise { from { opacity: 0; transform: translate(-50%, 6px); } }
.chat-main { position: relative; }

/* ── @-autocomplete ───────────────────────────────────────────────────── */
.chat-names {
  position: absolute; bottom: 100%; left: var(--s3); z-index: 7;
  min-width: 150px; margin-bottom: 4px; padding: 3px;
  background: var(--raised); border: 1px solid var(--edge);
  border-radius: var(--r-sm); box-shadow: 0 12px 32px rgba(0,0,0,.5);
}
.chat-name {
  display: block; width: 100%; text-align: left;
  padding: 4px var(--s2); border: 0; background: none; cursor: pointer;
  border-radius: 4px; font: inherit; font-size: var(--t-small); color: var(--ink-2);
}
.chat-name.on, .chat-name:hover { background: var(--raised-2); color: var(--ink); }
.chat-composer { position: relative; }

/* ── The formatting cheat sheet ───────────────────────────────────────── */
.chat-syntax { margin: var(--s2) var(--s3) var(--s3); font-size: var(--t-micro); }
.chat-syntax > summary { cursor: pointer; color: var(--ink-3); }
.chat-syntax > summary:hover { color: var(--ink-2); }
.chat-syntax ul { margin: var(--s2) 0 0; padding-left: var(--s4); color: var(--ink-2); line-height: 1.8; }
.chat-syntax code { font-family: var(--mono); background: var(--edge-soft); border-radius: 3px; padding: 1px 4px; }

/* A mention badge is not an unread badge. Grey says "there is talking";
   this says "somebody asked you something". */
.badge-count.chat-ping { background: var(--down); color: #fff; }

.chat-railtop { display: flex; align-items: baseline; gap: var(--s3); }
/* Shown only on a narrow screen — see the media query. */
.chat-railtoggle { display: none; }

@media (max-width: 720px) {
  /* The rail COLLAPSES rather than wrapping.
     It used to become a row of chips, which was right when it was four
     channels and nothing else. It is now a column of groups — site channels,
     each chat with its own channels, invitations, a search box — and
     row-wrapping that produced a jumble that filled the entire first screen,
     so the conversation started below the fold and every heading sat beside
     the wrong thing. A phone gets the room, and the channel list on request. */
  .chat-shell { grid-template-columns: 1fr; }
  .chat-rail { position: static; flex-direction: column; }
  .chat-railtoggle {
    display: block; margin-left: auto;
    padding: 4px var(--s3); border: 1px solid var(--edge); border-radius: var(--r-sm);
    background: var(--raised); color: var(--ink-2); font-size: var(--t-micro); cursor: pointer;
  }
  .chat-railtoggle[aria-expanded="true"] { border-color: var(--accent); color: var(--ink); }
  .chat-railbody { display: none; }
  .chat-shell.rail-open .chat-railbody { display: block; }
  .chat-railhead, .chat-railnote { width: 100%; }
  .chat-scroll { height: min(62vh, 460px); }
  .chat-msg { grid-template-columns: 40px minmax(0, 1fr); }
  .chat-avatar { width: 28px; height: 28px; font-size: var(--t-micro); }
  .chat-newline { padding-left: 40px; }
  /* No hover on a touch screen, so the verbs and the `+` have to be visible or
     they do not exist at all — and once they are always visible they cannot
     stay absolutely positioned over the top of the message. Overlaid, they
     landed straight on the reply header and the first line of text. In flow,
     right-aligned under the message, they are readable and reachable. */
  .chat-tools {
    position: static; opacity: 1;
    justify-content: flex-end; margin-top: 4px;
  }
  .chat-addreact > summary { opacity: 1; }
  .chat-palette { right: 0; left: auto; }
}

/* ── The eggs themselves ─────────────────────────────────────────────────
   All three are pure decoration with no layout role, so every one is
   pointer-events:none and fixed — nothing here can ever sit between a reader
   and something they were trying to click. */
.tt-toast {
  position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
  z-index: 90; pointer-events: none;
  background: var(--raised); border: 1px solid var(--accent);
  color: var(--ink); border-radius: 999px;
  padding: 7px 16px; font-size: var(--t-small);
  box-shadow: 0 12px 32px rgba(0,0,0,.5);
  animation: tt-toast-in .3s ease, tt-toast-out .4s ease 3.6s forwards;
}
@keyframes tt-toast-in { from { opacity: 0; transform: translate(-50%, 10px); } }
@keyframes tt-toast-out { to { opacity: 0; transform: translate(-50%, 10px); } }

.tt-paw {
  position: fixed; z-index: 89; pointer-events: none;
  font-size: 16px; line-height: 1;
  animation: tt-paw-fade 1.4s ease forwards;
}
@keyframes tt-paw-fade {
  0%   { opacity: .85; }
  100% { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  /* The coin and the paw are pure motion — with nowhere to travel they are
     just a dot and a glyph parked on the page, so they do not render at all.
     delight.js does not start them either; this is the belt to that braces. */
   .tt-paw { display: none; }
  /* The toast is NOT decoration — it is the only thing that tells you a secret
     registered, and it registers for everybody. The global rule collapses its
     durations, and `tt-toast-out` ends at opacity 0, so without this the
     notice would vanish the instant it appeared. No animation: it simply
     shows, and delight.js removes it on its own timer. */
  .tt-toast { animation: none; }
}

/* ── Motion, second pass ──────────────────────────────────────────────────
   Everything below is entrance or attention. Nothing loops, nothing plays
   while a page is being read, and every one is collapsed by the global
   reduced-motion rule near the top of this file. */

/* ── The rule every entrance below obeys ──────────────────────────────────

   NEVER ANIMATE A CONTENT ELEMENT IN FROM opacity: 0.

   A hidden tab freezes CSS animations and transitions at time 0 — measured,
   not assumed: a probe element sat at `playState: "running", currentTime: 0`
   and computed opacity 0 for as long as it was watched. Whatever starts from
   invisible therefore RENDERS invisible for as long as nobody is looking, and
   "as long as nobody is looking" includes a minimised window, a restored
   session tab, and anything that snapshots or prints a background tab.

   A page fade was the first thing written here and the first thing removed:
   `#main` entering from opacity 0 meant the entire page content was blank in
   that state. A 170ms fade is not worth that, and there was no safe version —
   a transform on #main is separately impossible, because it would make #main
   the containing block for `position: fixed` descendants and the compare tray
   (rendered inside the route body on /compare and the entity pages) would jump
   on every navigation.

   So entrances move, and do not fade. A frozen frame of a slide is the element
   sitting five pixels off; a frozen frame of a fade is nothing at all. */

/* A notice is the one thing on the page the reader has to see, and after a
   redirect it appears in content that otherwise looks unchanged. It arrives
   with a rise and a brief accent ring — no opacity, per the rule above, so a
   frozen frame is a notice slightly out of place rather than a missing one. */
.notice { animation: tt-notice-in .3s cubic-bezier(.2, .8, .3, 1); }
@keyframes tt-notice-in {
  from { transform: translateY(-5px); box-shadow: -3px 0 0 0 var(--accent); }
  to   { transform: none; box-shadow: -3px 0 0 0 transparent; }
}

/* A number that changed under the reader.
   Added by delight.js when a polled figure or badge actually changes value —
   the counts on /discord, /staff and /ai update on a timer, and without this
   the only way to notice is to have been staring at the right cell. Outline
   rather than background: the cell keeps its own colour, so a provenance tint
   or a status pill is not briefly repainted into a different meaning. */
.tt-changed { animation: tt-changed 1.1s ease; border-radius: 4px; }
@keyframes tt-changed {
  0%   { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
  100% { box-shadow: 0 0 0 3px transparent; }
}

/* Depth under the sticky header, only once the page has actually scrolled.
   Scroll-driven, so it costs no script and cannot fall out of step with the
   scroll position the way a scroll listener does. Browsers without
   scroll-timeline keep the flat header they have now. */
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .topbar {
      animation: tt-topbar-lift linear both;
      animation-timeline: scroll(root block);
      animation-range: 0 90px;
    }
  }
}
@keyframes tt-topbar-lift {
  from { box-shadow: 0 0 0 rgba(0,0,0,0); }
  to   { box-shadow: 0 6px 18px rgba(0,0,0,.28); }
}

/* <details> opening and closing, rather than snapping.
   Needs ::details-content and interpolate-size, so it is behind @supports and
   simply does not apply anywhere they are missing. Three are excluded and each
   for a concrete reason: .navdrop already animates its own menu, and
   .chat-addreact and .chat-edit both contain an absolutely-positioned panel
   that the required `overflow: hidden` would clip in half.

   This one DOES start from a collapsed state, which the rule above forbids —
   the exception is deliberate and the distinction is the trigger. #main enters
   on every page LOAD, so a hidden tab freezes it with no one having done
   anything; this transition only ever starts from a CLICK, and a click means
   somebody is looking at the tab. A reader who opens a disclosure and switches
   away mid-animation comes back to it finished. */
@supports selector(::details-content) {
  :root { interpolate-size: allow-keywords; }
  details:not(.navdrop):not(.chat-addreact):not(.chat-edit)::details-content {
    block-size: 0;
    overflow: hidden;
    transition: block-size .22s cubic-bezier(.2, .8, .3, 1), content-visibility .22s allow-discrete;
  }
  details[open]:not(.navdrop):not(.chat-addreact):not(.chat-edit)::details-content {
    block-size: auto;
  }
}
@keyframes tt-found {
  0%   { transform: scale(1); box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent); }
  35%  { transform: scale(1.015); box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 22%, transparent); }
  100% { transform: scale(1); box-shadow: 0 0 0 12px transparent; }
}

/* ── Patch notes ──────────────────────────────────────────────────────────
   The line as the developer wrote it, with this site's reading of it beside.
   Replaced a four-column table on /updates/:id that dropped the sentence —
   see noteList() in src/web/views/changelog.ts for the full reasoning and the
   count of rows it was rendering blank. */
/* .patch-notes, not .notes: .notes is also the entity table's footnote block,
   capped at 68ch for reading, and the cap squeezed this list into half of
   its panel. */
.patch-notes { list-style: none; margin: 0; padding: var(--s2) 0; }
.note {
  /* Set per line by the template from the changelog's own bullet depth.
     Declared here so the padding calc below is valid with nothing behind it. */
  --d: 0;
  display: flex; align-items: baseline; gap: var(--s2); flex-wrap: wrap;
  /* --d is the bullet's nesting depth, straight from the changelog. Indenting
     by it is what makes "NEW Mimipot:" read as the heading for the two lines
     that describe it, instead of three unrelated bullets in a row. */
  padding: 5px var(--s4) 5px calc(var(--s4) + var(--d, 0) * 22px);
  border-bottom: 1px solid var(--edge-soft);
  font-size: var(--t-small); line-height: 1.5;
}
.note:last-child { border-bottom: 0; }
.note:hover { background: var(--raised-2); }
/* A line ending in a colon with detail nested under it is a heading — the
   changelog's own structure, not something imposed here. */
.note.is-head { font-weight: 650; }
.note.is-head .note-text { color: var(--ink); }
/* <q> supplies quotation marks in most browsers; they are wrong here because
   the line is already visually set apart and a run of them reads as noise. The
   element stays a <q> so the markup still says "this is quoted". */
.note-text { quotes: none; color: var(--ink-2); min-width: 0; flex: 1 1 22ch; }
.note-text::before, .note-text::after { content: none; }
.note-meta { display: flex; align-items: baseline; gap: 4px; flex-wrap: wrap; margin-left: auto; }
.note-chip {
  font-size: var(--t-micro); border: 1px solid var(--edge); border-radius: 999px;
  padding: 1px 8px; color: var(--ink-2); text-decoration: none; white-space: nowrap;
}
a.note-chip:hover { border-color: var(--accent); color: var(--ink); }
.note-chip.is-attr { color: var(--ink-3); border-style: dashed; }

@media (max-width: 720px) {
  /* The chips drop under the line rather than fighting it for width — at
     375px a right-aligned chip row leaves about eight characters for the
     sentence, which is the thing people came to read. */
  .note { padding-left: calc(var(--s3) + var(--d, 0) * 14px); padding-right: var(--s3); }
  .note-meta { margin-left: 0; flex-basis: 100%; }
}

/* ── Channel rail extras ──────────────────────────────────────────────────
   Channels became rows rather than four constants, so the rail now carries
   private rooms, archived ones, per-channel colour, and pending invitations. */
.chat-chan { --h: 0; }
/* A channel with its own hue tints only its marker, never its label — a rail
   of differently-coloured words is unreadable, and the point of the colour is
   to find one room quickly, not to decorate the list. */
.chat-chan[style*="--h"] .chat-hash { color: hsl(var(--h) 60% 60%); }
.chat-chan.is-archived { opacity: .62; }
.chat-chan.is-archived .chat-hash { text-decoration: line-through; }

.chat-newchan {
  padding: 5px var(--s3); border-radius: var(--r-sm);
  color: var(--ink-3); text-decoration: none; font-size: var(--t-micro);
}
.chat-newchan:hover { background: var(--raised-2); color: var(--ink); }

.chat-railsub {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .06em;
  color: var(--ink-3); margin: var(--s4) 0 var(--s2);
}
.chat-invites { border-top: 1px solid var(--edge); margin-top: var(--s3); }
.chat-invite {
  padding: var(--s2) var(--s3); border-radius: var(--r-sm);
  background: var(--raised-2); margin-bottom: var(--s2);
  border-left: 2px solid var(--accent);
}
.chat-invite-who { display: flex; flex-direction: column; gap: 1px; font-size: var(--t-micro); }
.chat-invite-act { display: flex; align-items: center; gap: var(--s3); margin-top: 6px; }

/* Private and archived, said on the topic line rather than only in the rail —
   somebody arriving from a link has not seen the rail. */
.chat-lock {
  font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .05em;
  border: 1px solid var(--edge); border-radius: 999px; padding: 1px 8px;
  color: var(--ink-3);
}
.chat-lock.is-archived { border-color: var(--probable); color: var(--probable); }
.chat-settings { margin-left: auto; font-size: var(--t-micro); }
/* Why the composer is missing, in the composer's own shape and place — so it
   reads as "this is what you get instead of a box", not as a notice that
   happens to be nearby. Replaced the "read-only" chip that used to sit beside
   every such channel in the rail, which was noise on a list you scan for names
   and answered a question you only have once you are in the room. */
.chat-readonly {
  display: flex; align-items: center; gap: var(--s2);
  margin: 0 0 var(--s3); padding: var(--s3);
  border: 1px dashed var(--edge); border-radius: var(--r);
  background: var(--sunken); color: var(--ink-2); font-size: var(--t-small);
}
.chat-readonly-mark { flex: none; opacity: .8; }

/* ── Channel forms ────────────────────────────────────────────────────── */
.chat-form { display: flex; flex-direction: column; gap: var(--s4); margin-bottom: var(--s5); }
/* The site's own `.field` is display:block, which leaves the label, the input
   and the hint flowing inline. A text input fills the line so it looks stacked
   by accident; a number input does not, so Colour and Order came out as
   "Colour [200] A hue on the wheel…" all on one line while Icon above them
   stacked. Stated explicitly here rather than relying on how wide a given
   input happens to be. */
.chat-form .field { display: flex; flex-direction: column; gap: 4px; }
.chat-form .field > input,
.chat-form .field > select { width: 100%; }
.chat-fieldrow { display: flex; gap: var(--s4); flex-wrap: wrap; }
.chat-fieldrow .field { flex: 1 1 140px; }
.chat-formact { display: flex; align-items: center; gap: var(--s4); }
.chat-rowact { display: flex; gap: var(--s3); align-items: center; }
/* The one panel on the site that is asking for something irreversible. */
.panel.danger { border-color: color-mix(in srgb, var(--down) 55%, var(--edge)); }

/* ── Chats and their channels in the rail ─────────────────────────────────
   A flat list of channels with a "new" link under it did not survive contact:
   a chat and a channel looked identical, there was nowhere to see who was in
   one, and the only route to inviting anybody was a small "Settings" word on
   the topic line of a room you had to already be looking at. The rail is
   grouped now — the site's channels, then each chat with its own channels
   under it — because the grouping IS the explanation. */
.chat-railgroup { margin-top: var(--s4); border-top: 1px solid var(--edge); padding-top: var(--s3); }
.chat-railempty { font-size: var(--t-micro); line-height: 1.5; margin: 0 0 var(--s3); }
.chat-space { margin-bottom: var(--s3); }
.chat-spacehead {
  display: flex; align-items: baseline; gap: var(--s2);
  padding: 2px var(--s3); margin-bottom: 2px;
}
.chat-spacename {
  font-size: var(--t-small); font-weight: 700; color: var(--ink);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.chat-spacecog { margin-left: auto; font-size: var(--t-micro); color: var(--ink-3); text-decoration: none; }
.chat-spacecog:hover { color: var(--ink); text-decoration: underline; }
/* Channels inside a chat are indented, so the rail reads as a tree rather than
   as one list where a chat name happens to be bold. */
.chat-space .chat-chan { margin-left: var(--s3); }
.chat-space .chat-newchan { margin-left: var(--s3); }
.chat-newchat { margin-top: var(--s2); width: 100%; text-align: center; }

/* A channel most people cannot write in says so in the rail, not only when you
   arrive and find no composer. */
.chat-ro {
  font-size: 9px; text-transform: uppercase; letter-spacing: .05em;
  color: var(--ink-3); border: 1px solid var(--edge); border-radius: 999px;
  padding: 0 5px; margin-left: 4px;
}

/* ── Photos ───────────────────────────────────────────────────────────────
   The paperclip is a styled <label> wrapping a hidden file input — the native
   control cannot be restyled and reads as a stray button beside a chat box. */
.chat-photo {
  display: grid; place-items: center; flex: none;
  width: 30px; height: 30px; border-radius: var(--r-sm);
  border: 0; background: none;
  cursor: pointer; font-size: 15px; color: var(--ink-3);
  transition: color .12s ease, background .12s ease;
}
.chat-photo:hover { background: var(--raised-2); color: var(--ink); }
.chat-photo:focus-within { outline: 2px solid var(--accent); outline-offset: 1px; }
.chat-photo input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
/* Written by chat-live.js once a file is chosen. Without it the only sign a
   photo is attached is the file picker's own memory, which the page does not
   show — you would press Send and hope. */
.chat-photoname { margin: 0 0 var(--s2) var(--s3); font-size: var(--t-micro); }
.chat-photoname[hidden] { display: none; }

/* A photo in a message is an aside. One that pushes the conversation off the
   screen is a photo nobody asked to be that size. */
.chat-shot { display: block; margin: 4px 0; max-width: 320px; }
.chat-shot img {
  display: block; max-width: 100%; max-height: 240px; width: auto; height: auto;
  border-radius: var(--r-sm); border: 1px solid var(--edge);
}

/* ── The third column, pins, presence and dragging ────────────────────────
   Discord's shape completed: channels on the left, the room in the middle,
   the people who can read it on the right. */
.chat-shell { position: relative; }
.chat-people {
  position: sticky; top: var(--s4);
  display: flex; flex-direction: column; gap: 1px;
  min-width: 0;
}
.chat-person {
  display: flex; align-items: center; gap: var(--s2);
  padding: 4px var(--s2); border-radius: var(--r-sm);
  font-size: var(--t-small); color: var(--ink-3);
}
/* Dimmed until the state poll says otherwise, so "here" is a positive signal
   rather than the default everybody gets for having an account. */
.chat-person.is-here { color: var(--ink); }
.chat-personname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* A person in the member list opens the DM with them, so it has to read as
   pressable. Your own row is the one that does not — dmWith refuses it. */
a.chat-person { text-decoration: none; }
a.chat-person:hover,
a.chat-person:focus-visible { background: var(--raised); color: var(--ink); }
.chat-person.is-me { opacity: .75; cursor: default; }
/* The DM rail row: a presence dot where a channel has its #. */
.chat-dm { display: flex; align-items: center; gap: var(--s2); }
.chat-dmdot {
  width: 7px; height: 7px; border-radius: 50%; flex: none;
  background: var(--edge);
}
.chat-dm.on .chat-dmdot { background: var(--accent); }
.chat-avatar.sm { width: 22px; height: 22px; font-size: 10px; flex: none; }
.chat-dot {
  margin-left: auto; width: 7px; height: 7px; border-radius: 50%;
  background: var(--edge); flex: none;
}
.chat-person.is-here .chat-dot { background: var(--accent); }

.chat-pinsbtn {
  font-size: var(--t-micro); color: var(--ink-3);
  background: none; border: 0; cursor: pointer; padding: 0;
}
.chat-pinsbtn:hover { color: var(--ink); text-decoration: underline; }
.chat-pins {
  border-bottom: 1px solid var(--edge); background: var(--sunken);
  max-height: 260px; overflow-y: auto;
}
.chat-pins[hidden] { display: none; }
/* A pinned message carries a marker in the log too, or the pinned list is the
   only place you would ever learn it was pinned. */
.chat-msg.is-pinned { box-shadow: inset 2px 0 0 var(--probable); }
.chat-pinform { display: inline; }

/* Reserved height: without it the composer hops down and back every time
   somebody starts and stops typing. */
.chat-typing {
  min-height: 1.2em; margin: 0; padding: 0 var(--s3);
  font-size: var(--t-micro); color: var(--ink-3); font-style: italic;
}

/* ── Dragging ─────────────────────────────────────────────────────────── */
.chat-chanlist { display: flex; flex-direction: column; gap: 2px; }
.chat-chanlist[data-reorder] .chat-chan { cursor: grab; }
.chat-chan.is-dragging { opacity: .45; cursor: grabbing; }

/* The whole room is the drop target, not a small strip — you should be able to
   let go anywhere over the conversation. */
.chat-shell.is-dropping .chat-main {
  outline: 2px dashed var(--accent); outline-offset: -4px;
}
.chat-shell.is-dropping .chat-main::after {
  content: "Drop the image to attach it";
  position: absolute; inset: auto 0 84px 0; text-align: center;
  font-size: var(--t-small); color: var(--accent); pointer-events: none;
}

@media (max-width: 980px) {
  /* The people column is the first thing to go: it is the least load-bearing
     of the three, and two columns still work. */
  .chat-people { display: none; }
}

/* ── Search, the quick switcher, emoji and slash ──────────────────────────
   The rest of Discord's shape: find a message, jump to a channel without
   reaching for the mouse, and put an emoji in without knowing its shortcode. */
.chat-find { margin-bottom: var(--s2); }
.chat-find input { width: 100%; font-size: var(--t-small); }
.chat-jumpbtn {
  display: flex; align-items: center; gap: 4px; width: 100%;
  padding: 4px var(--s3); margin-bottom: var(--s3);
  border: 1px solid var(--edge); border-radius: var(--r-sm);
  background: var(--raised); color: var(--ink-3);
  font-size: var(--t-micro); cursor: pointer;
}
.chat-jumpbtn:hover { border-color: var(--accent); color: var(--ink); }
.chat-jumpbtn kbd {
  font-family: var(--mono); font-size: 9px; border: 1px solid var(--edge);
  border-radius: 3px; padding: 0 3px; background: var(--raised-2);
}
.chat-readall { margin-top: var(--s3); }
.chat-readall button { font-size: var(--t-micro); color: var(--ink-3); }
.chat-readall button:hover { color: var(--ink); }

/* Results sit OVER the log, so your place in the conversation is still there
   underneath when you close them. */
.chat-results {
  border-bottom: 1px solid var(--edge); background: var(--sunken);
  max-height: 340px; overflow-y: auto;
}
.chat-results[hidden] { display: none; }
.chat-hit { border-bottom: 1px solid var(--edge-soft); padding-bottom: var(--s2); }
.chat-hit:last-child { border-bottom: 0; }
.chat-hitwhere {
  display: inline-block; margin: var(--s2) 0 0 var(--s3);
  font-size: var(--t-micro); color: var(--ink-3); text-decoration: none;
}
.chat-hitwhere:hover { color: var(--accent); text-decoration: underline; }

/* The switcher is a dialog over everything, and closing it must not need the
   mouse — Escape and the backdrop both do it. */
.chat-switcher {
  position: fixed; inset: 0; z-index: 95;
  background: rgba(0,0,0,.55);
  display: flex; justify-content: center; align-items: flex-start;
  padding-top: 14vh;
}
.chat-switcherbox {
  width: min(460px, 92vw);
  background: var(--raised); border: 1px solid var(--edge);
  border-radius: var(--r); box-shadow: 0 12px 32px rgba(0,0,0,.5);
  overflow: hidden;
}
.chat-switcherq {
  width: 100%; border: 0; border-bottom: 1px solid var(--edge);
  background: var(--raised-2); color: var(--ink);
  padding: var(--s3) var(--s4); font-size: var(--t-lead);
}
.chat-switcherq:focus { outline: none; }
.chat-switcherlist { max-height: 320px; overflow-y: auto; padding: var(--s1); }
.chat-switcherhit {
  display: flex; align-items: baseline; gap: var(--s3);
  padding: 7px var(--s3); border-radius: var(--r-sm);
  color: var(--ink-2); text-decoration: none; font-size: var(--t-small);
}
.chat-switcherhit.on, .chat-switcherhit:hover { background: var(--raised-2); color: var(--ink); }
.chat-switchergroup { margin-left: auto; font-size: var(--t-micro); color: var(--ink-3); }

.chat-emoji { position: relative; flex: none; }
.chat-emoji > summary {
  list-style: none; cursor: pointer;
  width: 30px; height: 30px; border-radius: var(--r-sm);
  display: grid; place-items: center; font-size: 15px;
  border: 0; background: none; color: var(--ink-3);
}
.chat-emoji > summary::-webkit-details-marker { display: none; }
.chat-emoji > summary:hover { background: var(--raised-2); color: var(--ink); }
.chat-emojigrid {
  position: absolute; bottom: calc(100% + 6px); left: 0; z-index: 8;
  display: grid; grid-template-columns: repeat(6, 1fr); gap: 2px; padding: 5px;
  background: var(--raised); border: 1px solid var(--edge);
  border-radius: var(--r-sm); box-shadow: 0 12px 32px rgba(0,0,0,.5);
}

/* Search hits, the permalink, and the keyboard-reorder announcement. */
.chat-linkbtn { font-size: var(--t-micro); color: var(--ink-3); text-decoration: none; }
.chat-linkbtn:hover { color: var(--ink); text-decoration: underline; }
.chat-results mark {
  background: color-mix(in srgb, var(--accent) 35%, transparent);
  color: inherit; border-radius: 2px; padding: 0 1px;
}

/* ── The composer, under the taming.io skin ───────────────────────────────
   The skin gives every field a 3px border, a sunken fill, a 6px inset
   highlight and a large radius — right for a form, wrong inside the composer,
   where the BAR is the frame and the field is just the text. Those rules are
   `[data-skin="tio"] textarea`, which has exactly the same specificity as
   `.chat-composer textarea` and sits later in this file, so the base rule
   cannot win and the counter belongs here beside what it is countering.
   Resetting border and background alone was not enough — the visible pill
   inside the bar was the inset box-shadow, which nothing had touched. */
[data-skin="tio"] .chat-composer textarea {
  border: 0;
  background: none;
  box-shadow: none;
  border-radius: 0;
  padding: 5px 2px;
}
[data-skin="tio"] .chat-composer textarea:focus { box-shadow: none; outline: none; }

/* ── The guides filter ────────────────────────────────────────────────────
   Was a search box needing a button press, plus fourteen category buttons and
   "…and 85 more categories — pick one to see the rest", which is a dead end:
   you could not see the rest without picking one, and picking one is what you
   wanted the list for. 99 categories over 313 articles, 27 of them naming a
   single article — that belongs in a control built for long lists. */
.guide-filter {
  display: flex; gap: var(--s4); align-items: flex-end; flex-wrap: wrap;
}
.guide-filter .field { display: flex; flex-direction: column; gap: 4px; flex: 1 1 200px; }
.guide-filter .field label { margin: 0; font-size: var(--t-small); color: var(--ink-2); }
.guide-filter .field > input, .guide-filter .field > select { width: 100%; }
.guide-filteract { display: flex; gap: var(--s2); align-items: center; flex: none; }

/* What is narrowing the list, each removable on its own — "Clear" used to drop
   the query and the category together, so dropping one meant retyping the
   other. */
.guide-active { display: flex; gap: var(--s2); flex-wrap: wrap; margin: calc(-1 * var(--s3)) 0 var(--s4); }
.guide-chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 3px 10px; border-radius: 999px;
  border: 1px solid var(--edge); background: var(--raised-2);
  color: var(--ink-2); text-decoration: none; font-size: var(--t-small);
}
.guide-chip:hover { border-color: var(--down); color: var(--ink); }

/* ── Pictures on a guide, and the lightbox ────────────────────────────────
   The art is the GAME's, hotlinked from taming.io like every other sprite on
   this site — the wiki's own 5,691 images are not copied here, because the
   text licence that lets us mirror the articles does not cover them. See
   guideArt() in routes/guides.ts. */
.guide-art { margin: 0 0 var(--s5); }
.guide-artrow { display: flex; gap: var(--s3); flex-wrap: wrap; align-items: flex-start; }
.guide-arttile { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.guide-artlabel { font-size: var(--t-micro); text-transform: lowercase; }
/* A FIXED tile with the sprite centred in it. Sizing by the picture instead
   was the first attempt and it failed on the first thing tried: scaling every
   sprite to one height turned three of the Bear's four — body, tail, attack —
   into slivers a few pixels wide, because those are thin strips. The tile is
   the constant; the picture fits inside it whatever its shape. */
.shot {
  /* --shot-w/--shot-h are the tile MINUS its own padding and border: the box
     the picture actually has to live inside. They exist because a percentage
     cannot do this job here — see .shot img below. */
  --shot-w: calc(104px - 2 * var(--s2) - 2px);
  --shot-h: calc(104px - 2 * var(--s2) - 2px);
  width: 104px; height: 104px; display: grid; place-items: center;
  padding: var(--s2); border: 1px solid var(--edge); border-radius: var(--r-sm);
  background: var(--raised); cursor: zoom-in;
  transition: border-color .12s ease, transform .1s ease;
}
.shot:hover { border-color: var(--accent); transform: translateY(-2px); }
.shot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.shot img {
  /* ABSOLUTE caps, never percentages. .shot is a <button>, and a button lays
     its children out in an anonymous box that is not a definite containing
     block, so `height: 100%` has nothing to resolve against and is dropped.
     Measured on /guides/acidog: the rule below WAS in the sheet, correctly
     parsed, matching the element — and a 133x200 sprite still rendered
     133x200-scaled-to-80x120 inside a 104x104 tile, overflowing the label
     beneath it. `place-self: stretch` did not help either; stretch does not
     apply to a replaced element with an intrinsic aspect ratio. 7 of 11 tiles
     overflowed, a game sprite among them, so this predates the wiki gallery
     and was easier to miss with three tiles than with eleven.
     max-width/max-height in px shrink the picture to fit and keep its ratio,
     which is what object-fit: contain would have done had it been given a box. */
  max-width: var(--shot-w); max-height: var(--shot-h);
  width: auto; height: auto;
  display: block; /* Sprites are small pixel art; a smoothed upscale turns them to mush. */
  image-rendering: pixelated;
}
.guide-art figcaption { margin-top: var(--s2); font-size: var(--t-micro); line-height: 1.5; }

.lightbox {
  position: fixed; inset: 0; z-index: 120;
  background: rgba(0,0,0,.82);
  display: flex; align-items: center; justify-content: center;
  padding: var(--s5);
}
.lightbox-frame { display: flex; flex-direction: column; gap: var(--s3); align-items: center; max-width: 100%; }
.lightbox-frame img {
  /* Enlarged means ENLARGED. Left at `width:auto;height:auto` these render at
     their natural size, and a creature sprite is about 120px — so "click to
     enlarge" opened a modal to show the same picture again. A target height
     with the width following keeps the aspect ratio and actually makes it
     bigger; `pixelated` is what stops the upscale turning to mush. */
  height: min(58vh, 420px); width: auto;
  max-width: 90vw; object-fit: contain;
  image-rendering: pixelated;
  border-radius: var(--r); background: var(--raised);
  padding: var(--s4);
  box-shadow: 0 20px 60px rgba(0,0,0,.6);
}
.lightbox-cap { margin: 0; color: var(--ink-2); font-size: var(--t-small); text-align: center; }
/* Top-right of the SCREEN, not of the picture: a sprite can be 40px wide, and
   a close button pinned to that is a target nobody can hit. */
.lightbox-close {
  position: fixed; top: var(--s4); right: var(--s4);
  width: 40px; height: 40px; border-radius: 50%;
  display: grid; place-items: center; cursor: pointer;
  border: 1px solid var(--edge); background: var(--raised); color: var(--ink);
  font-size: 22px; line-height: 1;
}
.lightbox-close:hover { border-color: var(--accent); }
.lightbox-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ── An article's discussion ──────────────────────────────────────────────
   The room under a news post is a real chat channel, rendered by the same
   msgRow — so it inherits every message style already defined above. This only
   adapts the SHELL, which was built as a three-column page and here is one
   column inside an article. */
.post-talk { margin: var(--s6) 0 var(--s5); }
.chat-shell.is-embedded {
  display: block; margin-top: var(--s3);
}
.chat-shell.is-embedded .chat-main { border-radius: var(--r); }
.chat-shell.is-embedded .chat-scroll { height: auto; max-height: 420px; }
.chat-shell.is-embedded .chat-send { padding: var(--s3) var(--s3) 0; }

/* "Load earlier messages" — only rendered when there IS earlier, so it never
   sits there doing nothing. */
.chat-older { display: flex; justify-content: center; padding: var(--s2) 0 var(--s3); }

/* @everyone is a mention of you that nobody typed your name for; it reads
   louder than a personal one on purpose. */
.chat-mention.is-room { letter-spacing: .01em; font-weight: 700; }

/* Opt-in browser alerts for mentions. A link, not a button — it belongs with
   "Mark everything read" as one of the rail's two quiet settings. */
.chat-notify { display: block; margin-top: 4px; font-size: var(--t-micro); color: var(--ink-3); }
.chat-notify:hover { color: var(--ink); }
.chat-notify[aria-pressed="true"] { color: var(--accent); }



/* ── Profiles (/u/:username) ───────────────────────────────────────────── */
.prof-head {
  display: flex; gap: var(--s4); align-items: flex-start;
  margin-bottom: var(--s5); flex-wrap: wrap;
}
/* The same hue-from-id circle the chat uses, at a size that reads as a
   portrait rather than a bullet. */
.prof-face {
  /* Same fallback reasoning as .chat-avatar: a bare var(--h) with nothing
     behind it invalidates the whole background and the face renders clear. */
  --h: 0;
  width: 72px; height: 72px; border-radius: 50%; flex: none;
  display: grid; place-items: center;
  font-family: var(--display); font-size: 30px; font-weight: 700;
  color: #fff; background: hsl(var(--h) 45% 38%);
}
.prof-id { min-width: 0; flex: 1 1 260px; }
.prof-id h1 { margin: 0 0 var(--s2); }
.prof-tags { display: flex; gap: var(--s2); flex-wrap: wrap; margin: 0 0 var(--s2); }
/* The house .grid takes its bottom margin from an inline style at every other
   call site; the profile stacks several sections, so it needs the rule. */
.prof-body .grid { margin-bottom: var(--s5); }
.prof-body h2 { margin-top: var(--s6); }
.prof-body h2:first-of-type { margin-top: var(--s5); }
.prof-list { list-style: none; padding: 0; margin: 0 0 var(--s4); }
.prof-list li {
  padding: var(--s2) 0; border-top: 1px solid var(--edge);
  display: flex; gap: var(--s3); flex-wrap: wrap; align-items: baseline;
}
.prof-said {
  flex-basis: 100%; color: var(--ink-2); font-size: var(--t-small);
  overflow-wrap: anywhere;
}

/* The reverse code: the page tips the other way for a moment. Deliberately
   short and non-blocking — an effect you must reload to escape is a prank. */
.tt-mirror #main { animation: tt-mirror 1.1s ease; }
@keyframes tt-mirror {
  0%   { transform: none; }
  35%  { transform: rotateY(8deg) translateX(-6px); }
  70%  { transform: rotateY(-4deg) translateX(3px); }
  100% { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .tt-mirror #main { animation: none; }
}

/* ── Pictures placed inside an article ───────────────────────────────────
   Wider than a gallery tile, because one placed mid-article is being LOOKED
   at rather than scanned past, and centred so the prose flows around it. */
/* NOT .prose-img, which is the newsroom's bare <img> a few thousand lines up.
   Both were spelled `.prose-img` for two different elements, so this rule gave
   that <img> `display: flex`, and its rule gave this <figure> a border and a
   `min-width: min(420px, 100%)` floor meant for a picture, not a wrapper. */
.wiki-figure { margin: var(--s5) 0; display: flex; flex-direction: column; align-items: center; gap: var(--s2); }
.wiki-figure figcaption { text-align: center; font-size: var(--t-small); }
.shot-wide {
  width: auto; max-width: min(100%, 420px); height: auto; max-height: 340px;
  /* The wide tile is not square, so it carries its own inner box. */
  --shot-w: calc(420px - 2 * var(--s2) - 2px);
  --shot-h: calc(340px - 2 * var(--s2) - 2px);
}

/* ── The editor's picture picker ─────────────────────────────────────────*/
.pickimg { margin-top: var(--s3); }
.pickimg > summary { cursor: pointer; font-size: var(--t-small); color: var(--ink-2); }
.pickimg-row { display: flex; gap: var(--s2); flex-wrap: wrap; }
.pickimg-tile {
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  width: 92px; padding: var(--s2); cursor: pointer;
  background: var(--raised); border: 1px solid var(--edge); border-radius: var(--r-sm);
  font-size: var(--t-micro); color: var(--ink-3); text-align: center;
}
.pickimg-tile:hover, .pickimg-tile:focus-visible { border-color: var(--accent); color: var(--ink); }
.pickimg-tile img { width: 100%; height: 56px; object-fit: contain; }
.pickimg-search { display: flex; gap: var(--s2); margin-bottom: var(--s3); }
.pickimg-search input { flex: 1; min-width: 0; }
/* ── Arrivals ────────────────────────────────────────────────────────────
   MOVEMENT, NOT OPACITY, and the reason is not taste. A background tab
   freezes animations at frame 0, so an entrance that fades from opacity 0
   leaves the content genuinely invisible until the tab is looked at — a table
   that reads as empty. Frame 0 of a translate is still fully drawn, so the
   worst case here is "it arrived already", which is exactly what you want. */
.tt-arrive {
  animation: tt-arrive .34s cubic-bezier(.2, .8, .3, 1) both;
  animation-delay: var(--d, 0ms);
}
@keyframes tt-arrive {
  from { transform: translateY(7px); }
  to   { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .tt-arrive { animation: none; }
}
/* ── "?" explanations ────────────────────────────────────────────────────
   The detail is kept, the lecture is not: it lives behind the mark rather
   than in front of every reader who did not ask. */
.why-wrap { display: inline; }
.why { display: inline-block; vertical-align: baseline; }
.why > summary {
  display: inline-grid; place-items: center; cursor: help; list-style: none;
  width: 15px; height: 15px; margin-left: 4px; border-radius: 50%;
  border: 1px solid var(--edge); color: var(--ink-3);
  font-size: 10px; line-height: 1; font-weight: 700;
}
.why > summary::-webkit-details-marker { display: none; }
.why > summary:hover, .why > summary:focus-visible { color: var(--ink); border-color: var(--accent); }
.why[open] > summary { color: var(--ink); border-color: var(--accent); }
/* `display: block` here overrode the way <details> hides its own contents, so
   the explanation stayed on the page whether the mark was open or not — the
   one thing this component exists to prevent. Shown only when open. */
.why:not([open]) > .why-body { display: none; }
.why[open] > .why-body {
  display: block; margin-top: var(--s2); padding: var(--s2) var(--s3);
  border-left: 2px solid var(--edge); color: var(--ink-2);
  font-size: var(--t-small); line-height: 1.55; max-width: 62ch;
}
/* ── Touch targets ───────────────────────────────────────────────────────
   Measured at 375px: the "?" mark rendered 15x15 and the smallest control in
   a message's toolbar 18px. Both are well under any touch guidance (Apple 44,
   Android 48dp), and neither is a problem with a mouse — so the fix is scoped
   to COARSE POINTERS rather than to a width. A narrow desktop window is still
   a mouse; a wide tablet is still a finger.

   The visual size is left alone. What grows is the hit area, via padding on
   the control and a negative margin that gives the space back to the layout,
   so nothing shifts on a phone that did not shift on a desktop. */
@media (pointer: coarse) {
  .why > summary {
    width: 30px; height: 30px; margin: -7px -7px -7px -3px;
    padding: 7px; box-sizing: content-box;
    background-clip: content-box;
  }
  .chat-tools { gap: var(--s3); }
  .chat-tools > a,
  .chat-tools > button,
  .chat-tools details > summary {
    min-width: 32px; min-height: 32px;
    display: inline-grid; place-items: center;
  }
}
/* ── Like bar ────────────────────────────────────────────────────────*/
.socialbar { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; margin: var(--s4) 0; }
.social-form { display: inline; margin: 0; }
.social-like {
  cursor: pointer; font: inherit; font-size: var(--t-small);
  padding: 3px var(--s2); border-radius: 999px;
  border: 1px solid var(--edge); background: var(--raised); color: var(--ink-2);
}
.social-like:hover { border-color: var(--accent); color: var(--ink); }
.social-like.on { color: var(--accent); border-color: var(--accent-dim); }
.social-count { font-size: var(--t-small); color: var(--ink-3); }
@media (pointer: coarse) {
  .social-like { min-height: 32px; padding: 4px var(--s3); }
}
.plug-price { margin: var(--s2) 0 0; font-size: var(--t-small); color: var(--ink-3); }
.plug-price.on { color: var(--verified); }
.plug-buy { margin: var(--s2) 0 0; }
/* The author's editor, at the foot of the plugin's own page. */
.plug-editor { margin-top: var(--s6); }
.plug-editor h2 { margin-top: 0; }
.plug-editor textarea { resize: vertical; }
/* The four settings, on one line where there is room for it: they are short
   numeric answers and stacking them made the form twice as tall as the three
   text fields above it, which are the ones worth reading. */


/* ── Collection tiles ──────────────────────────────────────────────────
   A sprite, a count badge, a name — the way the game's own collection
   panels show a set. auto-fill rather than a fixed column count so the grid
   reflows instead of scrolling sideways on a phone. */
.tilegrid {
  display: grid; gap: var(--s2); margin: 0 0 var(--s4);
  grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
}
.tilegrid-head { margin: var(--s5) 0 var(--s1); }
/* An entry whose picture the mirror does not hold. A box the size of the
   sprite, so one missing file does not make its tile shorter than the rest of
   the row — and deliberately empty rather than a placeholder icon, which
   would read as "this is what it looks like". */
.tile-blank {
  width: 48px; height: 48px;
  border: 1px dashed var(--edge); border-radius: var(--r-sm);
}
.tile {
  position: relative; display: flex; flex-direction: column; align-items: center;
  gap: 2px; padding: var(--s2) 4px 4px;
  background: var(--sunken); border: 1px solid var(--edge); border-radius: var(--r-sm);
  color: var(--ink-2); text-decoration: none;
  transition: border-color .12s ease, background .12s ease;
}
.tile:hover { border-color: var(--accent); background: var(--raised); text-decoration: none; }
.tile img {
  width: 48px; height: 48px; object-fit: contain;
  image-rendering: pixelated;
}
/* Bottom-right, over the sprite, like the game's own quantity badge. */
.tile-n {
  position: absolute; right: 3px; top: 3px;
  padding: 0 4px; border-radius: 999px;
  background: var(--raised-2); border: 1px solid var(--edge);
  font-size: var(--t-micro); color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.tile-name {
  font-size: var(--t-micro); text-align: center; line-height: 1.25;
  /* Two lines, so a long name does not make one tile taller than its row. */
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden;
}

/* ── Bot Builder (/plugins/builder) ───────────────────────────────────────── */
/* The editor is a full-screen tool: the window is the canvas, nothing scrolls
   as a page, and the wheel belongs to the workspace. */
body.app { height: 100vh; height: 100dvh; overflow: hidden; display: flex; flex-direction: column; }
body.app > footer { display: none; }
body.app main#main { flex: 1; min-height: 0; max-width: none; margin: 0; padding: 0; background: none; border: 0; border-radius: 0; box-shadow: none; display: flex; }
.bb-app { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.bb-bar { display: flex; align-items: center; gap: var(--s2); padding: 6px var(--s3); background: var(--raised); border-bottom: 1px solid var(--edge); min-width: 0; }
.bb-spacer { flex: 1; }
.bb-grp { display: flex; gap: 2px; }
.bb-app .bb-icon svg, .bb-app .bb-btn svg { display: block; flex: none; }
.bb-app .bb-icon { display: inline-grid; place-items: center; flex: none; width: 32px; height: 32px; padding: 0; margin: 0; border: 1px solid transparent; border-radius: var(--r-sm); background: transparent; box-shadow: none; color: var(--ink-2); font-size: 16px; line-height: 1; cursor: pointer; list-style: none; text-decoration: none; }
.bb-app .bb-icon:hover { background: var(--raised-2); border-color: var(--edge); color: var(--ink); transform: none; filter: none; }
.bb-app .bb-icon:disabled { opacity: .35; cursor: default; background: transparent; border-color: transparent; }
.bb-app .bb-icon[aria-pressed="true"] { color: var(--accent); }
.bb-app .bb-btn { display: inline-flex; align-items: center; gap: 6px; flex: none; height: 32px; padding: 0 var(--s3); margin: 0; border: 1px solid var(--edge); border-radius: var(--r-sm); background: var(--raised-2); box-shadow: none; color: var(--ink); font-size: var(--t-small); font-weight: 600; white-space: nowrap; cursor: pointer; }
.bb-app .bb-btn:hover { border-color: var(--ink-3); transform: none; filter: brightness(108%); }
.bb-app .bb-btn:disabled { opacity: .45; cursor: default; }
.bb-app .bb-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.bb-app .bb-sm { height: 26px; padding: 0 var(--s2); font-size: var(--t-micro); }
[data-skin="tio"] .bb-app .bb-btn { box-shadow: inset 0 -3px 0 rgb(0 0 0 / 0.25); }
.bb-app input.bb-name { flex: 0 1 240px; min-width: 8ch; width: auto; height: 32px; padding: 0 var(--s2); margin: 0; border: 1px solid transparent; border-radius: var(--r-sm); background: transparent; box-shadow: none; color: var(--ink); font-weight: 700; text-overflow: ellipsis; }
.bb-app input.bb-name:hover { border-color: var(--edge); }
.bb-app input.bb-name:focus { border-color: var(--accent); background: var(--sunken); }
.bb-save { color: var(--ink-3); font-size: var(--t-micro); white-space: nowrap; }
.bb-save.bad { color: var(--down); }
.bb-app .bb-pchip { flex: none; height: 24px; padding: 0 10px; margin: 0; border: 0; border-radius: 999px; background: rgb(251 191 36 / .16); box-shadow: none; color: var(--probable); font-size: var(--t-micro); font-weight: 700; cursor: pointer; }
.bb-app .bb-pchip.err { background: rgb(251 113 133 / .16); color: var(--down); }
.bb-app .bb-pchip:hover { transform: none; filter: brightness(115%); }
.bb-find { position: relative; flex: 0 1 260px; min-width: 120px; }
.bb-app input.bb-search { width: 100%; height: 32px; padding: 0 28px 0 var(--s3); margin: 0; border-radius: var(--r-sm); box-shadow: none; font-size: var(--t-small); }
.bb-kbd { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); pointer-events: none; font-size: var(--t-micro); color: var(--ink-3); }
.bb-search:focus + .bb-kbd { display: none; }
.bb-found { position: absolute; left: 0; top: calc(100% + 4px); width: max(100%, 340px); max-height: 60vh; overflow: auto; overscroll-behavior: contain; padding: 4px; background: var(--raised); border: 1px solid var(--edge); border-radius: var(--r); box-shadow: 0 12px 32px rgb(0 0 0 / .35); z-index: 200; }
.bb-app .bb-hit { display: flex; align-items: center; gap: var(--s2); width: 100%; padding: 6px var(--s2); margin: 0; border: 0; border-radius: var(--r-sm); background: transparent; box-shadow: none; color: var(--ink); font-size: var(--t-small); font-weight: 500; text-align: left; cursor: pointer; }
.bb-app .bb-hit.on, .bb-app .bb-hit:hover { background: var(--raised-2); transform: none; filter: none; }
.bb-app .bb-chipblk { display: inline-block; max-width: 100%; padding: 5px 10px; color: #fff; font: 600 12px/1.3 system-ui, sans-serif; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-shadow: inset 0 -2px 0 rgb(0 0 0 / .18); }
.bb-app .bb-chip-hat { border-radius: 14px 14px 6px 6px; }
.bb-app .bb-chip-value { border-radius: 999px; }
.bb-app .bb-chip-bool { clip-path: polygon(8px 0, calc(100% - 8px) 0, 100% 50%, calc(100% - 8px) 100%, 8px 100%, 0 50%); padding: 5px 14px; border-radius: 0; }
.bb-sw { flex: none; width: 10px; height: 10px; border-radius: 3px; }
.bb-menu { position: relative; }
.bb-menu > summary::-webkit-details-marker { display: none; }
.bb-menu[open] > summary { background: var(--raised-2); color: var(--ink); }
.bb-menu-list { position: absolute; right: 0; top: calc(100% + 4px); min-width: 180px; padding: 4px; display: flex; flex-direction: column; background: var(--raised); border: 1px solid var(--edge); border-radius: var(--r); box-shadow: 0 12px 32px rgb(0 0 0 / .35); z-index: 200; }
.bb-app .bb-menu-list > * { display: block; width: 100%; padding: 8px var(--s3); margin: 0; border: 0; border-radius: var(--r-sm); background: transparent; box-shadow: none; color: var(--ink); font-size: var(--t-small); font-weight: 500; text-align: left; cursor: pointer; }
.bb-app .bb-menu-list > *:hover { background: var(--raised-2); transform: none; filter: none; }
.bb-app :focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.bb-main { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 6px clamp(320px, 28vw, 440px); }
.bb-noside .bb-main { grid-template-columns: minmax(0, 1fr); }
.bb-noside .bb-side, .bb-noside .bb-split { display: none; }
.bb-split { cursor: col-resize; background: var(--edge); touch-action: none; }
.bb-split:hover, .bb-split:focus-visible { background: var(--accent); outline: none; }
.bb-ws { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
.bb-tip { position: absolute; top: 12px; max-width: 320px; padding: 8px 12px; background: var(--raised); border: 1px solid var(--edge); border-radius: var(--r-sm); box-shadow: 0 6px 18px rgb(0 0 0 / .3); color: var(--ink-2); font-size: var(--t-small); pointer-events: none; z-index: 60; }
.bb-mini { position: absolute; bottom: 12px; width: 160px; height: 100px; background: color-mix(in srgb, var(--raised) 85%, transparent); border: 1px solid var(--edge); border-radius: var(--r-sm); opacity: .45; cursor: pointer; z-index: 50; }
.bb-mini:hover { opacity: 1; }

.bb-side { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--raised); }
.bb-tabs { display: flex; flex: none; padding: 0 var(--s2); border-bottom: 1px solid var(--edge); overflow-x: auto; scrollbar-width: none; }
.bb-app .bb-tab { display: inline-flex; align-items: center; gap: 4px; flex: none; padding: 10px 7px; margin: 0; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; box-shadow: none; color: var(--ink-3); font-size: var(--t-small); font-weight: 600; cursor: pointer; }
.bb-app .bb-tab:hover { color: var(--ink); transform: none; filter: none; }
.bb-app .bb-tab.on { color: var(--ink); border-bottom-color: var(--accent); }
.bb-count { min-width: 18px; padding: 1px 5px; border-radius: 999px; background: var(--down); color: var(--sunken); font-size: var(--t-micro); line-height: 1.3; text-align: center; }
.bb-count:empty { display: none; }
.bb-pane { display: none; flex: 1; min-height: 0; padding: var(--s3); overflow: auto; overscroll-behavior: contain; font-size: var(--t-small); }
.bb-pane.on { display: block; }
.bb-pane h3 { margin: var(--s4) 0 var(--s1); color: var(--ink-3); font-size: var(--t-micro); text-transform: uppercase; letter-spacing: .06em; }
.bb-pane h3:first-child { margin-top: 0; }
.bb-empty { padding: var(--s5) 0; color: var(--ink-3); text-align: center; margin: 0; }

.bb-field { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); padding: 6px 0; margin: 0; border-bottom: 1px solid var(--edge-soft); }
.bb-field > span { color: var(--ink-2); }
.bb-app .bb-field input, .bb-app .bb-field select, .bb-app .bb-row input[type="text"], .bb-app select.bb-select { width: auto; max-width: 60%; height: 28px; padding: 0 var(--s2); margin: 0; border-radius: var(--r-sm); box-shadow: none; font-size: var(--t-small); }
.bb-app .bb-field input[type="text"] { width: 200px; }
.bb-app .bb-field input[type="number"] { width: 90px; background: var(--sunken); color: var(--ink); border: 1px solid var(--edge); }
.bb-app .bb-field input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); }
.bb-app .bb-field input[type="color"] { width: 44px; padding: 2px; }
.bb-row { display: flex; align-items: center; gap: var(--s2); padding: 6px 0; border-bottom: 1px solid var(--edge-soft); flex-wrap: wrap; }
.bb-row > input[type="checkbox"] { accent-color: var(--accent); }
.bb-row .bb-grow, .bb-grow { flex: 1; min-width: 0; }
.bb-row .bb-row { flex-basis: 100%; padding-left: var(--s4); border-bottom: 0; }

.bb-simbar { display: flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2); }
.bb-app select.bb-select { max-width: none; }
.bb-simframe { display: block; width: 100%; aspect-ratio: 4 / 3; border: 1px solid var(--edge); border-radius: var(--r-sm); background: #2b2f26; }
.bb-simout details { border-bottom: 1px solid var(--edge-soft); padding: 6px 0; }
.bb-simout summary { cursor: pointer; color: var(--ink-3); font-size: var(--t-micro); font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.bb-simout ul { margin: var(--s1) 0 0; padding-left: 1.1em; font-family: var(--mono); font-size: var(--t-micro); }
.bb-simout pre, .bb-code { margin: var(--s1) 0 0; padding: var(--s2); background: var(--sunken); border: 1px solid var(--edge); border-radius: var(--r-sm); font-family: var(--mono); font-size: var(--t-micro); white-space: pre-wrap; word-break: break-word; overflow: auto; }
.bb-simout pre { max-height: 200px; }
.bb-simout pre:empty { display: none; }
.bb-scen textarea { width: 100%; margin: var(--s1) 0; box-sizing: border-box; font-family: var(--mono); font-size: var(--t-micro); }
.bb-outbar { display: flex; justify-content: flex-end; margin-bottom: var(--s1); color: var(--ink-3); font-size: var(--t-micro); }
.bb-code { margin: 0; }
.bb-app .bb-problem { display: block; width: 100%; padding: 8px 10px; margin: 0 0 4px; border: 0; border-left: 3px solid var(--probable); border-radius: 0 var(--r-sm) var(--r-sm) 0; background: var(--raised-2); box-shadow: none; color: var(--ink); font-size: var(--t-small); font-weight: 500; text-align: left; cursor: pointer; }
.bb-app .bb-problem.err { border-left-color: var(--down); }
.bb-app .bb-problem:hover { transform: none; filter: brightness(110%); }
.bb-toasts { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: 6px; z-index: 300; pointer-events: none; }
.bb-toast { display: flex; align-items: center; gap: var(--s3); padding: 8px 8px 8px 14px; background: var(--ink); color: var(--sunken); border-radius: var(--r); box-shadow: 0 8px 24px rgb(0 0 0 / .35); font-size: var(--t-small); pointer-events: none; }
.bb-app .bb-toast-act { margin: 0; padding: 4px 10px; border: 0; border-radius: var(--r-sm); background: transparent; box-shadow: none; color: var(--accent); font-size: var(--t-small); font-weight: 700; cursor: pointer; }
.bb-app .bb-toast-act:hover { background: rgb(127 127 127 / .2); transform: none; }
.bb-keyrow { display: grid; grid-template-columns: 170px 1fr; align-items: center; gap: var(--s3); padding: 5px 0; border-bottom: 1px solid var(--edge-soft); }
.bb-keyrow kbd { justify-self: start; }
.bb-tl { position: relative; }
.bb-vprev { width: min(900px, calc(100vw - 32px)); }
.bb-vprev-ws { height: min(60vh, 520px); margin: var(--s2) var(--s3); border: 1px solid var(--edge); border-radius: var(--r-sm); overflow: hidden; }
.bb-ws .categoryBubble { display: grid; place-items: center; }
.bb-ws .blocklyToolboxCategoryLabel { white-space: nowrap; }
.bb-scrubrow { display: flex; align-items: center; gap: var(--s2); margin-bottom: var(--s2); }
.bb-app input.bb-scrub { flex: 1; width: auto; height: auto; padding: 0; margin: 0; border: 0; background: transparent; box-shadow: none; accent-color: var(--accent); }
.bb-scentools { display: flex; flex-wrap: wrap; gap: 4px; padding: var(--s2) var(--s3) 0; }
.bb-app .bb-scentool { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 4px 8px; border: 1px solid var(--edge); border-radius: 999px; background: transparent; box-shadow: none; color: var(--ink-2); font-size: var(--t-micro); cursor: pointer; }
.bb-app .bb-scentool.on { border-color: var(--accent); color: var(--ink); }
.bb-app .bb-scentool:hover { transform: none; }
.bb-scencanvas { display: block; width: calc(100% - 2 * var(--s3)); margin: var(--s2) var(--s3); aspect-ratio: 8 / 5; border-radius: var(--r-sm); cursor: crosshair; touch-action: none; }
.bb-teststatus { color: var(--ink-3); font-size: var(--t-micro); }
.bb-test { margin: 0 0 var(--s2); border: 1px solid var(--edge); border-left: 3px solid var(--accent); border-radius: var(--r-sm); background: var(--raised-2); }
.bb-test.bad { border-left-color: var(--down); }
.bb-app .bb-test-head { display: flex; align-items: center; gap: var(--s2); width: 100%; margin: 0; padding: 8px 10px; border: 0; border-radius: 0; background: transparent; box-shadow: none; color: var(--ink); font-size: var(--t-small); text-align: left; cursor: pointer; }
.bb-app .bb-test-head:hover { transform: none; filter: brightness(110%); }
.bb-test-head span { margin-left: auto; color: var(--ink-3); font-size: var(--t-micro); }
.bb-test-line { margin: 0; padding: 2px 10px 6px 28px; color: var(--ink-2); font-family: var(--mono); font-size: var(--t-micro); }
.bb-test-line.bad { color: var(--down); }
.bb-app .bb-warnbtn { background: var(--probable); border-color: var(--probable); }
.bb-ghostblk { opacity: .45; }
.bb-glow { filter: drop-shadow(0 0 3px #ffd34d) drop-shadow(0 0 7px #ffd34d); }
.bb-bubble { position: absolute; transform: translate(-50%, calc(-100% - 8px)); max-width: 260px; padding: 4px 10px; background: var(--ink); color: var(--sunken); border-radius: 999px; font-family: var(--mono); font-size: var(--t-micro); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; z-index: 70; }
.bb-watch { position: absolute; right: 12px; top: 12px; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; pointer-events: none; z-index: 55; }
.bb-chip { display: inline-flex; gap: 6px; padding: 2px 8px; background: color-mix(in srgb, var(--raised) 90%, transparent); border: 1px solid var(--edge); border-radius: 999px; font-size: var(--t-micro); }
.bb-chip span { font-family: var(--mono); color: var(--ink-2); }
.bb-modal { width: min(560px, calc(100vw - 32px)); max-height: 80vh; padding: 0; background: var(--raised); color: var(--ink); border: 1px solid var(--edge); border-radius: var(--r); box-shadow: 0 20px 50px rgb(0 0 0 / .45); }
.bb-modal::backdrop { background: rgb(0 0 0 / .45); }
.bb-modal[open] { display: flex; flex-direction: column; }
.bb-modal-head, .bb-modal-foot { display: flex; align-items: center; justify-content: space-between; gap: var(--s2); padding: var(--s2) var(--s3); }
.bb-modal-head { border-bottom: 1px solid var(--edge); }
.bb-modal-foot { justify-content: flex-end; border-top: 1px solid var(--edge); }
.bb-app .bb-modal textarea { margin: var(--s3); width: auto; font-family: var(--mono); font-size: var(--t-micro); }
.bb-checkgrid { overflow: auto; padding: 0 var(--s3) var(--s2); font-size: var(--t-small); }
.bb-checkrow { display: grid; grid-template-columns: 12px minmax(110px, auto) 1fr; align-items: center; gap: var(--s2); padding: 4px 0; border-bottom: 1px solid var(--edge-soft); }
.bb-checkval { color: var(--ink-2); font-family: var(--mono); font-size: var(--t-micro); word-break: break-word; }
.bb-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-3); }
.bb-dot.ok { background: var(--accent); }
.bb-dot.bad { background: var(--down); }
@media (max-width: 900px) {
  .bb-main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 42%; }
  .bb-side { border-top: 1px solid var(--edge); }
  .bb-split { display: none; }
  .bb-noside .bb-main { grid-template-rows: minmax(0, 1fr); }
  .bb-kbd, .bb-save, .bb-mini { display: none; }
  .bb-find { flex: 1 1 100px; }
}
@media (max-width: 560px) {
  .bb-bar { flex-wrap: wrap; row-gap: 6px; }
  .bb-grp, .bb-spacer, .bb-app .bb-pchip, #bb-runlabel { display: none; }
  .bb-app input.bb-name { flex: 1 1 0; min-width: 0; }
  .bb-find { order: 10; flex: 1 1 100%; }
}

.bb-home { width: 960px; max-width: calc(100vw - 2 * var(--s5)); }
.bb-home-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); flex-wrap: wrap; }
.bb-home-head form { margin: 0; display: flex; align-items: stretch; gap: var(--s2); }
.bb-home-head label { cursor: pointer; display: inline-flex; align-items: center; margin: 0; }
.bb-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: var(--s3); margin-bottom: var(--s5); }
.bb-card { display: flex; flex-direction: column; background: var(--raised); border: 1px solid var(--edge); border-radius: var(--r); overflow: hidden; transition: border-color .12s ease; }
.bb-card:hover { border-color: var(--accent); }
.bb-home a.bb-tile { display: block; aspect-ratio: 16 / 9; overflow: hidden; background: var(--sunken); color: var(--ink); text-decoration: none; }
.bb-thumb { display: block; width: 100%; height: 100%; padding: 10px; box-sizing: border-box; opacity: .9; }
.bb-card-body { display: flex; flex-direction: column; gap: 2px; padding: var(--s2) var(--s3) 0; }
.bb-home input.bb-rename { width: 100%; height: auto; padding: 2px 4px; margin: 0 -4px; border: 1px solid transparent; border-radius: var(--r-sm); background: transparent; box-shadow: none; color: var(--ink); font-size: var(--t-body); font-weight: 700; }
.bb-home input.bb-rename:hover { border-color: var(--edge); }
.bb-home input.bb-rename:focus { border-color: var(--accent); background: var(--sunken); }
.bb-card-meta { color: var(--ink-3); font-size: var(--t-micro); }
.bb-home-row { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); flex-wrap: wrap; }
.bb-home-tools { display: flex; gap: var(--s2); }
.bb-home-tools input, .bb-home-tools select { width: auto; padding: 4px 8px; }
.bb-card-acts { display: flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s2) var(--s3) var(--s3); }
.bb-card-acts form { margin: 0; }
.bb-tpls { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--s3); margin-bottom: var(--s5); }
.bb-tpls form { margin: 0; }
.bb-tpl-btn { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; gap: var(--s2); width: 100%; height: 100%; min-height: 64px; padding: var(--s2); text-align: left; background: var(--raised); color: var(--ink); border: 1px solid var(--edge); border-radius: var(--r); font-size: var(--t-small); cursor: pointer; }
.bb-tpl-btn:hover { border-color: var(--accent); }
.bb-tpl-blank { border-style: dashed; color: var(--accent); justify-content: center; min-height: 150px; text-align: center; font-size: var(--t-body); }
.bb-tpl-btn span { padding: 0 var(--s1) var(--s1); }
.bb-tpl-btn .bb-tpl-art { position: relative; display: block; padding: 0; aspect-ratio: 16 / 9; border-radius: var(--r-sm); background: var(--sunken); overflow: hidden; }
.bb-tpl-art .bb-thumb, .bb-tpl-art .bb-tpl-prev { position: absolute; inset: 0; width: 100%; height: 100%; aspect-ratio: auto; background: transparent; }
.bb-tpl-btn .bb-tpl-desc { color: var(--ink-3); font-size: var(--t-micro); font-weight: 400; line-height: 1.35; padding-top: 0; }
.bb-tpl-prev, .bb-tpl-own .bb-thumb { display: block; width: 100%; aspect-ratio: 16 / 9; height: auto; border-radius: var(--r-sm); background: var(--sunken); }
.bb-gone summary { cursor: pointer; color: var(--ink-3); }
.bb-list { list-style: none; padding: 0; margin: var(--s2) 0 0; }
.bb-item { display: flex; align-items: center; gap: var(--s3); padding: var(--s2) 0; border-bottom: 1px solid var(--edge-soft); flex-wrap: wrap; }
.bb-item-name { font-weight: 600; }
.bb-item form { margin: 0 0 0 auto; }
.bb-muted { color: var(--ink-3); }
.bb-profrow { display: grid; grid-template-columns: 1fr auto auto; align-items: center; column-gap: var(--s2); padding: 4px 0; border-bottom: 1px solid var(--edge-soft); }
.bb-profrow > .bb-muted { grid-column: 1 / 3; font-size: var(--t-micro); overflow-wrap: anywhere; }
.bb-profrow > .bb-icon { grid-row: 1; grid-column: 3; }
/* the simulator's debugger */
.bb-timeline { display: block; width: 100%; margin-top: var(--s2); color: var(--ink-3); cursor: crosshair; }
.bb-spark { width: 64px; height: 14px; margin-left: 6px; vertical-align: middle; fill: none; stroke: var(--accent); stroke-width: 1.2; }
.bb-why { position: absolute; z-index: 70; padding: 8px 10px; background: var(--raised); border: 1px solid var(--edge); border-radius: var(--r-sm); box-shadow: 0 4px 14px rgba(0,0,0,.18); font-size: var(--t-micro); display: grid; gap: 6px; }
.bb-why-sum { color: var(--ink); }
.bb-why-strip { display: block; border-radius: 2px; }
.bb-why-key { display: flex; gap: 10px; color: var(--ink-3); }
.bb-why-key i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 4px; vertical-align: -1px; }
.bb-prof { width: 100%; border-collapse: collapse; font-size: var(--t-micro); font-variant-numeric: tabular-nums; }
.bb-prof th, .bb-prof td { text-align: right; padding: 2px 4px; border-bottom: 1px solid var(--edge-soft); }
.bb-prof th:first-child, .bb-prof td:first-child { text-align: left; }
.bb-prof tr.bad td { color: var(--down); font-weight: 600; }
.bb-proflink { background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; text-align: left; }
.bb-proflink:hover { text-decoration: underline; }
/* editing beyond one block */
.bb-lasso { position: absolute; z-index: 60; border: 1.5px dashed #4da3ff; background: rgba(77, 163, 255, .1); pointer-events: none; }
.bb-multibar { position: absolute; z-index: 61; display: flex; align-items: center; gap: 6px; padding: 4px 6px; background: var(--raised); border: 1px solid var(--edge); border-radius: var(--r-sm); box-shadow: 0 4px 14px rgba(0,0,0,.18); }
.bb-multicount { font-size: var(--t-micro); color: var(--ink-3); padding: 0 4px; }
.bb-hatpick { position: absolute; z-index: 62; display: grid; max-height: 280px; overflow: auto; background: var(--raised); border: 1px solid var(--edge); border-radius: var(--r-sm); box-shadow: 0 4px 14px rgba(0,0,0,.18); }
.bb-hatopt { text-align: left; padding: 6px 10px; border: 0; background: none; font: inherit; font-size: var(--t-small); color: var(--ink); cursor: pointer; }
.bb-hatopt:hover, .bb-hatopt:focus-visible { background: var(--sunken); }
.bb-codemark { background: color-mix(in srgb, var(--accent) 28%, transparent); color: inherit; border-radius: 2px; }
.bb-vdiff { margin-left: var(--s2); font-size: var(--t-micro); color: var(--ink-3); font-weight: 400; }
.bb-multibar[hidden], .bb-hatpick[hidden], .bb-why[hidden] { display: none; }
.bb-namer { padding: 6px; }
.bb-namer input { width: 200px; font: inherit; font-size: var(--t-small); padding: 4px 6px; border: 1px solid var(--edge); border-radius: var(--r-sm); background: var(--sunken); color: var(--ink); }
.bb-menu-head { padding: 8px 12px 2px; font-size: var(--t-micro); font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-3); border-top: 1px solid var(--edge-soft); margin-top: 4px; }
.bb-app .bb-menu-list > .bb-snips { padding: 0; }
.bb-app .bb-menu-list > .bb-menu-head { padding: 10px var(--s3) 4px; }
.bb-app .bb-menu-list > .bb-snips:hover, .bb-app .bb-menu-list > .bb-menu-head:hover { background: transparent; }
.bb-app .bb-snips > button { display: block; width: 100%; padding: 6px var(--s3); margin: 0; border: 0; border-radius: var(--r-sm); background: transparent; box-shadow: none; color: var(--ink); font: inherit; font-size: var(--t-small); text-align: left; cursor: pointer; white-space: nowrap; }
.bb-app .bb-snips > button:hover { background: var(--raised-2); }
/* toasts never block the canvas under them; only their own button takes clicks */
.bb-toast .bb-toast-act { pointer-events: auto; }
.bb-starter { position: absolute; z-index: 40; display: flex; flex-wrap: wrap; gap: 6px; max-width: 520px; }
.bb-starter[hidden] { display: none; }
.bb-simwrap { display: grid; }
.bb-simwrap > * { grid-area: 1 / 1; }
.bb-simgo { place-self: center; display: inline-flex; align-items: center; gap: var(--s1); }
.bb-simgo[hidden] { display: none; }
.bb-shared-facts { display: grid; grid-template-columns: max-content 1fr; gap: var(--s1) var(--s3); margin: var(--s3) 0; }
.bb-shared-facts dt { color: var(--ink-3); }
.bb-shared-facts dd { margin: 0; }
.bb-shared-facts ul { margin: 0; padding-left: 1.1em; }
.bb-view { height: 60vh; min-height: 320px; border: 1px solid var(--edge); border-radius: var(--r-sm); margin-bottom: var(--s3); overflow: hidden; }
.bb-outline { margin: 0 0 var(--s2); font-size: var(--t-small); }
.bb-outline[hidden] { display: none; }
.bb-outline summary { cursor: pointer; color: var(--ink-3); font-weight: 600; padding: var(--s1) 0; }
.bb-outline ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
.bb-app .bb-outline-line { display: block; width: 100%; text-align: left; padding: 4px 6px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--ink); font: inherit; box-shadow: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bb-app .bb-outline-line:hover { background: var(--raised-2); transform: none; filter: none; }
.bb-outline-line span { color: var(--ink-3); }
.bb-demo { display: flex; align-items: center; gap: var(--s1); margin: 0 0 var(--s1); min-width: 0; }
.bb-demo .bb-chipblk { display: inline-block; min-width: 0; max-width: 100%; padding: 4px 10px; color: #fff; font: 600 12px/1.3 system-ui, sans-serif; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* ── Shop watch (/shop) ─────────────────────────────────────────────────── */
.sw { width: 960px; max-width: calc(100vw - 2 * var(--s5)); }
.sw-head { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; }
.sw-head h1 { margin: 0; }
.sw-acts { display: flex; gap: var(--s2); flex-wrap: wrap; margin-left: auto; }
.sw-acts form { margin: 0; }
.sw-top { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; }
.sw-top h1 { margin: 0; }
.sw-top-acts { display: flex; gap: var(--s2); margin-left: auto; align-items: center; }
.sw-live { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px; border: 1px solid var(--edge); border-radius: 999px; font-size: .85em; color: var(--ink-2); font-variant-numeric: tabular-nums; white-space: nowrap; }
.sw-live::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--ink-3); }
.sw-live.on::before { background: var(--up); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), .25); }
.sw-alerts { position: relative; }
.sw-pop { position: absolute; right: 0; top: calc(100% + 6px); z-index: 20; width: min(300px, calc(100vw - 32px)); display: flex; flex-direction: column; gap: var(--s2); padding: var(--s3); background: var(--raised); border: 1px solid var(--edge); border-radius: var(--r); box-shadow: 0 10px 30px rgba(0, 0, 0, .35); }
.sw-pop[hidden] { display: none; }
.sw-pop p { margin: 0; font-size: .9em; line-height: 1.45; }
.sw-pop .btn { width: 100%; justify-content: center; }
.sw-n { display: inline-block; min-width: 1.6em; padding: 0 6px; border-radius: 999px; background: var(--raised-2); color: var(--ink-2); font: 600 .7em/1.6 var(--sans); text-align: center; vertical-align: middle; }

/* The Merchant, built the way the game builds it. Sizes, offsets and colours
   are the game's own (css/main3457.css, read 2026-10-17: #buy-merchant,
   .buy-merchant, #merchant-buy-items .chest-button, #merchant-shop); the art
   is the game's, through the mirror. */
.mc { --mc-black: #3a322b; --mc-white: #ffe5c1; --mc-beige: #ddac53; --mc-b0: #9e5c33; --mc-b2: #7d4320; --mc-b3: #613011;
  --mc-outline: #3a322b 2px 0 0, #3a322b -2px 0 0, #3a322b 0 2px 0, #3a322b 0 -2px 0, #3a322b 1.5px 1.5px 0, #3a322b -1.5px 1.5px 0, #3a322b 1.5px -1.5px 0, #3a322b -1.5px -1.5px 0;
  display: flex; gap: 8px; justify-content: space-between; margin-top: var(--s4); padding: 8px; background: var(--mc-b0); border: 4px solid var(--mc-black); border-radius: 12px; color: var(--mc-white); font-family: var(--display); }
.mc-left { position: relative; display: flex; flex-direction: column; flex: 1 1 420px; min-width: 0; height: 470px; padding: 5px; background: var(--mc-b2); border-radius: 8px; }
.mc-tabs { display: flex; height: 50px; margin: 0 -4px; background: var(--mc-black); border-radius: 5px; overflow: visible; position: relative; z-index: 3; }
.mc-tab { width: 50%; max-width: 215px; height: 83px; border: 0; padding: 0 0 24px; background: center top / 100% 100% no-repeat; color: var(--mc-white); font: 400 35px/1 var(--display); text-shadow: var(--mc-outline); filter: grayscale(1); cursor: pointer; }
.mc-tab:hover, .mc-tab.on { filter: none; }
.mc-tab-buy { background-image: url('/asset/img/interface/merchant-buy-button-active.png'); }
.mc-tab-buy.on { background-image: url('/asset/img/interface/merchant-buy-button.png'); }
.mc-tab-sell { margin-left: -5px; background-image: url('/asset/img/interface/merchant-sell-button-active.png'); }
.mc-tab-sell.on { background-image: url('/asset/img/interface/merchant-sell-button.png'); }
.mc-tab:focus-visible { outline: 3px solid var(--mc-beige); outline-offset: -6px; }
.mc-info { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin: 36px -5px 6px; padding: 14px 16px 8px; background: var(--mc-black); text-shadow: none; }
.mc-info h3 { margin: 0; font-size: 18px; font-weight: 400; color: var(--mc-white); }
.mc .sw-live { border-color: var(--mc-beige); color: var(--mc-white); font-family: var(--sans); }
.mc-grid { display: flex; flex-wrap: wrap; align-content: flex-start; gap: 0 6px; flex: 1; min-height: 0; overflow-y: auto; padding: 0 7px; scrollbar-color: var(--mc-white) var(--mc-black); }
.mc-grid[hidden] { display: none; }
.mc-card { position: relative; top: 0; width: 96px; height: 111px; margin: 5px 0; padding: 0; border: 0; background: transparent; color: var(--mc-white); text-align: center; text-shadow: var(--mc-outline); font-family: var(--display); cursor: pointer; transition: top .2s, filter .2s; }
.mc-card:hover, .mc-card:focus-visible { top: -5px; filter: brightness(1.2); }
.mc-card.on { filter: brightness(1.3); }
.mc-card:focus-visible { outline: 3px solid var(--mc-beige); outline-offset: 2px; border-radius: 8px; }
.mc-bg { display: block; width: 96px; height: 111px; }
.mc-item { position: absolute; z-index: 1; top: 9px; left: 0; right: 0; margin: auto; width: 85px; height: 85px; object-fit: contain; rotate: 10deg; }
.mc-card h4 { position: absolute; z-index: 1; top: 2px; left: 3px; width: 93px; margin: 0; font-size: 12px; font-weight: 400; }
.mc-card h2 { position: absolute; z-index: 1; top: 85px; left: 18px; width: 76px; margin: 0; font-size: 18px; font-weight: 400; font-variant-numeric: tabular-nums; }
.mc-sell-item { display: block; width: 80px; height: 80px; margin: 6px auto 0; object-fit: contain; }
.mc-tag { position: absolute; bottom: 0; left: 0; width: 96px; height: auto; }
.mc-card h3 { position: absolute; z-index: 1; bottom: 4px; left: 23px; width: 60px; margin: 0; font-size: 16px; font-weight: 400; }
.mc-card.sw-hit { filter: drop-shadow(0 0 7px #ffd866) drop-shadow(0 0 2px #ffd866); }
.mc-card.sw-hit::after { content: "★"; position: absolute; z-index: 2; top: -6px; right: -4px; color: #ffd866; font-size: 22px; text-shadow: var(--mc-outline); }
.mc-card.sw-new { animation: sw-arrive .5s cubic-bezier(.2, .8, .3, 1.2); }
@keyframes sw-arrive { from { transform: translateY(-14px) scale(.9); } to { transform: none; } }
.mc-empty { margin: 40px auto; color: var(--mc-white); font-size: 18px; text-shadow: var(--mc-outline); }
.mc-right { position: relative; flex: 0 0 340px; display: flex; flex-direction: column; }
.mc-right[hidden] { display: none; }
.mc-close { position: absolute; top: 6px; right: 14px; z-index: 2; width: 32px; height: 32px; border: 3px solid #3a322b; border-radius: 50%; background: #ce5a28; color: #ffe5c1; font: 400 18px/1 var(--display); cursor: pointer; }
.mc-close:focus-visible { outline: 3px solid #ffe5c1; outline-offset: 2px; }
.mc-board { position: relative; flex: 1; margin: 0; padding: 40px 18px 14px; border-radius: 8px; background: url('/asset/img/interface/merchant-description.png') center top / 100% 100% no-repeat; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.mc-box { display: flex; width: 100%; max-width: 280px; }
.mc-box[hidden], .mc-watch[hidden] { display: none; }
.mc-box > div { position: relative; flex: 1; display: flex; flex-direction: column; justify-content: space-between; align-items: center; height: 96px; margin: 3px; background: var(--mc-b3); border-radius: 5px; box-shadow: inset 5px 25px 0 #7d4320, inset -5px -5px 0 #7d4320; text-align: center; }
.mc-box > div:first-child { flex: 0 0 96px; }
.mc-box h5 { margin: 4px 0 0; font-size: 14px; font-weight: 400; }
.mc-box img { width: 65px; height: 65px; object-fit: contain; margin-bottom: 4px; }
.mc-box h1 { margin: 0 0 14px; padding-right: 40px; font-size: 30px; font-weight: 400; text-shadow: var(--mc-outline); font-variant-numeric: tabular-nums; }
.mc-box h1::after { content: ""; position: absolute; right: 8px; bottom: 16px; width: 34px; height: 34px; background: url('/asset/img/golden-apple1.png') center / contain no-repeat; }
.mc-detail-name { margin: 0; font-size: 16px; text-shadow: var(--mc-outline); text-align: center; }
.mc-watch { width: 220px; height: 40px; border: 4px solid var(--mc-black); border-radius: 10px; background: #888888; box-shadow: inset 0 -6px 0 #606060; color: var(--mc-white); font: 400 20px/1 var(--display); text-shadow: var(--mc-outline); cursor: pointer; }
.mc-watch.on { background: #a8c636; box-shadow: inset 0 -6px 0 #6e860e; }
.mc-watch:focus-visible { outline: 3px solid var(--mc-white); outline-offset: 2px; }

/* Your list */
.sw-panel { margin-top: var(--s5); padding: var(--s4); background: var(--raised); border: 1px solid var(--edge); border-radius: var(--r); }
.sw-panel-head { display: flex; align-items: baseline; gap: var(--s3); }
.sw-panel-head h2 { margin: 0; }
.sw-saved { margin-left: auto; color: var(--ink-3); font-size: .85em; }
.sw-add { position: relative; margin: var(--s3) 0; }
.sw-add input { width: 100%; box-sizing: border-box; padding: 10px 14px; font-size: 1rem; }
.sw-add-list { position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: 15; list-style: none; margin: 0; padding: 4px; max-height: 340px; overflow-y: auto; background: var(--raised); border: 1px solid var(--edge); border-radius: var(--r); box-shadow: 0 10px 30px rgba(0, 0, 0, .35); }
.sw-add-list[hidden] { display: none; }
.sw-opt { display: flex; align-items: center; gap: var(--s3); padding: 6px 8px; border-radius: calc(var(--r) - 2px); cursor: pointer; }
.sw-opt img { width: 32px; height: 32px; object-fit: contain; flex: none; }
.sw-opt-name { flex: 1; }
.sw-opt-seen { color: var(--ink-3); font-size: .8em; font-variant-numeric: tabular-nums; }
.sw-opt-act { font-size: .8em; font-weight: 600; color: var(--accent); }
.sw-opt.is-on .sw-opt-act { color: var(--ink-3); }
.sw-opt.on, .sw-opt:hover { background: var(--raised-2); }
.sw-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.sw-row { display: grid; grid-template-columns: 40px 1fr auto auto 32px; align-items: center; gap: var(--s3); padding: 6px 8px; background: var(--sunken); border-radius: var(--r); }
.sw-row img { width: 40px; height: 40px; object-fit: contain; }
.sw-row-name { font-weight: 600; min-width: 0; overflow-wrap: anywhere; }
.sw-lim { display: inline-flex; align-items: center; gap: 6px; margin: 0; color: var(--ink-3); font-size: .8em; white-space: nowrap; }
.sw-lim input { width: 92px; padding: 5px 8px; font-size: .95rem; }
.sw-x { width: 32px; height: 32px; border: 0; border-radius: 50%; background: transparent; color: var(--ink-3); font-size: 1.3em; line-height: 1; cursor: pointer; }
.sw-x:hover, .sw-x:focus-visible { background: var(--raised-2); color: var(--ink); }
.sw-none { margin: var(--s2) 0 0; color: var(--ink-3); }

/* History, folded away */
.sw-more { margin-top: var(--s3); border: 1px solid var(--edge); border-radius: var(--r); background: var(--raised); }
.sw-more summary { padding: var(--s3) var(--s4); cursor: pointer; font-weight: 600; }
.sw-more[open] summary { border-bottom: 1px solid var(--edge-soft); }
.sw-more .sw-log { margin: 0; }
.sw-log { width: 100%; border-collapse: collapse; }
.sw-log td img { width: 24px; height: 24px; object-fit: contain; vertical-align: middle; }
.sw-log th, .sw-log td { text-align: left; padding: 6px var(--s3); border-bottom: 1px solid var(--edge-soft); }
.sw-log th.num, .sw-log td.num { text-align: right; }
.sw-log tr.sw-hit td { background: var(--raised-2); }
.sw-up { color: var(--up); }
.sw-down { color: var(--down); }

/* /shop/watcher */
.sw-facts { margin-top: var(--s4); max-width: 520px; }
.sw-facts th { width: 40%; color: var(--ink-3); font-weight: 400; }
.sw-ctl { margin: var(--s4) 0 0; }
.sw-login { display: flex; flex-direction: column; gap: var(--s2); max-width: 360px; margin-top: var(--s5); }
.sw-login label { display: flex; flex-direction: column; gap: 4px; margin: 0; }
.sw-login .btn { align-self: flex-start; }
.sw > h2 { margin-top: var(--s5); }
.sw-shot img { display: block; width: 100%; max-width: 960px; height: auto; border: 1px solid var(--edge); border-radius: var(--r); }

@media (max-width: 600px) {
  .sw { max-width: calc(100vw - 32px); }
  .sw-top-acts { margin-left: 0; width: 100%; }
  .sw-alerts { flex: 1; }
  .sw-alerts > .btn { width: 100%; justify-content: center; }
  .sw-pop { left: 0; right: auto; }
  .sw-panel { padding: var(--s3); }
  .sw-row { grid-template-columns: 40px 1fr 32px; row-gap: 6px; }
  .sw-row .sw-lim { grid-column: 2 / span 2; }
  .sw-row .sw-lim + .sw-lim { grid-column: 2 / span 2; }
  .sw-row .sw-x { grid-column: 3; grid-row: 1; }
  .sw-lim input { width: 100%; }
  .sw-lim { display: grid; grid-template-columns: 52px 1fr; }
  .sw-log { display: block; overflow-x: auto; }
  .mc { flex-direction: column; padding: 6px; }
  .mc-left { flex-basis: auto; height: 440px; }
  .mc-tab { font-size: 28px; }
  .mc-grid { justify-content: center; }
  .mc-right { flex-basis: auto; }
}

/* Shop watch alerts, on every page (public/assets/shop-alerts.js) */
.sa-stack { position: fixed; top: var(--s4); right: var(--s4); z-index: 1000; display: flex; flex-direction: column; gap: var(--s2); max-width: min(360px, calc(100vw - 32px)); }
.sa-toast { display: flex; align-items: center; gap: var(--s3); padding: var(--s3); background: var(--raised); border: 2px solid var(--accent); border-radius: var(--r); color: var(--ink); text-decoration: none; font-weight: 600; box-shadow: 0 8px 24px rgba(0, 0, 0, .35); animation: sa-in .25s cubic-bezier(.2, .8, .3, 1); }
.sa-toast img { width: 40px; height: 40px; object-fit: contain; flex: none; }
.sa-toast span { flex: 1; }
.sa-x { background: none; border: 0; color: var(--ink-3); font-size: 1.2em; cursor: pointer; padding: 0 4px; }
@keyframes sa-in { from { transform: translateX(24px); } to { transform: none; } }
@media (prefers-reduced-motion: reduce) { .sa-toast, .mc-card.sw-new { animation: none; } .mc-card { transition: none; } }
.mc-open { display: inline-flex; align-items: center; justify-content: center; width: 220px; height: 40px; border: 4px solid #3a322b; border-radius: 10px; background: #e3911c; box-shadow: inset 0 -6px 0 #dd8c21; color: #ffe5c1; font: 400 20px/1 var(--display); text-decoration: none; text-shadow: #3a322b 2px 0 0, #3a322b -2px 0 0, #3a322b 0 2px 0, #3a322b 0 -2px 0; }
.mc-open[hidden] { display: none; }
.mc-open:focus-visible { outline: 3px solid #ffe5c1; outline-offset: 2px; }

/* Price history (shop-watch.js draw()): a step line per side. Buy is solid gold, sell dashed
   blue, so the pair never rests on colour alone; both are named in the legend. */
.pc { position: relative; width: 100%; max-width: 300px; padding: 8px 8px 4px; background: #613011; border: 3px solid #3a322b; border-radius: 8px; font-family: var(--sans); color: #ffe5c1; text-shadow: none; }
.pc[hidden] { display: none; }
.pc-svg { display: block; width: 100%; height: auto; overflow: visible; touch-action: pan-y; }
.pc-grid { stroke: rgba(255, 229, 193, .18); stroke-width: 1; }
.pc-axis { fill: rgba(255, 229, 193, .7); font-size: 10px; font-variant-numeric: tabular-nums; }
.pc-line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.pc-line.pc-buy { stroke: #f0c04a; }
.pc-line.pc-sell { stroke: #7fc4ff; stroke-dasharray: 5 3; }
.pc-dot { stroke: #613011; stroke-width: 2; }
.pc-dot.pc-buy { fill: #f0c04a; }
.pc-dot.pc-sell { fill: #7fc4ff; }
.pc-cross { stroke: rgba(255, 229, 193, .5); stroke-width: 1; }
.pc-legend { display: flex; gap: 12px; margin-bottom: 4px; font-size: 12px; font-variant-numeric: tabular-nums; }
.pc-key { display: inline-flex; align-items: center; gap: 6px; }
.pc-key i { width: 14px; height: 0; border-top: 2px solid; }
.pc-key.pc-buy i { border-color: #f0c04a; }
.pc-key.pc-sell i { border-color: #7fc4ff; border-top-style: dashed; }
.pc-tip { position: absolute; bottom: calc(100% + 4px); z-index: 2; display: flex; flex-direction: column; gap: 2px; min-width: 110px; padding: 6px 8px; background: #3a322b; border-radius: 6px; font-size: 12px; font-variant-numeric: tabular-nums; pointer-events: none; }
.pc-tip[hidden] { display: none; }
.pc-tip .pc-buy { color: #f0c04a; }
.pc-tip .pc-sell { color: #7fc4ff; }
.pc-none { margin: 4px 0; font-size: 12px; opacity: .8; }
.pc-spark { display: block; margin-top: 2px; line-height: 0; }
.pc-spark .pc-svg { width: 84px; height: 24px; }
.pc-spark .pc-line.pc-buy { stroke: var(--accent); }
.pc-spark .pc-dot { stroke: var(--sunken); }
.pc-spark .pc-dot.pc-buy { fill: var(--accent); }
