/* =========================================================================
   Self-hosted webfonts. These used to come from fonts.googleapis.com, which
   a Discord Activity's iframe CSP blocks outright — and which cost every
   other visitor a third-party connection on the critical render path.
   Subsets are Google's own latin + latin-ext woff2 files, byte-for-byte;
   the vietnamese subset Google also serves is dropped, as nothing here is
   in Vietnamese.

   **THE LATIN SUBSET ALREADY COVERS FIVE OF THE SEVEN LOCALES.** Spanish,
   German, French and Italian need nothing past Latin-1 plus Œ/œ, all of
   which is in the latin file — verified against the actual `cmap` rather
   than against the declared range, since a `unicode-range` is a promise
   about what the browser should ASK this file for and not a statement about
   what is in it. Russian and Simplified Chinese need glyphs neither family
   has at all, and each is set in stand-ins declared further down under these
   same family names: see the Cyrillic block, then the CJK one.
   `tests/test_webapp_fonts.py` refuses a family that cannot draw a script
   this app ships. Regenerate by re-downloading rather than editing, and
   change the FILENAME if you do — /fonts/* is served immutable (see
   build.mjs).
   ========================================================================= */
@font-face {
  font-family: 'Anton';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/anton-400-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Anton';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/anton-400-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/saira-condensed-400-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/saira-condensed-400-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('../fonts/saira-condensed-600-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('../fonts/saira-condensed-600-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('../fonts/saira-condensed-700-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('../fonts/saira-condensed-700-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url('../fonts/saira-condensed-800-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url('../fonts/saira-condensed-800-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* ---- CYRILLIC, AND IT IS A DIFFERENT TYPEFACE WEARING THE SAME NAME -----
   **NEITHER Anton NOR Saira Condensed HAS A SINGLE CYRILLIC GLYPH.** Both
   ship latin, latin-ext and vietnamese and nothing else — checked against
   Google's own CSS API, not assumed — so every Russian character in this app
   fell out of the family entirely and was drawn by whatever `system-ui`
   resolved to. That is why the Russian UI read as bigger and more loosely
   spaced than the English: it was not condensed at all, and `letter-spacing`
   tuned for a condensed face compounds on a normal-width one. It affected
   every screen, not one dialog.

   The substitutes are declared under the ORIGINAL family names with
   Cyrillic-only `unicode-range`s, which is exactly the mechanism Google's own
   subsetting uses: the browser takes Latin from Anton/Saira and Cyrillic from
   these, and no rule anywhere else in this stylesheet has to know. Oswald
   stands in for Anton (the nearest condensed display face that has Cyrillic)
   and Fira Sans Condensed for Saira, which matches its four weights exactly
   rather than rounding two of them to a neighbour.

   Oswald is taken at 700 and declared as 400, which is the file the design
   wants (Anton has one weight and it renders black, so matching its COLOUR
   matters more than matching its number — a `font-weight` in `@font-face` is
   a mapping, not a claim about the file).

   **THE DECLARED WEIGHT MUST EQUAL THE LATIN FACES', AND `100 900` SILENTLY
   DOES NOTHING.** Declaring the range looks more robust and is the bug: asked
   for Anton at 400, Chrome settles on the EXACT-400 face for the whole family
   and never consults this one's `unicode-range`, so every Cyrillic character
   went on falling through to the system font with the file sitting there
   fetched and unused. Measured, not reasoned about — the same file loaded
   under a family name of its own rendered 14% narrower, which is how it was
   found. Every subset face in a family carries the same weight, which is what
   Google's own multi-subset CSS does and the reason it works.

   A mixed-script word therefore draws from two faces. It already did — the
   fallback was further away — and these were chosen to make the seam as
   quiet as it can be short of commissioning Cyrillic for Anton. */

@font-face {
  font-family: 'Anton';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/oswald-700-cyrillic.woff2') format('woff2');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
  font-family: 'Anton';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/oswald-700-cyrillic-ext.woff2') format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/firasanscondensed-400-cyrillic.woff2') format('woff2');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/firasanscondensed-400-cyrillic-ext.woff2') format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('../fonts/firasanscondensed-600-cyrillic.woff2') format('woff2');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('../fonts/firasanscondensed-600-cyrillic-ext.woff2') format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('../fonts/firasanscondensed-700-cyrillic.woff2') format('woff2');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('../fonts/firasanscondensed-700-cyrillic-ext.woff2') format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url('../fonts/firasanscondensed-800-cyrillic.woff2') format('woff2');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url('../fonts/firasanscondensed-800-cyrillic-ext.woff2') format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}

/* ---- CJK, AND IT IS A SUBSET OF THE UI RATHER THAN OF A SCRIPT ---------
   **NEITHER Anton NOR Saira Condensed HAS A CJK GLYPH**, so the whole
   Simplified Chinese UI falls out of both families — the same hole Cyrillic
   had, and closed the same way: a substitute declared under the ORIGINAL
   family names with a CJK-only `unicode-range`, so no rule below has to know.
   Noto Sans SC stands in for both, because there is no condensed CJK face to
   match Saira with and no black display CJK face to match Anton with; every
   freely-licensed option is a humanist sans.

   **THE DISPLAY WEIGHT IS WHY THIS SHIPS AT ALL.** Without it a Chinese
   heading falls through to `system-ui` at weight 400 and renders THIN beside
   an English design built on Anton, which is black. Noto Sans SC 900 is taken
   as Anton's 400 — matching its COLOUR rather than its number, exactly as
   Oswald 700 is taken as Anton's 400 above, and for the same reason: a
   `font-weight` in `@font-face` is a mapping, not a claim about the file.
   Saira's four weights map 400→400, 600→500, 700→700, 800→900; the 900 file
   is fetched once and serves two declarations.

   **THE SUBSET COVERS THE INTERFACE, NOT THE LANGUAGE, AND THAT IS
   MEASURED.** The four files hold the 629 characters the two `zh` string
   tables actually use and weigh 349 KB together. Widening them to GB2312
   level 1 — the ~3,755 characters ordinary prose is made of, which is what a
   Chinese DISPLAY NAME would need — measures 2,103 KB, and a megabyte and a
   half of font on the critical path of a Mini App boot buys one thing: a
   name set in Noto rather than in the reader's system CJK font. On Android
   that fallback IS Noto Sans CJK, on iOS it is PingFang SC and on Windows
   Microsoft YaHei — all humanist sans, none of them a stranger to this one.
   That is a far quieter seam than the Cyrillic case, where the fallback was
   a NON-condensed face standing in for a condensed one and read as a
   different size on every screen.

   So: user content in Chinese is drawn by the system, deliberately, and the
   interface is drawn by us. `tests/test_webapp_fonts.py` fails if a character
   in either `zh` table is not in these files — which is what makes adding a
   Chinese string a red build rather than a silent fallback. Regenerate by
   re-subsetting rather than editing: `python3 scripts/subset_cjk.py` cuts all
   four from upstream Noto Sans SC and prints the four `src:` lines to paste
   back here. The hash in each filename is of the character SET, because
   /fonts/* is served immutable (see build.mjs) — a new charset must arrive
   under a new NAME or every client that cached the old one keeps it forever.

   The ranges below are CJK-ONLY on purpose. The Latin faces already declare
   U+2000-206F — the dashes, the quotes, the ellipsis — and a `unicode-range`
   that overlapped theirs would take those characters off Anton in EVERY
   language, not just Chinese, because a later rule wins. */

@font-face {
  font-family: 'Anton';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/notosanssc-900-cjk-ui-a179fd68.woff2') format('woff2');
  unicode-range: U+2E80-2EFF, U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/notosanssc-400-cjk-ui-a179fd68.woff2') format('woff2');
  unicode-range: U+2E80-2EFF, U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('../fonts/notosanssc-500-cjk-ui-a179fd68.woff2') format('woff2');
  unicode-range: U+2E80-2EFF, U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('../fonts/notosanssc-700-cjk-ui-a179fd68.woff2') format('woff2');
  unicode-range: U+2E80-2EFF, U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF;
}
@font-face {
  font-family: 'Saira Condensed';
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url('../fonts/notosanssc-900-cjk-ui-a179fd68.woff2') format('woff2');
  unicode-range: U+2E80-2EFF, U+3000-303F, U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+FE30-FE4F, U+FF00-FFEF;
}

/* =========================================================================
   Sprites Vault — clean rebuild of the original design system.
   Colors/values carried over faithfully from the original ft-* classes.
   ========================================================================= */

:root {
  --font-display: "Anton", system-ui, sans-serif;   /* italic display / brand */
  --font-sans: "Saira Condensed", system-ui, sans-serif;
  --bg: #070a1c;
  --ink: #eaf2ff;
  --ink-dim: #d2e1ffa6;
  --panel: #131a42b8;
  --panel-border: #78aaff33;
  --accent: #1ab4ff;
  --gold: #ffc31f;
  /* Side of a variant tile — also the matrix grid track, so the two must
     agree. The desktop matrix raises it once there's room (see below). */
  --cell-size: 4.25rem;
  /* Where the sticky chrome parks. Zero on the web; inside a Mini App it
     clears Telegram's floating overlay controls and the device notch, which
     draw on top of the WebView rather than reserving a bar of their own. */
  --sticky-top: calc(var(--tg-safe-area-inset-top, 0px) + var(--tg-content-safe-area-inset-top, 0px));
  /* Gap between the nav and the variant header parked under it. */
  --chrome-gap: .5rem;
  /* The page measure. The nav spans the viewport but pads its contents in to
     this, so its buttons line up with the cards below. */
  --container: 72rem;
  /* Platform brand fills. Two places wear them — the sign-in dialog's SSO
     buttons and the squad-up strip under the hero — and they must be the
     same blue and the same blurple in both, or the app looks like it has two
     opinions about what Discord's colour is. Defined once here so a brand
     refresh is one edit rather than a grep. */
  --fill-telegram: linear-gradient(#63b6f5, #54a9eb 52%, #3f95dc);
  --fill-telegram-hover: linear-gradient(#79c3f8, #62b3ef 52%, #4a9fe2);
  --fill-telegram-edge: #2f7cb8;
  --fill-discord: linear-gradient(#7b87f5, #5865f2 52%, #4752c4);
  --fill-discord-hover: linear-gradient(#909af8, #6b77f5 52%, #5761d4);
  --fill-discord-edge: #3c45a5;
  /* The website's own button. Grey on purpose and the only one of the four
     without a brand: the other three wear somebody else's colour because
     they go to somebody else's app. This one goes to us, and a fourth
     saturated fill beside them would compete with the server link the strip
     is built to sell. Cool-toned rather than neutral so it belongs to this
     palette instead of looking disabled. */
  --fill-grey: linear-gradient(#8d97ab, #7b8599 52%, #67707f);
  --fill-grey-hover: linear-gradient(#9ea8ba, #8b95a8 52%, #767f8e);
  --fill-grey-edge: #4c5361;
}

* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; line-height: 1.5; }
body {
  margin: 0;
  color: var(--ink);
  background-color: var(--bg);
  font-family: var(--font-sans);
  -webkit-tap-highlight-color: transparent;
  /* Kill the double-tap-to-zoom gesture (keeps pinch-zoom + scrolling). */
  touch-action: manipulation;
}
img { max-width: 100%; }
button { font-family: inherit; }

/* ---- animated screen background (radial glows + gradient) ---- */
.ft-screen {
  min-height: 100vh;
  position: relative;
  /* `clip`, never `hidden`. hidden makes this a scroll container, and every
     position: sticky inside it — the nav, the matrix's variant header — would
     then stick to a box that never scrolls, i.e. not at all. clip does the
     same clipping without establishing one. Only the horizontal axis needs
     it; the vertical overflow this used to catch was the fixed background
     layer, which the viewport clips by itself. */
  overflow-x: clip;
}
/* Gradient lives on a dedicated fixed layer that WebKit composites once,
   instead of `background-attachment: fixed`, which repaints the whole
   viewport gradient every scroll frame in Safari (Chrome composites it). */
.ft-screen::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  background-image:
    radial-gradient(120% 80% at 12% -10%, #1f3aa0 0, #1f3aa000 55%),
    radial-gradient(120% 90% at 100% 0, #3a1f78 0, #3a1f7800 50%),
    linear-gradient(#0c1230, #070a1c);
}
/* The page container. 120rem (1920px) let the whole app stretch edge-to-edge
   on a wide monitor, which nothing here wants: the matrix is the widest thing
   on the page and it needs ~54rem, so the rest was just spreading thin. 72rem
   is the ordinary web answer (Tailwind's max-w-7xl is 80rem, Bootstrap's
   widest container 71.25rem) and leaves the matrix room to breathe without
   the cells drifting apart. .ft-readable stays narrower for prose. */
.ft-shell {
  width: 100%;
  max-width: var(--container);
  margin-inline: auto;
  /* One knob, because the sticky bars reach back out over this padding to
     paint edge-to-edge and then add it again inside — see .app-nav. */
  --shell-pad: clamp(1rem, 2.5vw, 3rem);
  padding: 2.5rem var(--shell-pad) 5.5rem;
}
.ft-readable { max-width: 66rem; margin-inline: auto; }

/* ---- typography helpers ---- */
.ft-display { font-family: var(--font-display); font-style: italic; letter-spacing: .01em; }
.ft-heading { text-transform: uppercase; letter-spacing: .04em; font-style: italic; font-weight: 800; }
.ft-brand {
  text-transform: uppercase; letter-spacing: .01em; color: #eaf4ff;
  font-family: var(--font-display); font-style: italic; line-height: 1; font-size: 1.5rem;
}
.ft-title {
  color: transparent;
  background: linear-gradient(#fff, #b9ecff 55%, #5cc6ff);
  -webkit-background-clip: text; background-clip: text;
  filter: drop-shadow(0 3px #07123c99) drop-shadow(0 0 28px #3cbeff99);
  /* SAFARI: without this the glow flickers once when a nearby .ft-pillbtn is
     hovered — reported on "Back to tracker" beside the /tip heading.
     Nothing about the heading changes on that hover; the cause is next door.
     .ft-pillbtn transitions `filter` on :hover, and starting a filter
     transition promotes that button to its own compositing layer. Safari
     re-rasterises the surrounding stacking context when layers appear, and
     this element is the most expensive thing in it to re-raster —
     `background-clip: text` plus two drop-shadows — so the repaint is
     visible as a one-off flash.
     Keeping it permanently on its own layer means neighbouring layer churn
     can't force it to re-raster. `will-change` is normally too blunt to leave
     on, but this class is a page heading: 8 uses across the site, never more
     than two per page, so the memory it costs is negligible. */
  will-change: filter;
}
.muted { color: #d2e1ffa6; }

/* ---- panels ---- */
.ft-panel {
  color: var(--ink);
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: .75rem;
  box-shadow: inset 0 1px #ffffff0d, 0 18px 40px #00000073;
}

/* ---- buttons ---- */
.ft-pillbtn {
  text-transform: uppercase; letter-spacing: .03em; color: #dbeaff; cursor: pointer;
  background: #ffffff0d; border: 1px solid #78aaff59; border-radius: .4rem;
  display: inline-flex; justify-content: center; align-items: center; gap: .45rem;
  padding: .55rem 1rem; font-size: .9rem; font-style: italic; font-weight: 800;
  /* This class dresses BOTH <button> and <a>, and only the anchors were
     getting the browser's default underline — so "Back to tracker" was
     underlined on all six content pages while every <button> wearing the same
     class was not. Belongs here rather than on each anchor: one declaration
     covers all nine existing links and any added later. */
  text-decoration: none;
  transition: background .1s, border-color .1s, filter .1s;
}
.ft-pillbtn:hover { filter: brightness(1.12); }
/* The white wash is the hover for the *plain* pill only — anything that
   carries its own fill (gold, accent, Telegram) brightens that fill instead,
   or hovering would grey it out.

   **`blocking-btn--*` IS MATCHED AS A FAMILY, not listed member by member**,
   and the reason is that listing them is what went wrong: the blocking
   dialog's channel buttons were given brand fills without being added here,
   so this rule — five class-worth of specificity against their two — replaced
   the brand with translucent white on hover and every one of them turned grey
   under the pointer, which is the exact failure the paragraph above warns
   about. A substring match cannot be forgotten by the next channel. */
.ft-pillbtn:not(.ft-pillbtn--accent):not(.ft-pillbtn--gold):not(.signin-provider):not([class*="blocking-btn--"]):hover { background: #ffffff1f; }
.ft-pillbtn--accent { color: #06182e; border-color: transparent; background: linear-gradient(#7fe7ff, #1ab4ff 48%, #0f8fe6); }
.ft-pillbtn--gold { color: #3a2a00; border-color: transparent; background: linear-gradient(#ffe680, #ffc31f 50%, #f5a300); }
.ft-reset {
  text-transform: uppercase; letter-spacing: .03em; color: var(--ink-dim); cursor: pointer;
  background: none; border: none; display: inline-flex; align-items: center; gap: .35rem;
  padding: .45rem .6rem; font-size: .85rem; font-style: italic; font-weight: 700;
}
.ft-reset:hover { color: #fff; }

.ft-input {
  text-transform: none; letter-spacing: .01em; color: #fff; background: #00000052;
  border: 2px solid #82beff73; border-radius: .35rem; outline: none; width: 100%;
  padding: .55rem .8rem; font-weight: 600;
  /* 16px min prevents iOS Safari from zooming in when the field is focused. */
  font-size: 16px;
  transition: border-color .12s, box-shadow .12s;
}
.ft-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px #1ab4ff33; }

/* ---- the content pages' copy of the app bar ----
   privacy / terms / tip / thanks / the two sprite explainers render the
   SAME .app-nav the vault does, so moving between the app and a legal page
   doesn't swap the chrome for a smaller, different thing. They used to carry
   a `.site-header` — a logo and one "Back to tracker" pill — which is what
   this replaces, and which is why that rule is gone rather than merely
   unused.

   Two things the app's bar doesn't need. #app reserves the fixed bar's height
   for the vault, and these pages have no #app — `.has-site-nav` does it
   instead, off the same --nav-h fallback (nothing measures the bar here, so
   the fallback is the whole answer rather than a first guess). And this bar
   carries the BRAND: on a page that IS the vault a logo linking to itself is
   noise, and on one that isn't it is the way back — which is also why there
   is no "Back to tracker" text left to word differently per page. */
.has-site-nav .ft-shell { padding-top: calc(var(--nav-h, 3.25rem) + var(--chrome-gap)); }
/* The brand shrinks before the buttons do: on a narrow phone the wordmark is
   the one thing here that can be dropped without losing a control. */
.site-nav-brand { min-width: 0; }
.site-nav-brand .brand { gap: .5rem; }
.site-nav-brand .brand-logo { width: 1.75rem; height: 1.75rem; border-radius: .35rem; }
/* The padding is not spacing: .ft-heading is a SKEWED face, so the last
   glyph leans out past its own advance width and `overflow: hidden` sliced
   the T off "VAULT" at every width, not just the narrow ones the ellipsis is
   for. The em unit keeps the allowance tied to the type size. */
.site-nav-wordmark {
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  font-size: 1rem; padding-inline-end: .2em;
}
@media (max-width: 26rem) { .site-nav-wordmark { display: none; } }

/* The account button's two labels. Which one is right depends on a session,
   and a module can't answer before first paint — so both ship in the markup
   and an inline <head> script stamps `has-session` on <html> from the GoTrue
   key in localStorage. Signed out is the default, which is also what a
   crawler and a JS-less reader get. See js/site-chrome.js. */
.nav-label-signedin { display: none; }
.has-session .nav-label-signedin { display: inline; }
.has-session .nav-label-signedout { display: none; }
/* Signed in it's a way back into your own account, signed out it's an offer —
   the same split .app-nav-btn--accent already draws in the vault's bar. */
.has-session [data-nav-account] {
  background: rgba(159, 196, 255, .06);
  border-color: rgba(159, 196, 255, .18);
  color: #cfe0ff; font-weight: 600;
}
.has-session [data-nav-account]:hover, .has-session [data-nav-account]:focus-visible {
  background: rgba(159, 196, 255, .14);
  border-color: rgba(159, 196, 255, .35);
  color: #eaf3ff;
}
/* It is an <a> so it works with no JS at all (it points at the vault, where
   the dialog would have opened anyway); site-chrome.js upgrades it in place. */
a.app-nav-btn { text-decoration: none; }

.brand { display: inline-flex; align-items: center; gap: .6rem; text-decoration: none; color: #fff; }
.brand:visited { color: #fff; }
.brand-logo { display: block; }
.header-actions { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.account-chip {
  display: inline-flex; align-items: center; gap: .5rem; padding: .4rem .7rem;
  background: #ffffff0d; border: 1px solid #78aaff40; border-radius: 999px; color: #dbeaff;
  font-weight: 700; font-size: .9rem;
}
.account-chip .avatar {
  width: 1.4rem; height: 1.4rem; border-radius: 50%;
  background: linear-gradient(135deg, #7fe7ff, #1ab4ff); color: #06234a;
  display: grid; place-items: center; font-weight: 800; font-size: .8rem;
}

/* ---- account loader (shown until the first session check resolves) ---- */
.account-loading { display: inline-flex; align-items: center; justify-content: center; padding: .4rem .7rem; min-height: 2.2rem; }
.account-spinner {
  width: 1.2rem; height: 1.2rem; border-radius: 50%;
  border: 2px solid #78aaff40; border-top-color: #7fe7ff;
  animation: account-spin .7s linear infinite;
}
@keyframes account-spin { to { transform: rotate(360deg); } }

/* ---- account dropdown ---- */
.account-menu { position: relative; }
.account-chip--trigger { cursor: pointer; font-family: inherit; transition: background .1s, border-color .1s; }
.account-chip--trigger:hover { background: #ffffff1f; border-color: #78aaff73; }
.account-chip-caret { font-size: .7rem; color: #9fc4ff; transition: transform .12s; }
.account-chip--trigger[aria-expanded="true"] .account-chip-caret { transform: rotate(180deg); }
.account-dropdown {
  position: absolute; right: 0; top: calc(100% + .4rem); z-index: 20;
  min-width: 15rem; display: flex; flex-direction: column; gap: .1rem;
  padding: .35rem; background: #0c1230; border: 1px solid #78aaff40;
  border-radius: .6rem; box-shadow: 0 18px 40px #00000073;
}
.account-dropdown[hidden] { display: none; }
.account-item {
  width: 100%; text-align: left; cursor: pointer; color: #dbeaff;
  background: none; border: none; border-radius: .4rem; padding: .55rem .7rem;
  font-family: inherit; font-size: .88rem; font-weight: 700;
  display: flex; align-items: center; justify-content: space-between; gap: .75rem;
}
button.account-item:hover { background: #ffffff14; }
.account-item--info { cursor: default; }
.account-item--info .account-item-label { color: #9fc4ff; font-weight: 600; }
.account-item--info .account-item-value { color: #fff; font-weight: 800; }
.account-item--danger { color: #ff9b9b; }
button.account-item--danger:hover { background: #ff5a5a1f; }
.account-item-epic { display: flex; align-items: baseline; gap: .5rem; min-width: 0; flex: 1; }
.account-unlink {
  flex: none; cursor: pointer; color: #ff6b6b; background: transparent;
  border: none; appearance: none; -webkit-appearance: none;
  width: 1.4rem; height: 1.4rem; display: grid; place-items: center;
  border-radius: .3rem; padding: 0; font-size: 1rem; line-height: 1;
}
.account-unlink:hover { background: #ff5a5a24; color: #ff8f8f; }

/* ---- cross-view nav bar ---- */
/* Left slot holds the "← Show my collection" button when the viewer is
   in a shared read-only view; right slot holds "Back to @X →" when
   they've jumped from a shared view into their own vault. Both slots
   stay in the layout even when empty so the hero doesn't shift as
   buttons appear/disappear on navigation. */
/* The nav is the page's sticky chrome: it parks at the top and the matrix's
   variant header parks under it (see --nav-h, measured in main.js — the row
   wraps to two lines on a narrow phone, so its height can't be a constant).

   It's a floating .ft-panel, not a full-bleed bar: same border, radius and
   inset highlight as the cards it scrolls over, so the chrome reads as part
   of the same set of blocks. Two things differ from .ft-panel and both are
   load-bearing — the fill is opaque where the panel's is 72%, because cards
   pass directly underneath, and the shadow is tighter, because a floating
   bar with the cards' 40px drop shadow smears over everything below it. */
.app-nav {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: .75rem;
  min-height: 2.25rem;

  /* Fixed, not sticky, and for two reasons. It spans the whole viewport
     while living inside a centred 72rem shell, and `left/right: 0` on a
     fixed box is exact where a `calc(50% - 50vw)` full-bleed overshoots by
     the scrollbar's width. And on a narrow screen the page scrolls
     horizontally to reach the matrix, where sticky can't hold it: a sticky
     box only travels within its containing block, and the nav's is exactly
     one viewport wide, so there'd be no horizontal slack to stay behind in.
     Being out of flow, it leaves no space — #app reserves its height.

     The fill is opaque and the shadow tighter than .ft-panel's: cards pass
     directly underneath, and a bar carrying the cards' 40px drop shadow
     smears it over everything below. */
  position: fixed;
  /* Anchored to the very top of the viewport, NOT to --sticky-top. Inside a
     Mini App, Telegram's Close / collapse / ⋯ controls float over the top of
     the WebView, and parking the bar below them leaves an uncovered strip
     that the page scrolls through — the hero ended up rendering between
     Telegram's own buttons. So the bar starts at 0 and absorbs the safe area
     as top padding instead: its fill covers the whole strip, and its contents
     still sit clear of Telegram's controls. On the web --sticky-top is 0 and
     this is exactly the old geometry. */
  top: 0;
  left: 0; right: 0;
  z-index: 40;
  padding-top: calc(var(--sticky-top) + .45rem);
  padding-bottom: .45rem;
  padding-inline: max(var(--shell-pad), calc((100% - var(--container)) / 2 + var(--shell-pad)));
  background: #141c47f2;
  backdrop-filter: blur(12px) saturate(1.15);
  border-bottom: 1px solid var(--panel-border);
  box-shadow: inset 0 1px #ffffff14, 0 10px 24px #00000073;
}
/* The nav is out of flow, so the content it covers reserves its height. */
#app { padding-top: calc(var(--nav-h, 3.25rem) + var(--chrome-gap)); }
.app-nav-slot { display: inline-flex; min-height: 2.25rem; align-items: center; gap: .45rem; }

/* ---- collection progress, echoed into the sticky nav ----
   Hidden until the real progress panel has scrolled up past the nav, at
   which point main.js's IntersectionObserver adds .nav-progress-shown to
   <body>. It's the same numbers renderProgress() writes to the panel, so the
   two can't disagree. Never in the shared or teams views — the panel it
   mirrors isn't the subject there. */
/* The perspective lives on the slot, not the chip: a rotateX on an element
   with no perspective ancestor flattens into a vertical squash, which is the
   difference between a flip and a wipe.

   IT IS NOT IN THE CENTRE ANY MORE, despite the name and its place in the
   markup. Centred, it was the one thing in the bar with no neighbour, which
   made a small readout float in the middle of a wide screen and collide with
   the two groups on a narrow one. It now joins the left-hand group — the
   `margin-right: auto` is what pushes the right-hand group away instead of
   `justify-content: space-between` doing it, since with three groups that
   rule spaces all three rather than parking one at each end.

   Left in place in the DOM rather than moved: clearNav() empties both button
   slots on every mount, so a chip living inside one would have to be rebuilt
   by each of them. Two declarations here cost nothing and can't be forgotten.

   The order is the same on all three surfaces: it FOLLOWS My Teams. It was
   briefly reversed on the embedded two, on the theory that with no browser
   chrome above the bar the first thing in it is the first thing on screen and
   that should be the collection rather than a link away from it. Rendered, the
   chip leading meant the bar opened on a readout instead of a control, and the
   one button on that side got pushed off the edge it belongs at — so the two
   `.embedded` overrides are gone and the `margin-right: auto` below stays put
   on the slot that ends the left-hand group. */
.app-nav-center {
  flex: 0 1 auto; justify-content: center; min-width: 0; perspective: 500px;
  margin-right: auto;
}
.nav-progress {
  display: inline-flex;
  align-items: center; gap: .5rem;
  padding: .2rem .6rem; border-radius: 999px;
  background: #ffffff0d; border: 1px solid #78aaff33;
  font-weight: 700; font-size: .85rem; color: #dbeaff;
  white-space: nowrap; min-width: 0;

  /* Flips down into place like a split-flap board when the real progress
     panel scrolls away, and back up when it returns. Hinged at the top edge,
     so it reads as hanging from the nav rather than growing out of it.
     Kept in the layout (visibility, not display) because a display swap
     can't be transitioned — and the slot holds nothing else, so the space
     costs no one anything. */
  transform-origin: 50% 0;
  transform: rotateX(-92deg);
  opacity: 0;
  visibility: hidden;
  transition: transform .34s cubic-bezier(.2, .8, .25, 1),
              opacity .16s linear,
              visibility 0s linear .34s;
}
body.nav-progress-shown .nav-progress {
  transform: rotateX(0deg);
  opacity: 1;
  visibility: visible;
  transition: transform .34s cubic-bezier(.2, .8, .25, 1),
              opacity .16s linear,
              visibility 0s;
}
body.shared-view .nav-progress,
body.teams-view .nav-progress { display: none; }
.nav-progress-pct {
  color: #7fe7ff; font-style: italic; font-weight: 800;
  /* Own flip, fired by renderProgress() whenever the number actually
     changes — the split-flap gesture is the point, so a value that lands
     silently would undercut the reveal. Tabular figures keep the chip from
     twitching wider as the digits change under it. */
  display: inline-block;
  font-variant-numeric: tabular-nums;
  transform-origin: 50% 50%;
}
.nav-progress-pct.is-flip { animation: nav-digit-flip .3s cubic-bezier(.3, .8, .3, 1); }
@keyframes nav-digit-flip {
  0%   { transform: rotateX(-90deg); opacity: .2; }
  60%  { transform: rotateX(8deg);   opacity: 1; }
  100% { transform: rotateX(0deg); }
}
/* Everything above is decoration; honour a request for less motion. */
@media (prefers-reduced-motion: reduce) {
  .nav-progress { transition: opacity .12s linear, visibility 0s; transform: none; }
  body.nav-progress-shown .nav-progress { transform: none; }
  .nav-progress-pct.is-flip { animation: none; }
}
.nav-progress-bar {
  width: 4.5rem; height: .3rem; border-radius: 999px; overflow: hidden;
  background: #ffffff1a; flex: none;
}
.nav-progress-fill {
  display: block; height: 100%; width: 0;
  background: linear-gradient(90deg, #7fe7ff, #1ab4ff);
  transition: width .2s;
}
.nav-progress-count { color: #9fb4d9; font-size: .78rem; overflow: hidden; text-overflow: ellipsis; }
/* Below ~30rem the bar is the first thing to go: the percentage and the
   count carry the meaning, and the nav must not wrap to two lines just to
   hold a decoration. */
@media (max-width: 30rem) {
  .nav-progress-bar { display: none; }
}
/* Square-ish with softened corners, matching .chip's .4rem rather than the
   pills elsewhere. The bar sits directly above the filter row, and two
   rounding languages one gap apart made the nav read as a different app's
   chrome; the radius is what the two now share. */
.app-nav-btn {
  display: inline-flex; align-items: center; gap: .35rem;
  padding: .4rem .8rem; border: 1px solid rgba(159, 196, 255, .18);
  background: rgba(159, 196, 255, .06); color: #cfe0ff;
  font: inherit; font-weight: 600; font-size: .9rem;
  cursor: pointer; border-radius: .4rem;
  /* So the label, when it is clipped away on a narrow screen, is positioned
     against its own button rather than against the fixed bar. */
  position: relative;
  transition: background .15s ease, border-color .15s ease, color .15s ease;
}
/* THE LEFT SLOT YIELDS FIRST. The bar is one non-wrapping flex row (it can't
   wrap — it is `position: fixed` and #app reserves exactly one row's height),
   so something has to give when the window is narrower than its contents. It
   is this one: the right-hand group is the same three controls everywhere and
   is what the bar is FOR, while the left is one context button whose meaning
   survives being cut short ("← Show my colle…" is still the way back).
   `display: block` rather than the inherited inline-flex because ellipsis
   needs a block formatting context — on a flex container the text is an
   anonymous item and `text-overflow` never applies. Safe here: every button
   this slot has ever held is text-only. */
.app-nav-left { min-width: 0; }
.app-nav-left .app-nav-btn {
  display: block; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* THE TWO ICONS DROP THEIR LABELS ON A PHONE, and the breakpoint is measured
   rather than chosen. The right-hand group at its widest — Swap saying
   "Ищем обмен…", Support and Account, in Russian, which is the longer of the
   two locales — is 390px, and a left button cut to something still readable
   wants ~100 beside it: 34rem is the first round number above that. Below it
   the group used to run off the edge and take Account with it.

   Icon-only rather than dropped, because the point of this bar is that its
   contents do not change with where you stand or how wide the window is; a
   heart and a pair of arrows are the two things here that survive losing their
   words. Account keeps its label — it has no icon, and a blank button is not a
   control. The labels are CLIPPED, not `display: none`: paintSwapNav still
   writes the swap state into that span, and it stays in the accessibility tree
   as the button's name, which is the whole reason neither needs an aria-label
   bolted on beside it. */
@media (max-width: 34rem) {
  .app-nav-btn--gold .app-nav-btn-label,
  .app-nav-btn--archive .app-nav-btn-label,
  .app-nav-btn--support .app-nav-btn-label {
    position: absolute; width: 1px; height: 1px;
    overflow: hidden; clip-path: inset(50%); white-space: nowrap;
  }
  .app-nav-btn--gold, .app-nav-btn--archive, .app-nav-btn--support { padding-left: .6rem; padding-right: .6rem; }
}
.app-nav-btn:hover, .app-nav-btn:focus-visible {
  background: rgba(159, 196, 255, .14);
  border-color: rgba(159, 196, 255, .35);
  color: #eaf3ff;
  outline: none;
}
/* Icons ride the label's own size, so the button keeps its height whatever
   the text scales to. */
.app-nav-btn-icon { display: inline-flex; align-items: center; justify-content: center; min-width: 1.05em; }
.app-nav-btn-icon svg { display: block; width: 1.05em; height: 1.05em; fill: currentColor; }

/* "Sign in" on the signed-out website. The one nav button that's an offer
   rather than a way back, so it's the one that gets colour. */
.app-nav-btn--accent {
  background: linear-gradient(#7fe7ff, #1ab4ff 48%, #0f8fe6);
  border-color: transparent; color: #06182e; font-weight: 800;
}
.app-nav-btn--accent:hover, .app-nav-btn--accent:focus-visible {
  background: linear-gradient(#9befff, #35c2ff 48%, #1a9bf0);
  border-color: transparent; color: #06182e;
}
/* Crypto addresses on /tip. The address is never truncated and never
   ellipsised: a tipper comparing it character by character against their
   wallet app is the last check before a transfer nobody can reverse, so it
   wraps instead — `anywhere` rather than `break-all` so it still breaks at
   sensible points when it can. */
.wallet-list { list-style: none; padding: 0; margin: 1rem 0 0; display: grid; gap: .85rem; }
.wallet-item {
  border: 1px solid rgba(159, 196, 255, .18);
  background: rgba(159, 196, 255, .05);
  border-radius: .5rem; padding: .75rem .9rem;
}
.wallet-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem; margin-bottom: .5rem; }
.wallet-name { font-weight: 700; color: #eaf3ff; }
/* The network is a warning, not a caption — sending on the wrong chain is the
   most common way a crypto tip is lost, so it reads as a label rather
   than as small print under the address. */
.wallet-network {
  font-size: .78rem; font-weight: 600; letter-spacing: .01em;
  color: #ffdd85; background: #ffc31f1f;
  border: 1px solid #ffc31f4d; border-radius: .3rem; padding: .1rem .4rem;
}
.wallet-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
/* THE ADDRESS TAKES A WHOLE LINE, and the button wraps under it. Side by side
   they shared a row, which meant the button's width was the address's problem:
   the label swaps between "Copy" and "Copied, thank you ♥" — roughly four
   characters against twenty, and more again in Russian — so the address
   reflowed under the cursor every time somebody copied it. On a row whose
   entire purpose is being read character by character before an irreversible
   transfer, that is the worst place on the page to move text.
   Giving the address its own line decouples the two completely: the button can
   be any width in any language and nothing beside it can move, which is also
   why it needs no min-width pinning it. */
.wallet-address {
  flex: 1 1 100%; min-width: 0;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .85rem; line-height: 1.5; color: #cfe0ff;
  overflow-wrap: anywhere;
}
/* nowrap so the thank-you never breaks across two lines inside the pill. */
.wallet-copy { flex: 0 0 auto; white-space: nowrap; }
/* Green rather than the usual fill: this is the same "it worked" the Epic
   tick uses, and it has to read at a glance from wherever on the row the
   cursor happens to be. */
.wallet-copy--done {
  background: #3ddc9744; border-color: #3ddc9788; color: #d6fff0;
}

/* The Stars amount picker (Mini App only). Presets wrap rather than scroll —
   four short chips fit two-up on the narrowest WebView, and a horizontal
   scroller would hide the larger amounts behind a gesture nobody makes in a
   payment dialog. */
.stars-presets { display: flex; flex-wrap: wrap; gap: .5rem; margin: .75rem 0; }
.stars-preset { flex: 1 1 6rem; }
/* The custom row sits on one line with the field taking the slack, so the
   action never drops below the fold on a short keyboard-covered viewport. */
.stars-custom { display: flex; gap: .5rem; align-items: center; }
.stars-custom .ft-input { flex: 1 1 auto; min-width: 0; }
/* Number inputs get spinners that fight the pill button for width in a narrow
   dialog, and the value is already constrained by min/max plus a server
   check — the arrows add nothing here. */
.stars-custom input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
.stars-custom input[type="number"]::-webkit-outer-spin-button,
.stars-custom input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

/* Support. A warm rose, which is the only hue in this bar not already spoken
   for — blue is navigation, gold is Swap waiting on you, green is the Epic
   tick. Deliberately the QUIETEST of the coloured three: it is an ask, and an
   ask that outshouts the thing actually waiting on the user has the priority
   backwards. Colour arrives only on hover, so at rest it sits at the same
   weight as Account beside it. */
.app-nav-btn--support { color: #f3c6d6; }
.app-nav-btn--support:hover, .app-nav-btn--support:focus-visible {
  background: #ff7aa81f; border-color: #ff7aa866; color: #ffd9e6;
}
/* The heart fills on hover only — a permanently solid one reads as a "liked"
   state rather than an invitation. */
.app-nav-btn--support .app-nav-btn-icon svg { fill: #ff7aa8b3; transition: fill .15s ease; }
.app-nav-btn--support:hover .app-nav-btn-icon svg,
.app-nav-btn--support:focus-visible .app-nav-btn-icon svg { fill: #ff7aa8; }
/* Mid-request. The click leaves the page, so the disabled beat is brief — but
   without it a slow round trip looks like a button that did nothing. */
.app-nav-btn--support[disabled] { opacity: .55; cursor: progress; }
/* Archive. A violet — the one hue in this bar not already spoken for (blue is
   navigation, gold is Swap, rose is Support, green is the Epic tick). Like
   Support, the colour arrives on hover; at rest it sits at the group's weight,
   and on the Archive view the aria-current underline below draws in it. */
.app-nav-btn--archive { color: #cdbcff; }
.app-nav-btn--archive:hover, .app-nav-btn--archive:focus-visible {
  background: #9b7bff1f; border-color: #9b7bff66; color: #e6ddff;
}
.app-nav-btn--archive .app-nav-btn-icon svg { fill: #b6a0ffcc; transition: fill .15s ease; }
.app-nav-btn--archive:hover .app-nav-btn-icon svg,
.app-nav-btn--archive:focus-visible .app-nav-btn-icon svg { fill: #cdbcff; }
/* YOU ARE HERE. The bar's right-hand group is the same three controls on every
   page and every view, so the one on the surface you are already looking at is
   MARKED rather than dropped — /tip keeps its Support button and the swap
   pool keeps its Swap button. Hiding it was the first attempt and it made the
   bar's contents depend on where you stood, which costs a reader the thing a
   fixed bar is for: you have to look at what is there before you can look for
   what you want. An empty slot is also a puzzle rather than an answer — it
   says nothing about why.

   Each button keeps its own colour: this is the same control, not a different
   one, so the mark is an underline drawn in whatever the button already is —
   gold on Swap, rose on Support. `cursor: default` is the only other change,
   because pressing it is a no-op (the app re-mounts the view it is on; the
   content pages' Support is a link back to /tip).

   Two selectors for the two surfaces. The app sets `aria-current` from JS
   (main.js::paintSwapNav, off isSwapViewMounted). The content pages can't:
   they share ONE bar (partials/site-nav.html, stamped in by scripts/build.mjs)
   whose expander takes no arguments on purpose, and the bar must be right at
   first paint with no JS at all — so `data-page` on the body is what says
   which page this is. js/site-chrome.js adds the attribute for a screen reader
   once it loads; the look never waits for it. */
.app-nav-btn[aria-current="page"],
[data-page="tip"] .app-nav-btn--support {
  cursor: default;
  box-shadow: inset 0 -2px 0 currentColor;
}

/* Swap. Gold because it is the only thing in this bar that can be waiting on
   the user — and a tinted fill rather than the solid gradient .ft-pillbtn--gold
   uses, since the alert state (--alert, further down) has to be able to raise
   it further without having anywhere left to go. */
.app-nav-btn--gold {
  background: #ffc31f1f; border-color: #ffc31f4d; color: #ffdd85;
}
.app-nav-btn--gold:hover, .app-nav-btn--gold:focus-visible {
  background: #ffc31f33; border-color: #ffc31f8c; color: #ffeeb8;
}
/* The two live modes. Both keep the gold — this is the same button doing the
   same job, not a different control — and differ only in their mark and their
   label, which paintSwapNav swaps in place.

   The label is the longest thing in this bar when it says "Looking for
   swaps…", so both modes cap it and ellipsise rather than let the nav wrap to
   a second line on a phone. */
.app-nav-btn--searching .app-nav-btn-label,
.app-nav-btn--stalled .app-nav-btn-label,
.app-nav-btn--blocked .app-nav-btn-label {
  max-width: 10rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Searching: a ring with one lit quarter, turning. It replaces the arrows
   because the arrows describe what the button DOES and this describes what is
   happening right now, and only one of those is news. */
.nav-mark-spin {
  display: block; width: .95em; height: .95em; border-radius: 50%;
  border: 2px solid #ffc31f3d; border-top-color: var(--gold);
  animation: nav-spin .8s linear infinite;
}
@keyframes nav-spin { to { transform: rotate(360deg); } }

/* Reconnecting: the poll is failing, so the mark stops turning and starts
   flickering — motion that reads as a fault rather than as progress. Amber
   rather than red on purpose: the page is retrying and usually wins, and a
   red light for a hiccup spends alarm the broken state will need. */
.app-nav-btn--stalled { color: #ffdd85; border-color: #ffc31f66; }
.nav-mark-dot {
  display: block; width: .55em; height: .55em; border-radius: 50%;
  background: var(--gold); box-shadow: 0 0 0 3px #ffc31f2e;
  animation: nav-flicker 1.1s steps(1, end) infinite;
}
@keyframes nav-flicker {
  0%, 45%  { opacity: 1; }
  50%, 95% { opacity: .18; }
}
/* Stopped: the switch is on and no run will pick this account up. Orange
   rather than the gold the other modes share, because it is the only one of
   the four that is about the ACCOUNT rather than about the pool — and still,
   where the other two marks move: they say "something is happening", and the
   whole content of this state is that nothing is. It is therefore absent from
   the reduced-motion block below, having nothing to turn off. */
.app-nav-btn--blocked { color: #ffbf9b; border-color: #ffa26b66; }
.nav-mark-bang {
  display: grid; place-items: center;
  width: .95em; height: .95em; border-radius: 50%;
  background: #ffa26b; color: #12172e;
  font-family: var(--font-sans); font-weight: 900; font-size: .72em; line-height: 1;
}
/* Both animated ones are decoration standing in for a state the label already
   carries in words, so a request for less motion costs nothing. */
@media (prefers-reduced-motion: reduce) {
  .nav-mark-spin, .nav-mark-dot { animation: none; }
  .nav-mark-dot { opacity: 1; }
}

/* ---- Account, marked with the Epic shield ----
   This was a badge of its own for one iteration, and two boxes about the same
   subject sitting side by side read as two destinations. The mark now rides
   the button, which is the thing you press to act on the account it describes.

   The shield keeps Epic's white-on-black rather than taking the button's
   colour: it is a brand mark standing for an external account, not an icon
   for what the button does, and the two should not look alike. The rest of
   the button is unchanged — this adds a mark, not a state. */
.app-nav-btn-icon--epic {
  color: #fff;
  background: linear-gradient(#3a3a3a, #202020 55%, #101010);
  border-radius: .2rem;
  padding: .12rem;
  box-shadow: inset 0 1px #ffffff26;
}
.app-nav-btn--verified { border-color: rgba(159, 196, 255, .3); }

/* The same mark on the content pages' bar, where the button is static markup
   and nothing has run that could add a class to it. The shield ships in the
   markup on every page and this shows it, keyed on `has-epic` — stamped on
   <html> by the head probe in partials/site-head-session.html and corrected by
   js/site-chrome.js once a session is actually resolved. Exactly how the two
   account labels work, so the bar's whole signed-in appearance comes from one
   synchronous read of one localStorage key.

   Hidden by an attribute selector rather than `hidden`: `.app-nav-btn-icon`
   sets `display: inline-flex`, which outranks the UA rule for [hidden] and
   would have shown the shield to everyone. */
[data-nav-epic] { display: none; }
.has-epic [data-nav-epic] { display: inline-flex; }
.has-epic [data-nav-account] { border-color: rgba(159, 196, 255, .3); }

/* ---- hero ---- */
.hero { text-align: center; margin-bottom: 2rem; }
.hero-title { font-size: clamp(2.5rem, 8vw, 4.5rem); line-height: 1; margin: 0; }
.hero-sub { text-transform: uppercase; letter-spacing: .08em; font-weight: 700; font-style: italic; color: #9fc4ff; margin: .4rem 0 1rem; }
.hero-lead { color: #cfe0ff; max-width: 46rem; margin: 0 auto; }

/* ---- links strip (the bots + the Discord server) ----
   One centred row of three brand buttons under the hero.
   A strip and not an .ft-panel on purpose — the panels below it are the
   vault (progress, actions, filters), and a fourth card would read as one
   more thing about this person's collection instead of as a way out to the
   two bots. It sits above the fold on every surface, which is the whole
   point: this is where the project is promoted.

   Cut to the same -11deg parallelogram as every other button here, so
   Telegram's blue and Discord's blurple arrive as OUR buttons wearing their
   colours rather than as two pasted-in widgets. The label counter-skews
   (.connect-link-inner), as in the sign-in dialog. */
.connect-strip {
  display: flex; align-items: center; justify-content: center;
  flex-wrap: wrap;
  margin: -.75rem 0 1.75rem;
}
.connect-links { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: center; }
.connect-link {
  display: inline-flex; align-items: center; justify-content: center;
  padding: .5rem .95rem; border: 1px solid transparent; border-radius: .15rem;
  color: #fff; text-decoration: none;
  text-transform: uppercase; letter-spacing: .03em;
  font-style: italic; font-weight: 800; font-size: .85rem;
  transform: skew(-11deg);
  transition: background .12s, box-shadow .12s, transform .12s;
}
/* Visited styling would mark the Discord server as "been there" in a duller
   colour than the two beside it, which is not information anyone wants here. */
.connect-link:visited { color: #fff; }
.connect-link:hover, .connect-link:focus-visible { transform: skew(-11deg) translateY(-1px); }
.connect-link-inner { display: inline-flex; align-items: center; gap: .45rem; transform: skew(11deg); }
.connect-icon { display: block; width: 1.15em; height: 1.15em; fill: currentColor; }

.connect-link--telegram {
  background: var(--fill-telegram);
  box-shadow: inset 0 1px #ffffff40, 0 2px 0 var(--fill-telegram-edge);
}
.connect-link--telegram:hover, .connect-link--telegram:focus-visible {
  background: var(--fill-telegram-hover);
}
.connect-link--discord, .connect-link--server {
  background: var(--fill-discord);
  box-shadow: inset 0 1px #ffffff40, 0 2px 0 var(--fill-discord-edge);
}
.connect-link--discord:hover, .connect-link--discord:focus-visible,
.connect-link--server:hover, .connect-link--server:focus-visible {
  background: var(--fill-discord-hover);
}
/* The way out to the real website, shown only on the two embedded surfaces
   (main.js::mountWebsiteLink) — on the website it would point at the page
   you are already on. First in the row, and the quietest thing in it. */
.connect-link--site {
  background: var(--fill-grey);
  box-shadow: inset 0 1px #ffffff33, 0 2px 0 var(--fill-grey-edge);
}
.connect-link--site:hover, .connect-link--site:focus-visible {
  background: var(--fill-grey-hover);
}

/* The server is the one link of the three that is a PLACE rather than a
   tool, and the one we most want taken, so it carries a blurple glow the
   two bot buttons don't. Same fill — a different blurple would read as a
   different product. */
.connect-link--server {
  box-shadow: inset 0 1px #ffffff40, 0 2px 0 var(--fill-discord-edge),
              0 0 0 1px #ffffff33, 0 0 18px #5865f299;
}
.connect-link--server:hover, .connect-link--server:focus-visible {
  box-shadow: inset 0 1px #ffffff40, 0 2px 0 var(--fill-discord-edge),
              0 0 0 1px #ffffff4d, 0 0 24px #5865f2cc;
}

/* THE LEAD IS INSTRUCTION, AND INSTRUCTION EXPIRES. "Tap a sprite to mark it
   owned" is the first thing a new visitor needs and the thing a returning one
   scrolls past every single time — on a phone it is most of a screen standing
   between the nav and the first sprite. It is drawn while it is still telling
   somebody something and not after, which is the rule the trades screen states
   as drawing no badge on a healthy connection. `has-collection` is set from
   renderProgress, so it flips on the very tap it describes.

   The hero's TITLE and SUB stay at every state: they are the page's <h1> and
   its subject, duplicated into index.html as static markup for crawlers that
   run no JavaScript, and hiding either would be cloaking. */
body.has-collection .hero-lead { display: none; }

/* ---- the masthead ----
   THE ONE PANEL ON THE PAGE, and that is the point of it. Everything above
   the grid used to be a card — the progress readout, the action row, and the
   save hint — each wearing .ft-panel, each the same radius and fill as the
   twelve species cards below them. Five identical boxes is a page with no
   hierarchy: the chrome was drawn exactly like the content it introduces.
   So the actions lost their box (see .vault-actions) and this is the only
   thing left that is allowed to look like a panel.

   THREE READOUTS, EACH ANSWERING A DIFFERENT QUESTION. How far along (the
   ring), exactly how many (the count), and where the gaps are (the four
   rarity rails). There used to be a fourth — one full-width bar restating the
   ring's percentage — and the rails replace it: four bars that partition the
   same total say strictly more than one bar plus four coloured dots, and the
   dots said nothing at all. */
.progress-panel { display: flex; align-items: center; gap: 1rem; padding: .8rem 1.1rem .9rem; margin-bottom: .75rem; flex-wrap: wrap; }
/* 56px, down from 68. It is the panel's least precise readout — a rounded
   percentage, next to the exact count that supersedes it — so when the panel
   had to lose height it is the thing that gives way first, and at this size it
   still stops being the tallest item in the row. */
.ft-ring {
  --p: 0;
  width: 56px; height: 56px; border-radius: 50%; flex: none; display: grid; place-items: center;
  background: conic-gradient(#3fd0ff 0 calc(var(--p) * 1%), #ffffff1a calc(var(--p) * 1%) 100%);
  box-shadow: 0 0 16px #3fd0ff59; position: relative;
}
.ft-ring::before { content: ""; position: absolute; inset: 6px; border-radius: 50%; background: #0c1230; }
.ring-label { position: relative; z-index: 1; font-weight: 800; font-style: italic; font-size: .8rem; }
/* A completed vault turns both percentage readouts gold. Keyed on
   owned === total, not on the rounded percentage: 118/119 renders as "99%"
   but 236/237 would round to 100% while a sprite is still missing. */
.ring-label.is-complete,
.nav-progress-pct.is-complete { color: #ffcf4d; text-shadow: 0 0 10px #ffb32966; }
.progress-body { flex: 1 1 260px; min-width: 220px; }
/* Three things on one line: what is being counted, how much of it is
   mastered, and how much of it you have. The mastery pill used to take a row
   of its own under the rarity rails, which cost the panel a whole line to say
   one number — and put a whole-collection measure below four per-band ones,
   reading as a fifth band. `margin-left: auto` on the count keeps it pinned
   right whatever sits between. */
.progress-top { display: flex; align-items: baseline; gap: .35rem .85rem; flex-wrap: wrap; margin-bottom: .25rem; }
.progress-counts { margin-left: auto; }
/* Centred against the eyebrow rather than sharing its baseline: the pill's
   text sits inside a border and a crown glyph, so a common baseline hangs it
   visibly below the label it stands next to. */
.progress-mastered { display: inline-flex; align-self: center; }

/* THE HEADING NAMES THE SEASON, because that is the one thing about this
   panel a reader cannot work out from it. Every number here divides by the
   CURRENT season's variants — the earlier ones live in the Archive, behind a
   nav button that makes no sense until something on the page admits there is
   more than one season. "Collection progress" was true of a panel containing
   a ring, a count and a bar, and told you nothing they had not already said.
   It survives as the fallback for a catalog carrying no season at all.

   Set in the trades eyebrow register — .68rem/900/.14em with the app's own
   -11deg mark in front of it — so the label that opens the page and the label
   that opens a trade card are the same kind of object. */
.progress-eyebrow {
  display: inline-flex; align-items: center; gap: .45rem;
  font-family: var(--font-sans);
  font-size: .68rem; font-weight: 900; letter-spacing: .14em;
  text-transform: uppercase; color: #8fb4ff;
}
.progress-eyebrow::before {
  content: ""; flex: none; width: .4rem; height: .4rem;
  background: currentColor; transform: skewX(-11deg);
}
.progress-counts { display: flex; align-items: baseline; gap: .9rem; }
.progress-count { display: inline-flex; align-items: baseline; gap: .3rem; font-family: var(--font-display); font-style: italic; }
/* The one number the whole panel is about, so it gets the brand treatment the
   hero title has — same gradient, same glow — while the denominator drops to
   a caption. Sizes are clamped rather than fixed: on a phone this shares its
   row with the "Collection progress" heading, and an unclamped display
   numeral wraps the heading onto two lines. */
.progress-count-owned {
  font-size: clamp(1.9rem, 6vw, 2.6rem); line-height: .9;
  color: transparent;
  background: linear-gradient(#fff, #b9ecff 55%, #5cc6ff);
  -webkit-background-clip: text; background-clip: text;
  filter: drop-shadow(0 2px #07123c99) drop-shadow(0 0 18px #3cbeff8c);
}
.progress-count-total { font-size: 1rem; color: #7f9ecbe6; }
/* The chunked-upload dialog's bar (main.js::syncLocalCollectionUp), and now
   its only caller. It used to be the progress panel's full-width bar as well;
   that one is gone — the four rarity rails below partition the same total, so
   a bar restating the ring's percentage was the panel's fourth readout of one
   number. Kept here, unchanged, because the dialog's bar tracks a real
   chunked write and is the one place a single bar is exactly right. */
.ft-progress { background: #0006; border: 1px solid #ffffff2e; border-radius: 999px; height: .85rem; overflow: hidden; }
.ft-progress-fill { background: linear-gradient(90deg, #1ab4ff, #7fe7ff 50%, #ffc31f); height: 100%; transition: width .35s; box-shadow: 0 0 12px #1ab4ffbf; }

/* THE LEGEND IS FOUR BARS, NOT FOUR DOTS. The question somebody brings to a
   collection tracker is "where am I behind", and a coloured pip beside
   "Rare 7/12" cannot answer it — it identifies the band and leaves the
   reader to divide. Four rails that partition the same total answer it
   without arithmetic, and they are why the full-width progress bar could go:
   these ARE that bar, cut where the cutting means something.

   Same rail as the species rows and the trade cards — a track in the quiet
   #2f3c6e with a lit fill over it — laid on its side. Ends up as one device
   used at three scales on one page, which is the opposite of the four
   unrelated ways the page used to draw "how much of this do you have". */
/* EACH BAND IS ONE LINE: name, its own track, its count. Stacked — name and
   count on one row, the track under it — the pair sat at opposite ends of a
   cell that grows with the panel, so on a wide screen the label and the
   number it belongs to ended up a hand's width apart and the row read as a
   sentence rather than as four gauges. On one line the track is what
   separates them, which is the thing that relates them.

   It also costs the panel a line. The whole reason this legend replaced a
   full-width bar and four pips was to say more in the same space; saying it
   in half the space is the same argument carried one step further. */
.progress-legend {
  display: grid;
  /* 8rem is the narrowest a gauge stays readable — "LEGENDARY", a stub of
     track, "12/12" — and it is the floor that decides how many fit: a phone
     takes two per row instead of stacking all four, which is the difference
     between an 84px legend and a 42px one on the surface most of this app is
     read on. Nothing overflows below that; the track is the flexible part and
     gives way first.

     THE MAX IS `1fr` AND THE WIDTH IS CAPPED INSTEAD, which is not
     interchangeable with capping the track. `auto-fit` counts repetitions
     using the track's MAX when that is a definite length, so `minmax(8rem,
     13rem)` asked whether two 13rem tracks fit — 430px — and answered no in a
     361px panel, stacking all four on a phone while each one then sat in a
     208px track it did not need. With `1fr` the count is taken from the 8rem
     floor and the cap moves to the container, where it bounds the row without
     lying about how many will fit. */
  grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
  max-width: 46rem;
  gap: .3rem .9rem; margin-top: .5rem;
}
.legend-item {
  --fill: 0;
  display: flex; align-items: center; gap: .45rem; min-width: 0;
}
.legend-name {
  flex: none;
  font-size: .63rem; font-weight: 900; letter-spacing: .13em;
  text-transform: uppercase; color: var(--rarity, #9fb4d9);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.legend-n {
  flex: none; font-size: .72rem; font-weight: 700; color: #8ea4c6;
  font-variant-numeric: tabular-nums;
}
/* A band you have finished stops being a gap, so its readout joins the ring
   and the count in the app's one "done" colour rather than staying grey. */
.legend-item--full .legend-n { color: var(--gold); }
/* The same rail at the other scale, so the same rule about colour holds: the
   track is the band's own hue muted, never a neutral. Four grey tracks would
   have made the masthead say less about an empty vault than the ledger does. */
.legend-bar {
  flex: 1 1 auto; min-width: 1.5rem; height: 3px; border-radius: 2px;
  background: var(--rail-dim);
  overflow: hidden;
}
.legend-bar::before {
  content: ""; display: block; height: 100%;
  width: calc(var(--fill) * 100%);
  background: var(--rarity, #9fb4d9);
  box-shadow: 0 0 9px -1px var(--rarity, #9fb4d9);
  transition: width .35s;
}
/* The mastery band: gold, and carried in a faint pill, because it counts
   something the four rarity bands before it don't partition.
   The pill does that separating on its own, which a divider rule could not —
   this row wraps on a phone, and a rule (or an auto margin) that separates
   two chips on one line is a stray tick, or a shove to the far edge, the
   moment the chip starts a line of its own. */
/* Mastery is a second axis through the same collection rather than a fifth
   band, so it does not get a rail: a track it does not partition would claim
   a denominator it hasn't got. It keeps the pill it has always worn and now
   sits on the headline row (#progressMastered) rather than under the rails,
   where it both cost a line and read as a fifth band. */
.legend-item--mastered {
  display: inline-flex; align-items: center; gap: .3rem;
  font-size: .78rem;
  color: var(--gold, #ffc31f); font-weight: 700;
  padding: .1rem .55rem .1rem .4rem; border-radius: 999px;
  background: #ffc31f14; border: 1px solid #ffc31f33;
}
/* Sized against the pill's own text now that it no longer stands in a row of
   rarity pips. */
.legend-crown { width: 1rem; }
.ft-pip { border-radius: 999px; flex: none; width: .6rem; height: .6rem; display: inline-block; box-shadow: 0 0 6px currentColor; }

/* ---- filters ---- */
/* Whole-collection actions (Share, Export, Reset), living in the toolbar's
   right-hand group.

   NO BOX AND NO PILLS. This was an .ft-panel holding two pill buttons and a
   text link — a full-width card between two other full-width cards, carrying
   less than either. It lost the card first and now it loses the pills: three
   filled slabs sat directly above the grid, competing with the sprites for
   the only lit surface on the page. They are text actions in the register
   .ft-reset already established for this row, separated by weight rather than
   by borders — Export in the accent, Share in plain ink, Reset dim, which is
   the order somebody wants them in. */
.vault-actions {
  display: flex; align-items: center; justify-content: flex-end;
  gap: .25rem .35rem; flex-wrap: wrap;
  min-width: 0;
}
.ft-action {
  cursor: pointer; background: none; border: none;
  display: inline-flex; align-items: center; gap: .35rem;
  padding: .35rem .5rem;
  color: #cfe0ff;
  text-transform: uppercase; letter-spacing: .03em;
  font-size: .82rem; font-style: italic; font-weight: 800;
  border-radius: .3rem;
  transition: color .12s ease, background .12s ease;
}
.ft-action:hover { color: #fff; background: #ffffff0f; }
.ft-action--strong { color: #7fe7ff; }
.ft-action--strong:hover { color: #b9f0ff; background: #1ab4ff1f; }

/* The DM-permission card (js/write-access.js). Telegram Mini App only, and
   only for somebody the bot is not allowed to message — so most sessions
   never render it and it must not reserve any space when absent, which a
   JS-inserted node gets for free.

   Deliberately quiet: it sits directly above the share/export row on the
   one screen it appears on, and a collection tracker interrupted by a loud
   notification pitch reads as an advert for itself. The copy does the work;
   see write-access.js for why this card exists at all rather than firing
   Telegram's native popup at boot. */
.write-access {
  display: flex; align-items: center; justify-content: space-between;
  gap: 1rem; flex-wrap: wrap;
  padding: .85rem 1rem;
  margin-top: 1rem;
}
.write-access-copy { flex: 1 1 16rem; min-width: 0; }
.write-access-title { margin: 0 0 .2rem; }
.write-access-body {
  margin: 0; font-size: .85rem; line-height: 1.45; color: var(--ink-dim, #9fb2cc);
}
.write-access-actions {
  display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
}
/* "Not now" must read as the lesser of the two without becoming invisible —
   a dismissal nobody can find is a card that cannot be got rid of. */
.write-access-later { font-size: .8rem; opacity: .75; }
.write-access-later:hover { opacity: 1; }
/* A shared collection is somebody ELSE'S vault, and this card is about your
   own notifications — so it goes, the same way `.shared-view .filters` does
   and from the same body class. Hidden rather than torn down because the
   shared view is reached from share.js, which knows nothing about this
   module; the own-vault mount rebuilds the card on the way back. */
.shared-view .write-access { display: none; }

/* =========================================================================
   THE TOOLBAR — what you are looking at, and what you can do with it.

   EVERY CONTROL IS DRAWN AS THE KIND OF CONTROL IT IS, which is the whole
   change. This was nine buttons over two rows: five rarity chips, two
   switches wearing the same chip, and a separate row of pill buttons above
   them. Three problems, and they are one problem.

   A chip row spends a line per five options and grows with the catalog, to
   present a choice of exactly one — that is a <select>, and the app already
   had one in the account panel. The two tier filters are independent
   booleans, and as chips they took the identical solid fill that "I am
   looking at Mythic" took, so nothing distinguished a CHOICE from a SWITCH;
   they are checkboxes, which is what a boolean looks like. And the actions
   were pill buttons stacked in a row of their own, three slabs above a row of
   seven more.

   Left group is the view, right group is the actions, one line on a desktop.
   Nothing here has a filled background any more: the only solid, lit objects
   between the nav and the grid are the sprites. */
.filters { display: flex; flex-direction: column; align-items: stretch; gap: .75rem; margin-bottom: .85rem; }
.filter-controls {
  display: flex; align-items: center; justify-content: space-between;
  gap: .6rem 1.25rem; flex-wrap: wrap;
  padding-bottom: .7rem;
  /* The one rule on this bar. It closes the chrome and opens the grid, which
     is the same job .account-foot's rule does for a dialog's buttons. */
  border-bottom: 1px solid #78aaff1f;
}
.filter-view { display: inline-flex; align-items: center; gap: .55rem 1.1rem; flex-wrap: wrap; min-width: 0; }

/* The rarity select carries NO sizing of its own — it is `.ft-select` and
   nothing else, exactly as the account panel's notification-channel select
   is, so the two are the same control rather than two things that resemble
   each other.

   It had `min-width: 9.5rem` for one pass, to stop the box changing width as
   the chosen option changed length ("All rarities" against "Epic") and
   nudging the switches after it. That bought a control sitting at 152px to
   hold 95px of word — a visibly half-empty box, and the one place on this bar
   where something was drawn bigger than what it contained. The reflow it
   avoided happens on an explicit press, it moves things by a few pixels, and
   the account panel's select has always done exactly the same without anyone
   minding. Hugging the content is the cheaper trade and it is the one that
   keeps the two selects identical. */

/* A switch is its label as well as its box — the whole thing is the <label>,
   so the target is the sentence rather than a 1.15rem square. */
.filter-check {
  display: inline-flex; align-items: center; gap: .45rem;
  cursor: pointer;
  /* Tracked small caps, not the sentence case a dialog's checkbox wears. On
     this bar it is the only thing that would have been lower case, between a
     select and two actions that are both uppercase — and a single sentence
     among them reads as a caption that wandered in rather than as a control.
     Lighter and smaller than either neighbour so it still says "label", not
     "button". */
  font-size: .72rem; font-weight: 800;
  text-transform: uppercase; letter-spacing: .06em;
  color: #9fb4d9;
  white-space: nowrap;
  transition: color .12s ease;
}
.filter-check:hover { color: #eaf4ff; }
.filter-check:has(.ft-check:checked) { color: #7fe7ff; }

/* =========================================================================
   MATRIX — the tracker grid
   ========================================================================= */
.matrix { display: grid; gap: 1rem; }
.creature-band { margin-top: 1rem; }
/* One species per row. The card is wide enough for every variant cell to sit
   on a single .cell-row line, so the grid reads like the export image's
   matrix — rows = species — instead of wrapping into columns. */
.creature-grid { display: grid; grid-template-columns: 1fr; gap: 0; }

/* The variant header only exists in the desktop matrix — on a phone every
   cell carries its own label instead (see the media query below). */
.variant-header { display: none; }

/* THE COLUMN RULER. These are table column labels, and until now they were
   the loudest object on the page: three full-width gradient slabs wearing
   .ft-rarity — the badge primitive that marks a species Mythic — stacked
   directly above the 117 sprites they exist to caption. A header that
   out-shouts every row under it is a header the eye has to get past.

   They are eyebrows now, in the trades register, ruled under in the variant's
   own colour. The colour was the only information the slab carried and it is
   all that survives. Nothing else on the page changed size, and the sprites
   became the loudest thing on it.

   A species' rarity colour comes from the DB (sprite_types), but a variant has
   no such column — Gold/Gummy/Galaxy are a look, not data — so the colours
   live here, keyed on the DB's `variant` value. The neutral default is what an
   unrecognised variant gets, so a catalog sync that adds one still renders a
   legible label until someone picks its colour. */
.variant-head-label {
  display: block; text-align: center;
  font-size: .68rem; font-weight: 900; letter-spacing: .13em;
  text-transform: uppercase; color: var(--rarity);
  padding-bottom: .45rem;
  border-bottom: 2px solid color-mix(in srgb, var(--rarity) 55%, transparent);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.variant-head-label { --rarity: #9fb4d9; --grad-from: #dfeaff; --grad-to: #7f9ec9; }
.variant-head-label[data-variant="cube"]     { --rarity: #5b2fb8; --grad-from: #8a5aeb; --grad-to: #3a1580; }
.variant-head-label[data-variant="gold"]     { --rarity: #ffc31f; --grad-from: #ffe680; --grad-to: #f5a300; }
.variant-head-label[data-variant="cheatmaster"] { --rarity: #35e06a; --grad-from: #9dffbc; --grad-to: #16a848; }
.variant-head-label[data-variant="quack"]    { --rarity: #dd9bff; --grad-from: #f6ddff; --grad-to: #b878f0; }
.variant-head-label[data-variant="gummy"]    { --rarity: #ff6fc4; --grad-from: #ffc0e5; --grad-to: #e33d9c; }
.variant-head-label[data-variant="galaxy"]   { --rarity: #7b5cff; --grad-from: #c3b6ff; --grad-to: #4b2fd6; }
.variant-head-label[data-variant="holofoil"] { --rarity: #4fe3ff; --grad-from: #c8f6ff; --grad-to: #14a8d8; }
.variant-head-label[data-variant="gem"]      { --rarity: #eaf6ff; --grad-from: #ffffff; --grad-to: #b9e4ff; }
/* Cube's --rarity is dark enough to be unreadable as text on this ground —
   the badge it used to wear put near-black ink ON that colour, so the problem
   arrives the other way round now. Its lighter --grad-from is the label. */
.variant-head-label[data-variant="cube"] { color: var(--grad-from); }

/* =========================================================================
   THE LEDGER — one species per row, and the left edge is the chart.

   THE SIGNATURE. The trades screen made the left edge of a card say whose
   move it is; the question a COLLECTION asks is not whose move but how far,
   so every row here carries the same 4px rail filled to the fraction of that
   species you hold. Twelve rails down the page is a bar chart of the whole
   vault that costs no space and no element — you read where the gaps are
   before you read a single word, and only then start on the names.

   That is why the boxes went. Each species used to be an .ft-panel: its own
   fill, its own 1px border, its own .75rem radius — identical to the progress
   card, the actions card and the save hint above it, so twelve rows of
   CONTENT were drawn exactly like four pieces of CHROME. A card is for an
   object with its own state, which is right for a trade and wrong for a
   species: these are rows of one table, and on a desktop they literally are
   one. So they are ruled apart by a hairline, the way .account-row separates
   settings, and the only thing left on the left edge is the rail — which now
   says something instead of framing something.

   The rail is inset from both ends rather than running the full height, so
   two neighbouring rails read as two readings rather than as one broken
   column, and the hairline between them stays the divider.
   ========================================================================= */
/* THE SAME HUE AT REST. Both rails — a species row's and a rarity band's in
   the masthead — spend their colour down to this rather than to a neutral, so
   an empty vault still says WHAT is empty.

   Mixed toward a near-neutral #242938 rather than faded to transparent or
   mixed into the panel's blue-black. Low alpha on a 4px bar reads as damaged
   rather than as dim; and a blue-heavy base drags the warm bands off their own
   hue — measured against the catalog's four colours, mixing into #202a55 moved
   Mythic 40deg toward mauve and Legendary 17deg toward brown, where this base
   holds every band within 11deg of itself at roughly the lightness the old
   flat neutral had. */
.creature-card, .legend-item {
  --rail-dim: color-mix(in srgb, var(--rarity, #9fb4d9) 34%, #242938);
}

/* ONE HUE PER RAIL, AND ONLY THE FILL MOVES. The rail is the species' rarity
   colour at every state — spent down to a muted version of itself where the
   species is unheld, full strength where it is held. Nothing else changes, so
   the left edge stays readable as two independent facts at once: WHICH band a
   row belongs to, by colour, and HOW MUCH of it you have, by height.

   The unheld end used to be a flat neutral #2f3c6e and the finished end used
   to be gold. Both broke that: a neutral track dropped the row's identity
   exactly where nothing else on the row carries it, and gold made the one
   state you most want to pick out of a column the one state whose colour says
   nothing about what it is. A completed species is now simply its own colour,
   full height — which is what "finished" looks like on a gauge. */
.creature-card {
  --fill: 0;
  position: relative;
  display: flex; flex-direction: column; gap: .6rem;
  padding: .95rem 0 .95rem 1.15rem;
}
.creature-card + .creature-card { border-top: 1px solid #78aaff1f; }
.creature-card::before {
  content: "";
  position: absolute; inset: .6rem auto .6rem 0; width: 4px; border-radius: 2px;
  background: linear-gradient(to top,
    var(--rarity) 0 calc(var(--fill) * 100%),
    var(--rail-dim) calc(var(--fill) * 100%) 100%);
  box-shadow: 0 0 20px -4px var(--rarity);
}
/* Nothing owned: the muted end of its own hue, all the way up, and no glow —
   the row still says which band it belongs to, and says you hold none of it. */
.creature-card--empty::before { background: var(--rail-dim); box-shadow: none; }
/* Finished: the same colour at full strength for the whole height. Stated as
   its own rule rather than left to `--fill: 1` so the glow can go up a step —
   the one state worth catching from the corner of the eye. */
.creature-card--complete::before {
  background: var(--rarity);
  box-shadow: 0 0 20px -2px var(--rarity);
}
.creature-head { display: flex; align-items: center; gap: .5rem; }
/* The head carries name, rarity, count and the two info icons. Only the name
   gives way — everything else is fixed-size and must stay legible, so a long
   species name ellipsises rather than pushing an icon off the card on a
   narrow viewport. */
.creature-name {
  font-family: var(--font-display); font-style: italic; text-transform: uppercase; letter-spacing: .02em; font-size: 1.15rem;
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.creature-head > .ft-rarity,
.creature-head > .creature-count,
.creature-head > .sprite-meta-group { flex: none; }
/* THE TABLE'S TWO AXES ARE LABELLED ALIKE. This is the row label — a species'
   rarity — and .variant-head-label is the column label; they name the two
   axes of the same grid, and they now read as the same kind of mark: an
   uppercase eyebrow in the thing's own colour.

   It was a gradient badge with a white hairline and a coloured glow, and it
   had become the second statement of something already said louder — the
   row's rail IS this colour, at eight times the size, and carries the
   species' completeness besides. Two lit objects a centimetre apart about one
   subject read as two subjects, which is the argument that put the Epic
   shield ON the account button rather than beside it. The rail keeps the
   colour; this keeps the word, which the rail cannot say.

   Left as .ft-rarity rather than renamed: it is the species' rarity wherever
   it appears, and once the column ruler stopped borrowing it there is exactly
   one caller (matrix.js::creatureCard). */
.ft-rarity {
  display: inline-flex; align-items: center; gap: .4rem;
  font-weight: 900; font-size: .66rem;
  text-transform: uppercase; letter-spacing: .13em;
  color: var(--rarity);
}
/* The app's -11deg mark, the same one .trade-eyebrow wears — so "this species
   is Mythic" and "this trade is waiting on you" are the same shape of
   statement. It is the only lit thing left in the label, which is what keeps
   a column of twelve of them scannable by colour alone. */
.ft-rarity::before {
  content: ""; flex: none; width: .3rem; height: .62rem;
  background: currentColor; transform: skewX(-11deg);
  box-shadow: 0 0 8px -1px currentColor;
}
.ft-rarity > span { display: inline-block; }
/* The row's readout, and the rail's caption — tabular so twelve of them make
   a column.

   GOLD ON A FINISHED SPECIES, and it is the one place gold survives on this
   row. The rail carries hue for rarity and height for progress, which leaves
   it nothing free to shout "done" with; this does, in the colour the ring
   label and the nav percentage already use for a completed vault. The two are
   not saying the same thing twice — the rail says how much, this says the
   count has stopped mattering — and neither has to be read for the other to
   make sense. */
.creature-count {
  margin-left: auto; font-weight: 700; color: #9fdcff;
  font-style: italic; font-size: .85rem;
  font-variant-numeric: tabular-nums;
}
.creature-card--complete .creature-count { color: var(--gold); }
.creature-card--empty .creature-count { color: #7f93bd; }

/* sprite meta icons (ability / where-to-find) with hover+tap tooltips.
   The pair keeps a tighter gap than the card head's, so adding the second
   icon costs the species name as little width as possible. */
.sprite-meta-group { display: inline-flex; align-items: center; gap: .3rem; }
.sprite-meta { position: relative; display: inline-flex; }
.sprite-icon-btn {
  display: inline-flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
  width: 1.5rem; height: 1.5rem; border-radius: 50%;
  color: #9fc4ff; background: #ffffff0d; border: 1px solid #78aaff33;
  transition: color .1s, background .1s, border-color .1s;
}
.sprite-icon-btn:hover, .sprite-icon-btn:focus-visible {
  color: #7fe7ff; background: #ffffff1a; border-color: #78aaff66; outline: none;
}
.sprite-icon-btn > svg { display: block; }
/* Anchored to the icon's right edge, not centred on it: the icon is the last
   thing in the card head, so a centred bubble would hang off the right of the
   card — and off the viewport for the last column of the grid. */
.sprite-tip {
  /* right: 0, flush with the icon — a negative offset would push the tip past
     the head's right edge and show up as overflow on the flex row. */
  position: absolute; bottom: calc(100% + .5rem); right: 0;
  z-index: 30; width: max-content; max-width: min(15rem, 72vw); pointer-events: none;
  display: flex; flex-direction: column; gap: .35rem; padding: .55rem .7rem;
  background: #0c1230; color: #eaf2ff; border: 1px solid #78aaff40; border-radius: .5rem;
  box-shadow: 0 12px 30px #00000080;
  font-size: .82rem; line-height: 1.4; font-style: normal; text-transform: none; letter-spacing: normal;
  text-align: left; white-space: normal;
  opacity: 0; visibility: hidden; transition: opacity .12s;
}
.sprite-tip::after {
  content: ""; position: absolute; top: 100%; right: .8rem;
  border: 6px solid transparent; border-top-color: #0c1230;
}
/* Flipped below the icon by ui.js when the card sits too close to the top of
   the viewport for the tip to fit above it. */
.sprite-meta.is-below .sprite-tip { bottom: auto; top: calc(100% + .5rem); }
.sprite-meta.is-below .sprite-tip::after {
  top: auto; bottom: 100%; border-top-color: transparent; border-bottom-color: #0c1230;
}
.sprite-tip-row { display: flex; flex-direction: column; gap: .1rem; }
.sprite-tip-label { font-weight: 800; color: #9fc4ff; text-transform: uppercase; letter-spacing: .04em; font-size: .68rem; }
/* .is-open is the tap toggle ui.js drives, and is the only mechanism on
   touch. Hover/focus-within are deliberately fenced behind a real pointer:
   a tap synthesises a :hover that sticks until the next tap elsewhere, so
   an unfenced hover rule holds the tip open and makes tap-to-close look
   broken — verified in Chromium under isMobile. */
.sprite-meta.is-open .sprite-tip { opacity: 1; visibility: visible; }
@media (hover: hover) and (pointer: fine) {
  .sprite-meta:hover .sprite-tip,
  .sprite-meta:focus-within .sprite-tip { opacity: 1; visibility: visible; }
}

/* variant cells */
.cell-row { display: flex; flex-wrap: wrap; gap: 1.1rem .6rem; }
.ft-cell {
  width: var(--cell-size); height: var(--cell-size); border-radius: .3rem; cursor: pointer; position: relative;
  display: inline-block; transform: skew(-11deg); transition: box-shadow .1s, filter .1s;
  border: 2px solid #ffffff24;
}
.ft-cell:hover { transform: skew(-11deg) translateY(-2px); filter: brightness(1.08); }
.ft-cell-inner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; transform: skew(11deg); }
.ft-sprite-img { width: 100%; height: 100%; object-fit: contain; padding: .3rem; pointer-events: none; user-select: none; filter: drop-shadow(0 1px 2px #0000008c); }

/* A SPRITE YOU DO NOT HAVE STILL HAS TO BE RECOGNISABLE — half of what this
   page is for is the gap, and you cannot want a sprite you cannot make out.
   The old fill ended in #0006, a wash DARKER than what was behind it, which
   worked while every cell sat on a lit .ft-panel and stopped working the
   moment the species boxes went: on the page's own ground it turned a missing
   tile into a hole. It is a faint lift now, in both stops, and the image is
   one step less faded. The owned state is untouched and still unmistakable —
   a full rarity gradient with a white edge, against this. */
.ft-cell--off { background: linear-gradient(#ffffff17, #ffffff08); border-color: #ffffff2b; }
.ft-cell--off .ft-sprite-img { filter: grayscale(1) brightness(.95) contrast(.9); opacity: .55; }
.ft-cell--owned {
  border: 2px solid #ffffffd9;
  background: radial-gradient(120% 100% at 50% 115%, var(--grad-from, var(--rarity)) 0, var(--rarity) 42%, var(--grad-to, var(--rarity)) 100%);
  box-shadow: 0 0 16px color-mix(in srgb, var(--rarity) 75%, transparent), inset 0 -10px 18px color-mix(in srgb, var(--rarity) 40%, #fff);
}
.ft-cell--mastered { border-width: 3px; border-color: var(--gold); box-shadow: 0 0 14px #ffc31fa6, inset 0 -10px 18px color-mix(in srgb, var(--rarity) 40%, #fff); }
.cell-label { text-align: center; font-size: .62rem; text-transform: uppercase; letter-spacing: .03em; color: #9fb4d9; pointer-events: none; }
/* Dust cost, overlaid in the cell's bottom-left corner (ui.js::dustBadge
   appends it to the cell, not the wrap). It was a third caption line under the
   tile; the cells are now big enough to carry it, and inside the tile it reads
   as a price tag on the thing it prices instead of stretching the caption
   block. The badge inherits the cell's -11deg and skews back out of it, the
   same trick .ft-cell-compare-label-text uses; the offsets are in the skewed
   frame, so it hugs the parallelogram's corner rather than a notional square
   one. Tabular figures keep the ◆ glyphs aligned down a column when the values
   differ in width (100 vs 18,750). */
.cell-dust {
  /* 4px, not 0: the counter-skew shears the pill's own corners back out by
     about a pixel, so a badge flush to the edge pokes through the cell's
     slanted border — and the mastered state's border is 3px wide. */
  position: absolute; left: 4px; bottom: 4px; z-index: 3;
  padding: .1rem .2rem; border-radius: .18rem;
  font-size: .55rem; font-weight: 800; line-height: 1;
  color: var(--gold, #ffc31f); pointer-events: none;
  font-variant-numeric: tabular-nums;
  /* Opaque enough to stay legible over both a greyed-out tile and a bright
     rarity gradient — the cell fill underneath varies more than a caption's
     background ever did. */
  background: #070b26bf;
  border: 1px solid #ffc31f33;
  text-shadow: 0 1px 2px #000000a6;
  transform: skew(11deg);
}
/* Compare mode — and the "coming soon" ribbon — park their own full-width
   label along the bottom of the cell; two badges in the same strip is one too
   many, so the price steps aside. */
.ft-cell:has(> .ft-cell-compare-label) .cell-dust,
.ft-cell:has(> .ft-cell-soon-label) .cell-dust { display: none; }
/* The mastery crown, built by ui.js::crownIcon wherever the crown is an icon.
   Every call site sets its own `width`; the ratio comes from the img's
   width/height attributes, so the box is the right shape before the file
   arrives.

   That ratio is load-bearing for the widths below: the art is a real crown
   silhouette and so much WIDER than tall (256x152, ~1.68:1) where the Victory
   Crown render it replaced was nearly square (1.16:1). Every width here was
   re-picked for height rather than carried over — a width tuned to the old
   ratio lands at two thirds of the mark it used to make.

   The file is NOT fingerprinted — see ui.js::CROWN_ICON for what changing the
   art requires. */
.crown-icon { display: inline-block; height: auto; flex: none; }
/* The mastery badge, and — since the −/+ row under each mastered cell was
   removed — the only place the mastery count appears in the grid.
   ui.js::masteryCrown builds it for both grids.

   At one mastery it's the circle it has always been: "1" is what a crown
   already means, and the round badge is the common case. From two up it grows
   a number and relaxes into a pill, which is the only shape change — so the
   count costs the layout nothing, where the old stepper cost every mastered
   cell a third caption line the other cells didn't have.

   The badge went dark when the ♛ glyph became gold crown art: the fill it
   used to have was gold too, and gold art on a gold disc is a blob.
   Everything gold about it moved to the rim and the count, which is
   also what the mastered CELL does (`.ft-cell--mastered`), so the badge now
   echoes the border it sits on instead of fighting it. */
.ft-cell-crown {
  position: absolute; top: -10px; right: -10px; z-index: 3;
  min-width: 1.75rem; height: 1.75rem; border-radius: .875rem;
  display: flex; align-items: center; justify-content: center; gap: .05rem;
  padding: 0 .18rem;
  color: #ffd76a; font-size: .85rem; line-height: 1; transform: skew(11deg);
  background: linear-gradient(#1a2560, #0a0e2c);
  border: 1.5px solid var(--gold, #ffc31f);
  box-shadow: 0 1px 5px #000000a6, 0 0 8px #ffc31f4d;
}
.ft-cell-crown-mark { width: 1.35rem; }
/* Tabular figures so a column of 2s and 12s keeps its badges the same width
   apart, and a slightly smaller crown so the pair reads as one token. */
.ft-cell-crown--n { padding: 0 .36rem 0 .2rem; gap: .15rem; }
.ft-cell-crown--n .ft-cell-crown-mark { width: 1.2rem; }
.ft-cell-crown-n {
  font-family: var(--font-sans); font-weight: 800; font-size: .78rem;
  font-variant-numeric: tabular-nums;
}
.ft-cell-compare-label {
  position: absolute; left: 4px; right: 4px; bottom: 4px; z-index: 4;
  height: 16px; display: flex; align-items: center; justify-content: center;
  padding: 0 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  border-radius: .12rem; box-shadow: 0 1px 4px #00000080;
}
.ft-cell-compare-label--theirs {
  color: #06182e; background: linear-gradient(#fff, #b9ecff 55%, #5cc6ff);
  border: 1px solid #eaf4ffcc;
}
.ft-cell-compare-label--mine {
  color: #06182e; background: linear-gradient(#edfff4, #9ff1bd 55%, #56d38b);
  border: 1px solid #d9ffe8cc;
  opacity: .7;
}
.ft-cell-compare-label-text {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: .58rem; font-weight: 900; line-height: 1; text-transform: uppercase;
  letter-spacing: 0; transform: skew(11deg); text-shadow: 0 1px #ffffff99;
}

/* ---- "coming soon": a sprite the catalog lists but nobody can own yet ----
   (public.sprites.unreleased, migration 20260808014931)

   Same slot and geometry as the compare labels above, deliberately: the two
   can never appear on one cell — an unreleased sprite is in nobody's
   collection, so it has no Mine/Theirs verdict to draw — and reusing the
   strip means a cell has exactly one ribbon position, whatever is in it.

   Cyan rather than the compare palette's green/blue-white, because this
   isn't a verdict about two collections; it's a note about the catalog. The
   cell underneath keeps .ft-cell--off's greyed treatment, so the sprite reads
   as "not yours" before you get to the word. */
.ft-cell--soon {
  cursor: default;
  border-style: dashed;
  border-color: #7fe7ff5c;
}
/* No hover lift: nothing happens on click, and a tile that rises under the
   pointer is the grid's affordance for "this one toggles". */
.ft-cell--soon:hover { transform: skew(-11deg); filter: none; }
/* Dimmer than a merely-unowned sprite, so a coming-soon cell recedes behind
   the ones actually in play rather than reading as another missing tile. */
.ft-cell--soon .ft-sprite-img { opacity: .28; }
.ft-cell-soon-label {
  position: absolute; left: 4px; right: 4px; bottom: 4px; z-index: 4;
  height: 16px; display: flex; align-items: center; justify-content: center;
  padding: 0 5px; overflow: hidden; white-space: nowrap;
  border-radius: .12rem; box-shadow: 0 1px 4px #00000080;
  color: #04202e; background: linear-gradient(#f2fdff, #b6f0ff 55%, #7fe7ff);
  border: 1px solid #e6fbffcc;
}
.ft-cell-soon-label-text {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: .58rem; font-weight: 900; line-height: 1; text-transform: uppercase;
  letter-spacing: 0; transform: skew(11deg); text-shadow: 0 1px #ffffff99;
}
.cell-wrap { display: flex; flex-direction: column; align-items: center; gap: .4rem; }

/* The shared view's wrapping row breathes a little more than the owner's: its
   cells carry compare outlines, which sit OUTSIDE the tile and would touch the
   neighbouring one at the tighter gap. Stated here, above the matrix media
   query, so the desktop grid's own gap still wins there — the outlines have
   the column gutter to sit in, and matching the owner's spacing matters more
   than the loose row does. */
.shared-view .cell-row { gap: 1.35rem .9rem; }

/* ---- desktop only: the matrix — species left, variants across the top ----
   Variant names appear once in a header row, the species name moves into a
   fixed left column, and every card's cells lock to the header's columns
   (matrix.js hands each cell its 1-based --col and leaves a hole where a
   species lacks that variant).

   Two lengths are load-bearing and shared: --name-col and --head-gap are the
   left column and its gutter, and the header pads itself by exactly those plus
   the card's own inset. Change one without the other and every label stops
   naming the column under it.

   All of it is gated on width. Eight tappable cells plus a name column don't
   fit a phone, and the ways to make them — scrolling sideways, or shrinking the
   tiles — both cost more than the matrix is worth there. So a narrow screen
   keeps the layout it always had: the species head above a wrapping row of
   cells, each labelled with its own variant, and no header row at all (the
   base rule leaves .variant-header display: none). matrix.js still renders
   that header's markup at every width; only CSS decides whether it exists.

   Scoped to .creature-band--matrix, which is the band matrix.js emits — so
   BOTH vault views get this, the owner's and the read-only shared one. They
   render through the same component precisely so a rule here can't reach one
   grid and miss the other. */
@media (min-width: 60rem) {
  .creature-band--matrix { --name-col: 12.5rem; --head-gap: .9rem; }

  .creature-band--matrix .variant-header {
    display: block;
    /* Sticky, as the second row of the chrome: the grid is many viewports tall
       and a column is only identifiable while its badge is on screen. It parks
       under the nav and below the nav's z-index, so the two stack in the right
       order; both sit under .sprite-tip's 30 so an open tooltip still draws
       over them.

       Flush against the nav, with no --chrome-gap between them. The vignette
       fades in at the top over --vig-top rather than starting solid, but that
       run is short and lands entirely in the row's padding above the badges —
       any actual gap here would become a band of cells peeking between two
       pieces of chrome. Butted up, the shade reads as the nav's own falloff.

       --nav-h alone, with no --sticky-top term: the nav starts at 0 and
       carries the safe area inside its own height, so its bottom edge IS
       --nav-h. */
    position: sticky;
    top: var(--nav-h, 3.25rem);
    z-index: 20;
    /* The strip itself paints nothing — it only positions. The shade belongs
       to the badges, so it lives on the .cell-row below, which spans exactly
       the columns and stops where they stop; on this element it would also
       cover the empty name-column indent, and a vignette over nothing is just
       a smudge on the left. */
    --vig-bleed: 1.25rem;
    --vig-run: 2.6rem;
    /* The top falloff, and the one measurement here that isn't free to grow:
       the layer starts at the row's top edge and the nav is fixed above it, so
       a longer run can't be given more room — it can only eat into the shade
       the badges sit on. Kept inside .cell-row's .5rem top padding, so it is
       spent entirely above them. */
    --vig-top: .5rem;
    padding-block: 0;
    /* Indented past the row's own left padding (which clears the rail) and the
       name column, so each label lands over the cells it names. */
    padding-inline: calc(1.15rem + var(--name-col) + var(--head-gap)) 0;
  }
  .creature-band--matrix .creature-card {
    display: grid;
    grid-template-columns: var(--name-col) minmax(0, 1fr);
    column-gap: var(--head-gap);
    align-items: center;
  }
  /* The head becomes the left column: name on its own line, then the rarity
     badge, the count and the info icons packed under it. `margin-left: auto`
     on the count belongs to the full-width row layout — here it would shove
     the icons against the far edge of the column. */
  .creature-band--matrix .creature-head {
    flex-wrap: wrap;
    align-content: center;
    gap: .3rem .35rem;
  }
  .creature-band--matrix .creature-name {
    flex-basis: 100%;
    font-size: 1.05rem;
    white-space: normal; overflow: visible;
  }
  .creature-band--matrix .creature-count { margin-left: 0; }

  .creature-band--matrix .cell-row {
    display: grid;
    grid-template-columns: repeat(var(--cols, 8), minmax(var(--cell-size), 1fr));
    gap: .35rem .6rem;
  }
  .creature-band--matrix .cell-wrap,
  .variant-head-label { grid-column: var(--col); }
  /* The header names each column once, so the per-cell copy is noise — but
     only here. Below the breakpoint the label under each cell is the ONLY
     thing naming its variant. */
  .creature-band--matrix .cell-label { display: none; }

  .creature-band--matrix .variant-header .cell-row {
    position: relative;
    z-index: 0;
    padding-block: .5rem;
    /* Real space between the badges and the first card. The fade below reaches
       past it (see --vig-run) rather than being made of it, so the gap and the
       falloff are two independent knobs. */
    margin-bottom: 1.4rem;
  }
  /* The badge row's vignette: a solid fill masked away downwards, briefly at
     the top and, more gently, at both ends — so the badges sit in a pool of
     shade the cards rise out of, with no edge anywhere to read as a panel. Two
     masks composited to their intersection: one fades top and bottom, one
     softens the sides.

     It's a backing layer, not the row itself. A mask on the row applies to its
     CONTENTS as well as its fill, which faded the badges along with the
     background and made them read as half-transparent — the one thing they
     must not be. On a pseudo behind them the gradient only ever touches fill.

     A TINT AND A BLUR, NOT A SLAB. This was solid #0a0f2c, which reads as a
     lid: the labels sat on a panel rather than over the grid, and the one
     thing a sticky header should not look like is a second page laid on top
     of the first. It is a wash now — the fill is mostly transparent and the
     legibility comes from a backdrop blur, so what scrolls underneath stays
     visible as colour and movement while losing the detail that would compete
     with the words.

     Which reverses a note that used to sit here saying a blur was deliberately
     avoided, on the grounds that a masked blur only smears the cards where the
     fill has already gone. That was true of the old header: tall gradient
     badges pooled in a deep shade, where the blur had nothing to do that the
     fill was not already doing. Against a ruled eyebrow there is no shade to
     hide behind, and the blur is the only thing that buys legibility without
     opacity. Same reason `.squad-rail` wears one.

     The layer hangs --vig-run below the row so the falloff runs through the
     gap and onto the first card, and --vig-bleed past each end so the side
     falloff happens beside the badges rather than over the first and last.

     The top is the one side with no bleed to spend: the nav is fixed and
     opaque at a higher z-index, so anything drawn above the row's edge is
     simply covered. The fade therefore runs downwards from that edge over
     --vig-top, inside the row's own padding.

     THE SOLID BAND IS THE ROW, EXACTLY. The layer is the row plus --vig-run
     hanging below it, so `100% - --vig-run` is the row's own bottom edge:
     opaque behind every label, with the entire falloff spent in the gap and
     on the first card. It used to be a flat 24% of the layer, tuned for the
     tall gradient badges that used to live here — against the ruler that
     replaced them the layer is half as high, 24% landed level with the
     labels, and the first species' cells scrolled visibly THROUGH the header.
     A share of a height that depends on what is being backed cannot be a
     constant; the row's own edge can. */
  .creature-band--matrix .variant-header .cell-row::before {
    content: "";
    position: absolute;
    z-index: -1;
    inset: 0 calc(-1 * var(--vig-bleed)) calc(-1 * var(--vig-run)) calc(-1 * var(--vig-bleed));
    /* Low enough to see the grid through, high enough that the blur has
       something to sit on — on its own a blur over a dark page barely
       registers. */
    background: #0a0f2c8f;
    backdrop-filter: blur(10px) saturate(1.1);
    -webkit-backdrop-filter: blur(10px) saturate(1.1);
    mask-image:
      linear-gradient(to bottom, transparent 0, #000 var(--vig-top),
                      #000 calc(100% - var(--vig-run)), transparent 100%),
      linear-gradient(to right, transparent 0, #000 var(--vig-bleed),
                      #000 calc(100% - var(--vig-bleed)), transparent 100%);
    mask-composite: intersect;
    -webkit-mask-image:
      linear-gradient(to bottom, transparent 0, #000 var(--vig-top),
                      #000 calc(100% - var(--vig-run)), transparent 100%),
      linear-gradient(to right, transparent 0, #000 var(--vig-bleed),
                      #000 calc(100% - var(--vig-bleed)), transparent 100%);
    -webkit-mask-composite: source-in;
    pointer-events: none;
  }
  /* One width for every label — the column's — so the header reads as a table
     head. Stretching the grid item is what equalises them, and it is also what
     makes each column's rule exactly as wide as the column it names. */
  .variant-head-label { justify-self: stretch; }
}

@media (min-width: 1120px) {
  .creature-band--matrix { --cell-size: 5.5rem; }
}
/* …but the rail spends --rail-w plus a gutter before the grid gets any, and
   the rule above measures the viewport. Hold the small cell until the wider
   one actually fits beside the rail. Measured, not derived: 8 columns at
   5.5rem need ~1014px of card, which clears the rail at ~1310px — rounded up
   for the gutter clamp and a scrollbar. */
@media (max-width: 1380px) {
  body.squad-split .creature-band--matrix { --cell-size: 4.25rem; }
}

/* Shared-view: cells aren't interactive. */
.ft-cell--readonly { cursor: default; pointer-events: none; }
.ft-cell--readonly:hover { transform: skew(-11deg); filter: none; }

/* Compare-mode borders (activated only when the shared view's compare toggle
   is on). Outlines sit above the cell fill and are visible on both greyed and
   coloured sprites — a "mine-only" sprite stays greyed with a dashed green
   outline, exactly as spec'd. */
.ft-cell--compare-both   { outline: 4px solid #56d38b; outline-offset: 1px; }
.ft-cell--compare-theirs {
  outline: 4px solid #5cc6ff; outline-offset: 1px;
}  /* they own, you don't */
.ft-cell--compare-mine   { outline: 2px dashed #56d38b99; outline-offset: 1px; } /* you own, they don't */

/* ---- shared-view compare bar ---- */
.compare-bar {
  margin: .8rem 0 1.2rem;
  padding: .9rem 1rem;
  display: flex; flex-direction: column; gap: .7rem;
}
.compare-controls { display: flex; flex-wrap: wrap; gap: 1.2rem; align-items: center; }
.compare-toggle { display: inline-flex; align-items: center; gap: .5rem; cursor: pointer; user-select: none; }
.compare-toggle--disabled { opacity: .45; cursor: not-allowed; }
.compare-check { accent-color: var(--gold, #f5a623); width: 1rem; height: 1rem; }

.compare-stats { display: flex; flex-wrap: wrap; gap: .6rem; }
.compare-chip {
  display: inline-flex; align-items: center; gap: .5rem;
  padding: .35rem .7rem; border-radius: 999px;
  background: #ffffff0a; border: 1px solid #ffffff20; font-size: .85rem;
}
.compare-chip-label { color: #9fb4d9; }
.compare-chip-count { font-weight: 700; }
.compare-chip-dust  { color: #ffffff99; font-size: .8rem; }
.compare-chip--both   { border-color: #56d38b80; box-shadow: inset 0 0 0 1px #56d38b30; }
.compare-chip--theirs { border-color: #5cc6ff80; box-shadow: inset 0 0 0 1px #5cc6ff30; }
.compare-chip--mine   { border-color: #56d38b60; box-shadow: inset 0 0 0 1px #56d38b1a; border-style: dashed; }


/* ---- info sections ---- */
.info { margin-top: 3rem; }
.section-title { font-size: 1.5rem; margin-bottom: .4rem; }

/* ---- ported prose sections ---- */
.prose { color: #cfe0ff; line-height: 1.7; }
.variant-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: .75rem; margin-top: 1rem; }
.variant-card { padding: .9rem 1rem; }
.variant-card { --rarity: #7fb0ff; border-left: 3px solid var(--rarity); }
.variant-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-bottom: .35rem; }
.variant-name { font-size: 1.05rem; }
.variant-badge {
  font-size: .68rem; font-weight: 800; font-style: italic; text-transform: uppercase; letter-spacing: .03em;
  color: #06182e; padding: .18rem .55rem; border-radius: .25rem; transform: skew(-11deg);
  background: color-mix(in srgb, var(--rarity) 80%, #fff);
}
.variant-desc { margin: 0; font-size: .88rem; }

.faq-list { display: grid; gap: .6rem; margin-top: 1rem; }
.faq-item { padding: 0; overflow: hidden; }
.faq-item > summary {
  cursor: pointer; list-style: none; padding: .9rem 1.1rem; font-weight: 800; font-style: italic;
  text-transform: uppercase; letter-spacing: .02em; color: #dbeaff; font-size: .95rem;
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
}
.faq-item > summary::-webkit-details-marker { display: none; }
.faq-item > summary::after { content: "+"; color: #7fe7ff; font-size: 1.2rem; transition: transform .15s; }
.faq-item[open] > summary::after { content: "−"; }
.faq-item > p { margin: 0; padding: 0 1.1rem 1rem; line-height: 1.6; }

/* ---- footer ---- */
.site-footer { margin-top: 3rem; text-align: center; }
.site-footer a { color: var(--accent); text-decoration: none; }
.site-footer a:hover { text-decoration: underline; }
/* Trademark attribution: legally required to be visible, but it is not
   something a visitor needs to read — quieter than the operator line. */
.site-footer .footer-tm { margin-top: .5rem; font-size: .8em; opacity: .75; }

/* ---- the language picker (js/language-picker.js) ----
   In the footer because signed out there is no account panel to put it in,
   and because a fifth control would have broken the nav's measured overflow
   budget. Above the legal prose rather than below it: it is the one thing
   down here somebody might be LOOKING for, and the trademark line is the one
   thing nobody is. */
.footer-language {
  display: flex; align-items: center; justify-content: center;
  gap: .5rem; margin-bottom: 1.25rem;
}
.footer-language-label {
  color: var(--muted); font-size: .82rem; letter-spacing: .03em;
  text-transform: uppercase;
}
/* **THE OPTIONS OPT OUT OF `.ft-select`'s CASE AND SLANT, AND THAT IS THE
   POINT OF THE CONTROL.** Every other select here holds our own vocabulary,
   where uppercase italic is the house style; this one holds seven languages
   written the way their own speakers write them. Uppercasing turns Русский
   into РУССКИЙ (shouting, and not how the name is set), does nothing at all
   to 简体中文, and the italic is a SYNTHESISED oblique on both — the browser
   shearing glyphs sideways because no italic face exists, which on CJK is
   simply wrong. A name a reader has to recognise at a glance is the last
   place to restyle it. */
.footer-language-select {
  text-transform: none; font-style: normal; font-weight: 600;
  letter-spacing: normal;
}

/* ---- legal pages (privacy policy, etc.) ---- */
.legal { padding-block: 1rem 3rem; }
.legal .section-title { margin-top: 2rem; }
.legal .prose { margin: .6rem 0; }
.legal ul.prose { padding-left: 1.25rem; }
/* Ordered lists sit at the same indent as bullets. Left to the browser an <ol>
   takes 40px against the <ul>'s 20px, so a page carrying both — the levelling
   guide does — has two list indents for no reason a reader can see. */
.legal ol.prose { padding-left: 1.25rem; }
.legal ol.prose li { margin: .35rem 0; line-height: 1.7; }
/* h3 is a step inside a section, not a section: closer to the paragraph above
   it than an h2 is, so the two levels stay tellable apart at a glance. */
.legal h3.ft-heading { margin: 1.4rem 0 .3rem; font-size: 1.05rem; }
.legal ul.prose li { margin: .35rem 0; line-height: 1.7; }
.legal a { color: var(--accent); }

/* ---- data tables (sprite-dust, sprite-rarity) ----
   The wrapper, not the table, is the scroll container: a table that scrolls
   itself can't be given a border-radius or a shadow without clipping its own
   overflow, and on a phone the Dust tables are wider than the viewport. Rule
   from the audit: wide content scrolls inside its own box, the page body never
   scrolls sideways. */
.table-scroll { overflow-x: auto; margin: 1rem 0; -webkit-overflow-scrolling: touch; }
/* Keyboard users need to be able to reach a scrollable box; tabindex="0" on
   the wrapper is the standard fix, and it also gives the region a focus ring
   that hints the content moves. */
.table-scroll:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.data-table { border-collapse: collapse; width: 100%; min-width: 20rem; font-variant-numeric: tabular-nums; }
.data-table caption { caption-side: bottom; padding-top: .5rem; font-size: .85em; text-align: left; }
.data-table th, .data-table td { padding: .5rem .6rem; text-align: right; border-bottom: 1px solid var(--panel-border); }
.data-table thead th { text-align: right; color: #9fb4d9; font-weight: 600; font-size: .85em; text-transform: uppercase; letter-spacing: .04em; }
.data-table th[scope="row"], .data-table thead th:first-child { text-align: left; }
.data-table tbody tr:last-child th, .data-table tbody tr:last-child td { border-bottom: 0; }
/* The "All" summary row reads as a total, not as one more rarity. Keyed on a
   class, NOT :last-child — most of these tables end on Mythic, and bolding
   that made the rarest tier look like a sum of the rows above it. */
.data-table tr.total-row { font-weight: 700; }

/* ---- seasonal pages (season-N, lobby-hack-codes) ----
   .data-table right-aligns everything by default, which is right for the Dust
   ladders it was written for and wrong for a roster whose middle columns are
   sentences. Prose columns are opted back to the left by name; only the cells
   the build marks `.num` stay right-aligned, so a season whose variant line-up
   changes moves the numeric columns with it rather than stranding the rule on
   an nth-child that no longer points at Dust. */
.data-table td.num, .data-table thead th.num { text-align: right; }
/* A HEADER FOLLOWS ITS COLUMN. `.data-table thead th` is right-aligned with
   only `:first-child` excepted, which is right for the Dust ladders it was
   written for — every column after the first is a number there. These two
   tables have prose in the middle, so the header and the cells under it were
   pointing opposite ways: "What it gives" and "Where to find it" sat hard
   right above left-aligned sentences. Both halves are keyed off the same
   marker class, so a column cannot change side without its header. */
.roster-table td:not(.num),
.roster-table thead th:not(.num) { text-align: left; }
.roster-table { min-width: 44rem; }
/* Sized in the markup as well as here: the sprite art is the one image on
   these pages that has no CSS box fixing its geometry before load, so it
   carries width/height to reserve its own space and not shift the row. */
.roster-art { vertical-align: middle; margin-right: .45rem; border-radius: 4px; }
/* The name sits beside its artwork on one line. Without this the longer names
   wrap under the icon, which makes every row a different height and reads as
   two columns that happen to be in one cell. The column is narrow enough that
   the table's own horizontal scroll is the right place for the overflow. */
.roster-table th[scope="row"] { white-space: nowrap; padding-right: 1.5rem; }

.codes-table { min-width: 26rem; }
.codes-table td:not(.codes-used-col),
.codes-table thead th:not(.codes-used-col) { text-align: left; }
.codes-used-col { text-align: center !important; width: 4rem; }
/* A redeemed row recedes rather than disappearing — the list is also read as
   a reference, and hiding used codes would make the page disagree with every
   other list of the same codes. */
.codes-table tr.is-used { opacity: .55; }
.codes-table tr.is-used .cheat-code-text { text-decoration: line-through; }
/* The code cell reuses the Admin Panel modal's own `.cheat-code` /
   .cheat-code-text / .cheat-copy-icon (see "ADMIN PANEL" below), so the two
   surfaces cannot drift into styling the same object differently. Only the
   things that differ because this one is a table cell are set here.

   `is-copyable` is added by js/codes-page.js, never by the build: with no
   JavaScript the code is plain text, and a pointer cursor over something that
   cannot be clicked is a promise the page does not keep. */
.codes-table .cheat-code { margin-left: 0; cursor: default; white-space: nowrap; }
.codes-table .cheat-code.is-copyable { cursor: pointer; }
.codes-table .cheat-code.is-copied { color: #b5ffcd; background: #2ee06322; }
.codes-table .cheat-code.is-copied .cheat-copy-icon { color: #b5ffcd; }
.codes-status { font-weight: 600; }
.codes-status.is-error { color: #ffb4b4; }

/* ---- homepage explainer copy ----
   Static text below the tool. It ships in the HTML rather than being built by
   main.js, which is the whole point: it is the only prose on the home page a
   crawler (or an AI agent that doesn't run JS) can read.

   Hidden inside Telegram and Discord — there the Mini App IS the app and a
   wall of marketing copy under it is noise. The `embedded` class is set by a
   synchronous inline script in index.html <head>, before first paint, so this
   never flashes in and out. */
.site-copy { padding-block: 2rem 1rem; border-top: 1px solid var(--panel-border); margin-top: 3rem; }
.site-copy .section-title { margin-top: 1.75rem; }
.site-copy .prose { margin: .6rem 0; }
.site-copy ul.prose { padding-left: 1.25rem; }
.site-copy ul.prose li { margin: .35rem 0; line-height: 1.7; }
.site-copy a { color: var(--accent); }
.embedded .site-copy { display: none; }

/* =========================================================================
   MODALS + TOASTS (auth / trade dialogs)
   ========================================================================= */
/* The backdrop covers the whole WebView, and inside Telegram the top of that
   is UNDER the client's own header — which is what --sticky-top measures for
   the nav. A dialog centred in the full box therefore starts behind Telegram's
   controls and, worse, ENDS below the visible bottom on a phone. So the host's
   padding carries both insets, and --modal-avail-h is what a dialog may
   actually occupy: every height below is written against that rather than
   against a raw viewport unit. */
.modal-host {
  --modal-inset-top: calc(1rem + var(--sticky-top));
  --modal-inset-bottom: calc(1rem + var(--tg-safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
  --modal-avail-h: calc(100vh - var(--modal-inset-top) - var(--modal-inset-bottom));
  position: fixed; inset: 0; z-index: 100; display: grid; place-items: center;
  background: #04081aee; backdrop-filter: blur(4px);
  padding: var(--modal-inset-top) 1rem var(--modal-inset-bottom);
}
/* The dynamic unit where it exists, behind @supports rather than the usual
   two-declarations trick: a custom property swallows an unknown unit at
   declaration time and only fails where it is SUBSTITUTED, so a plain
   `100vh` line followed by a `100dvh` one would leave every height built on
   this var invalid instead of falling back. */
@supports (height: 100dvh) {
  .modal-host { --modal-avail-h: calc(100dvh - var(--modal-inset-top) - var(--modal-inset-bottom)); }
}
.modal-host[hidden] { display: none; }
.modal {
  width: min(100%, 30rem); max-height: min(90vh, var(--modal-avail-h));
  overflow-y: auto; padding: 1.5rem;
}

/* ---- background scroll lock (see ui.js::openModal) ----
   A dialog is the whole interaction while it's up, and the grid behind it
   must not travel under it. Two separate things scroll that grid: a wheel or
   trackpad over the backdrop, and a touch-drag anywhere on a phone — which
   in an iOS WebView reaches the document straight through a fixed overlay.

   The wheel half is `overflow: hidden` on both roots, set by ui.js. Deliberately
   NOT `position: fixed` on the body, the other common recipe: that one drops
   the scroll position (it has to be measured and restored by hand) and pulls
   the scroll container out from under .app-nav, whose `position: sticky` then
   collapses back into the flow.

   The touch half is here: the host refuses pans, and the dialog re-enables the
   one axis it needs, with `overscroll-behavior: contain` so a drag that hits
   the end of a tall dialog stops there instead of chaining out to the page.
   `pinch-zoom` is kept in both, deliberately — `touch-action: none` would have
   been shorter and would have taken zooming with it, and a dialog someone
   can't magnify is a dialog they can't read. */
.modal-host { touch-action: pinch-zoom; overscroll-behavior: contain; }
.modal { touch-action: pan-y pinch-zoom; overscroll-behavior: contain; }
html.modal-open, body.modal-open { overflow: hidden; }
.modal--wide { width: min(100%, 46rem); }

/* ---- bottom sheet (openModal's `sheet: true`) ----
   A phone-shaped dialog for something tapped in the grid: it rises from the
   bottom edge, so its controls land where the thumb already is instead of at
   the top of the screen, and it stays clear of Telegram's floating chrome,
   which draws over the WebView's top corners. Above the phone breakpoint it
   is just a centred modal — there is no thumb to be near on a desktop, and a
   docked panel would only be further from the pointer. */
@media (max-width: 640px) {
  .modal-host--sheet { place-items: end stretch; padding: 0; }
  .modal--sheet {
    width: 100%;
    /* Never taller than the viewport minus a thumbnail of the grid behind it
       — seeing the cell you tapped is what tells you which sprite this is. */
    max-height: min(88vh, 34rem);
    border-radius: 1rem 1rem 0 0;
    border-bottom: none;
    padding: 1.25rem 1.25rem 1rem;
    /* Telegram's inset on a notched phone; env() covers an installed PWA,
       and both fall back to 0 in a plain browser tab. */
    padding-bottom: calc(1rem + var(--tg-safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
    animation: sheet-up .18s ease-out;
  }
  /* A sheet is dismissed by tapping the backdrop or "Done"; the × would sit
     at the far end of a reach the sheet exists to avoid. */
  .modal--sheet .modal-close { display: none; }
}
@keyframes sheet-up { from { transform: translateY(100%); } to { transform: none; } }

/* ---- mastery sheet ----
   Opened by tapping a sprite you've already mastered. It replaces the −/+ row
   that used to sit under every mastered cell, which had to fit inside
   --cell-size (4.25rem) and so shipped 1.15rem buttons — about a quarter of
   the 44px touch-target minimum, a couple of millimetres from the next cell's.
   Here the same two buttons get 3.25rem and the count is legible across the
   room. */
.mastery-sheet-head { display: flex; align-items: center; gap: .9rem; margin-bottom: 1.1rem; }
/* The tapped tile, restated — it borrows .ft-cell for the skew, border and
   rarity gradient, and overrides only what a non-interactive echo shouldn't
   have. Larger than a grid cell because this one is the subject. */
.mastery-sheet-tile { width: 5rem; height: 5rem; cursor: default; flex: none; }
.mastery-sheet-tile:hover { transform: skew(-11deg); filter: none; }
.mastery-sheet-meta { display: flex; flex-direction: column; gap: .2rem; min-width: 0; }
.mastery-sheet-variant { font-weight: 800; font-size: 1.05rem; text-transform: uppercase; letter-spacing: .02em; }
.mastery-sheet-label { font-size: .82rem; color: #9fc4ff; text-transform: uppercase; letter-spacing: .03em; }

.mastery-sheet-stepper {
  display: flex; align-items: center; justify-content: center; gap: 1.1rem;
  padding: .75rem; border-radius: .75rem;
  background: #ffffff0d; border: 1px solid var(--panel-border);
}
.mastery-sheet-step {
  flex: none; width: 3.25rem; height: 3.25rem; border-radius: .6rem; cursor: pointer;
  display: grid; place-items: center;
  font-size: 1.7rem; font-weight: 800; line-height: 1;
  color: #eaf4ff; background: #ffffff14; border: 1px solid #78aaff59;
  transition: background .1s, border-color .1s;
  /* Held "+" repeats on desktop; on touch this also stops the press from
     selecting the glyph or firing the iOS callout. */
  user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
}
.mastery-sheet-step:hover:not(:disabled),
.mastery-sheet-step:focus-visible:not(:disabled) { background: #ffffff24; border-color: var(--accent); outline: none; }
.mastery-sheet-step:active:not(:disabled) { background: #ffffff33; }
.mastery-sheet-step:disabled { opacity: .35; cursor: default; }
.mastery-sheet-value {
  display: inline-flex; align-items: baseline; justify-content: center; gap: .35rem;
  min-width: 4.5rem; color: var(--gold);
  font-family: var(--font-display); font-size: 2.4rem; line-height: 1;
  font-variant-numeric: tabular-nums;
}
/* An <img> in a baseline-aligned flex row sits on the baseline by its bottom
   edge, which is where the number's digits sit too — so the pair reads as
   "♛ 4" without either being nudged. */
.mastery-sheet-crown { width: 2.9rem; }
.mastery-sheet-note { margin: .7rem 0 0; text-align: center; font-size: .85rem; color: var(--ink-dim); min-height: 2.4em; }
.mastery-sheet-actions { display: flex; gap: .6rem; margin-top: .4rem; }
/* min-height, not padding: .ft-pillbtn's own .55rem is fine in a dense modal
   but lands under the touch-target minimum, and these two are the last thing
   a thumb reaches on the way out of the sheet. */
.mastery-sheet-actions .ft-pillbtn { flex: 1; min-height: 2.9rem; }
/* The one destructive action here, so it says so in colour rather than
   relying on the label alone — but it stays a pill among pills, because a
   full red button next to "Done" reads as the primary choice. */
.mastery-sheet-remove { color: #ff9c9c; border-color: #ff8a8a59; }
.mastery-sheet-remove:hover { border-color: #ff8a8ab3; }
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
.modal-title { font-family: var(--font-display); font-style: italic; text-transform: uppercase; font-size: 1.4rem; }
.modal-close { background: none; border: none; color: #cfe0ff; font-size: 1.4rem; cursor: pointer; line-height: 1; }
.field { margin-bottom: .9rem; }
.field label { display: block; font-weight: 700; font-size: .82rem; text-transform: uppercase; letter-spacing: .03em; color: #9fc4ff; margin-bottom: .3rem; }
.form-error { color: #ff8a8a; font-size: .85rem; min-height: 1.1rem; }
.form-switch { text-align: center; margin-top: 1rem; color: #cfe0ff; font-size: .9rem; }
.link-btn { background: none; border: none; color: #7fe7ff; cursor: pointer; font-weight: 700; text-decoration: underline; }
.toast-host { position: fixed; bottom: 1.25rem; left: 50%; transform: translateX(-50%); z-index: 200; display: flex; flex-direction: column; gap: .5rem; align-items: center; }
.toast {
  background: #0c1230; border: 1px solid #78aaff59; color: #eaf4ff; padding: .7rem 1.1rem; border-radius: .5rem;
  box-shadow: 0 10px 30px #00000080; font-weight: 600; animation: toast-in .2s ease;
}
.toast--error { border-color: #ff8a8a80; }
.toast--success { border-color: #7fffb080; }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }

/* ---- trade-specific ---- */
.match-list { display: grid; gap: .75rem; }
.match-card { padding: 1rem; display: grid; gap: .6rem; }
.match-head { display: flex; align-items: center; gap: .6rem; }
.match-give-get { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
.match-col h4 { margin: 0 0 .4rem; font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; color: #9fc4ff; }
.mini-sprites { display: flex; flex-wrap: wrap; gap: .3rem; }
.mini-sprite { width: 2.4rem; height: 2.4rem; border-radius: .25rem; border: 1px solid #ffffff24; background: #ffffff0d; padding: .15rem; }
.match-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
.empty-state { text-align: center; color: #9fb4d9; padding: 2rem 1rem; }

/* =========================================================================
   TEAMS VIEWS — team list + roster
   ========================================================================= */
/* teams-view reuses the vault shell but hides the vault-only chrome, so
   the hero + #matrix carry the team list / roster instead. */
.teams-view .progress-panel,
.teams-view .filters,
.teams-view .vault-actions { display: none; }

/* The filter chips belong to the vault you can edit — in the read-only shared
   view they'd narrow someone else's grid by YOUR collection state, which the
   toggles have no way to mean there. share.js used to hide this by id, but the
   section only ever had a `filters` CLASS, so the lookup was null and the
   panel showed anyway: an empty rarity row plus two chips that did nothing.
   Class-based, like teams-view above, so it matches the markup that exists. */
.shared-view .filters { display: none; }

/* The install hint belongs to the signed-out vault; both of the views that
   borrow this shell are about someone/something else. It stays in the DOM
   (its mount is idempotent) and simply steps out of the way. */
.shared-view .save-hint,
.teams-view .save-hint { display: none; }

.ft-pillbtn--sm { padding: .3rem .7rem; font-size: .82rem; }

/* ---- team list ---- */
.team-list { display: flex; flex-direction: column; gap: .6rem; }
.team-row {
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  width: 100%; text-align: left; cursor: pointer;
  padding: .9rem 1.15rem; font: inherit; color: inherit;
  transition: background .15s ease, border-color .15s ease, transform .05s ease;
}
.team-row:hover, .team-row:focus-visible {
  background: #ffffff14; border-color: #9fc4ff59; outline: none;
}
.team-row:active { transform: scale(.995); }
.team-row-main { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
.team-row-name {
  font-family: var(--font-display); font-style: italic; text-transform: uppercase;
  letter-spacing: .02em; font-size: 1.15rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.team-row-meta { color: #9fb4d9; font-size: .85rem; }
.team-row-chevron { color: #9fc4ff; font-size: 1.6rem; line-height: 1; flex: none; }

/* ---- team roster ---- */
.member-list { display: flex; flex-direction: column; gap: .6rem; }
.member-row {
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  padding: .85rem 1.15rem; flex-wrap: wrap;
}
.member-row--owner { border-color: #ffc31f4d; }
.member-main { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
.member-name {
  font-family: var(--font-display); font-style: italic; text-transform: uppercase;
  letter-spacing: .02em; font-size: 1.1rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.member-meta { color: #9fb4d9; font-size: .82rem; }
.member-actions { display: inline-flex; align-items: center; gap: .5rem; flex: none; }

/* ---- Discord squad rail ----
   The server's roster as a fixed column, with whichever collection you
   picked filling the rest of the shell. It is navigation, not a view: it
   is mounted once per Activity boot and every other mount (own vault,
   someone else's, My Teams) happens beside it.

   Fixed rather than sticky for the same reason .app-nav is — the rail has
   to hold its place while a narrow screen scrolls sideways to reach the
   matrix, and a sticky box only travels inside its containing block. It
   starts under the nav, which is the one thing drawn above it. */
:root { --rail-w: 15rem; --rail-clear: 0px; }

/* Measured, never painted — squad.js reads its document position to place
   anything fixed below the strip form of the rail. It must contribute no
   height and no margin of its own, or it moves the thing it measures. */
.squad-rail-anchor { display: block; height: 0; margin: 0; }

.squad-rail { display: none; }
body.squad-split .squad-rail {
  display: flex; flex-direction: column; gap: .35rem;
  position: fixed;
  top: calc(var(--nav-h, 3.25rem) + var(--sticky-top));
  left: 0; bottom: 0; width: var(--rail-w); z-index: 30;
  padding: .9rem .7rem 1.2rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  background: #101740e6;
  border-right: 1px solid var(--panel-border);
  backdrop-filter: blur(12px) saturate(1.15);
}
/* The shell keeps its measure and its centring; it just grows by the rail
   and pays that width back as padding, so the collection stays centred in
   what's left instead of being shoved off-axis. The nav spans the viewport
   and pads its own contents in, so it needs the same push to keep its
   buttons over the content rather than over the rail. */
/* On .ft-shell, NOT on #app. The shell is the element that owns the measure
   and the gutter (#app is its child), so widening #app was capped by the
   parent's max-width and its padding stacked on top of the shell's own —
   leaving the matrix ~30px less room than it looked like it had. */
body.squad-split .ft-shell {
  max-width: calc(var(--container) + var(--rail-w));
  padding-left: calc(var(--rail-w) + var(--shell-pad));
}
body.squad-split .app-nav {
  padding-left: max(
    calc(var(--rail-w) + var(--shell-pad)),
    calc((100% - var(--container) - var(--rail-w)) / 2 + var(--rail-w) + var(--shell-pad))
  );
}

.squad-rail-head {
  display: flex; flex-direction: column; gap: .1rem;
  padding: 0 .35rem .45rem;
}
.squad-rail-title {
  font-family: var(--font-display); font-style: italic; text-transform: uppercase;
  letter-spacing: .04em; font-size: .95rem; color: #9fb4d9;
}
.squad-rail-counts { font-size: .78rem; color: var(--ink-dim); }
/* The caller's slot is a container only so a refresh can refill it without
   touching the rail's structure. `display: contents` keeps its row a direct
   flex child of the rail, so it lays out exactly as it did when it was one —
   including in the narrow row-direction mode. */
.squad-rail-self { display: contents; }
.squad-rail-list { display: flex; flex-direction: column; gap: .2rem; }

/* The whole row is the control. A rail this narrow has no room for a name
   beside a "browse" button, and a row that looks tappable but isn't is
   worse than either. */
.squad-rail-row {
  display: flex; align-items: center; gap: .5rem; width: 100%;
  padding: .45rem .5rem; border-radius: .6rem;
  border: 1px solid transparent; background: none; color: inherit;
  font: inherit; text-align: left; cursor: pointer;
  transition: background .15s, border-color .15s;
}
.squad-rail-row:hover, .squad-rail-row:focus-visible { background: #ffffff12; }
.squad-rail-row.is-active {
  background: #1ab4ff1f; border-color: #1ab4ff59;
}
.squad-rail-row--self { border-color: #ffc31f3d; margin-bottom: .35rem; }
.squad-rail-row--self.is-active { border-color: #ffc31f8a; background: #ffc31f1f; }
.squad-rail-body { display: flex; flex-direction: column; min-width: 0; }
.squad-rail-name {
  font-weight: 600; font-size: .92rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.squad-rail-meta { font-size: .74rem; color: var(--ink-dim); }
/* Presence, in Discord's own colours so the dot means what it means three
   pixels away in the client. `is-unknown` is the default fill and is NOT
   the same as offline: offline is a fact about the person, unknown is the
   absence of one (nobody has reported, or the bot is down and the stored
   status has aged past its TTL). Drawing them alike would turn "we can't
   tell" into a confident claim. */
.squad-rail-dot {
  flex: none; width: .5rem; height: .5rem; border-radius: 50%;
  background: #ffffff1f;
}
.squad-rail-row.is-online  .squad-rail-dot { background: #23a55a; }
.squad-rail-row.is-idle    .squad-rail-dot { background: #f0b232; }
.squad-rail-row.is-dnd     .squad-rail-dot { background: #f23f43; }
.squad-rail-row.is-offline .squad-rail-dot { background: #80848e; }
.squad-rail-row.is-unknown .squad-rail-dot { background: #ffffff1f; }
/* Has the vault open — a ring around whatever the status dot already says,
   because it is an additional fact about them, not a different one. */
.squad-rail-row.has-vault-open .squad-rail-dot { box-shadow: 0 0 0 3px #1ab4ff59; }
/* Someone Discord says is away is still worth listing — they are on the
   roster and their collection is still browsable — but they should not draw
   the eye the way the people who are around do. */
.squad-rail-row.is-offline,
.squad-rail-row.is-unknown { opacity: .62; }
.squad-rail-row.is-offline:hover,
.squad-rail-row.is-unknown:hover,
.squad-rail-row.is-offline.is-active,
.squad-rail-row.is-unknown.is-active { opacity: 1; }
.squad-rail-note { padding: .35rem .35rem 0; font-size: .88rem; }
.squad-rail-empty,
.squad-rail-offroster { font-size: .78rem; margin: .5rem .5rem 0; }
/* Two stacked actions, so the destructive one is never the one nearest the
   list of people. */
.squad-rail-board { width: 100%; justify-content: center; margin-bottom: .4rem; }

/* ---- the server leaderboard, in the main pane ----
   A ranking, unlike the rail beside it: rank, name, counts and the way in,
   all on one line, which is exactly what a 15rem column could not give it. */
.squad-board { display: flex; flex-direction: column; gap: .5rem; }
.squad-board-row {
  display: flex; align-items: center; gap: .85rem; width: 100%;
  padding: .7rem .9rem; text-align: left; cursor: pointer;
  color: inherit; font: inherit;
  transition: border-color .15s, background .15s;
}
.squad-board-row:hover, .squad-board-row:focus-visible { border-color: #78aaff80; }
.squad-board-row--self { border-color: #ffc31f4d; }
.squad-board-rank {
  flex: none; min-width: 2.2rem; text-align: center;
  font-family: var(--font-display); font-style: italic; font-size: 1.05rem;
  color: var(--ink-dim);
}
.squad-board-body { display: flex; flex-direction: column; gap: .1rem; min-width: 0; flex: 1; }
.squad-board-name {
  font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.squad-board-meta { font-size: .82rem; color: var(--ink-dim); }
.squad-board-go { flex: none; font-size: .8rem; }
@media (max-width: 34rem) {
  .squad-board-go { display: none; }
}
.squad-rail-foot { margin-top: auto; padding-top: 1rem; }

/* Opening the vault here is what lists you, so this is not a prompt any
   more — it is the rare state where Discord would not confirm the server
   and there is nothing on screen to press. The `code` block that used to
   carry `/server join` went with the command. */
.squad-join-lead { font-size: 1rem; margin: 0 0 .7rem; }

/* Too narrow for a column beside the grid — which is most phone-sized
   Activities. The rail becomes a horizontally scrolling strip of the same
   rows, parked under the nav: still always on screen, still one tap to
   anyone, without taking a third of the width from the thing it points at. */
@media (max-width: 74rem) {
  body.squad-split .squad-rail {
    position: sticky; top: calc(var(--nav-h, 3.25rem) + var(--sticky-top));
    width: auto; bottom: auto;
    flex-direction: row; align-items: center; gap: .4rem;
    padding: .5rem var(--shell-pad);
    /* Flush against the nav AT REST, not just once pinned.
       .variant-header makes the same point one screen up and for the same
       reason: two pieces of chrome with a band of content peeking between
       them read as a rendering fault.

       The strip is the first in-flow child of #app, so its natural place is
       `.ft-shell`'s 1rem of top padding plus #app's --chrome-gap below the
       nav — the room the shell leaves for CONTENT. Its sticky `top` is the
       nav's bottom edge. Those disagreed by 24px, so the bar sat low until
       the first scroll pulled it up and then never moved again: a jump on
       the first flick, with the pinned position being the correct-looking
       one. Pulling the flow position onto the sticky one settles it there
       from the first paint, and the strip stops moving entirely.

       Paid straight back below, so nothing under the bar shifts: only the
       rail moves. */
    margin-top: calc(var(--sticky-top) - 1rem - var(--chrome-gap));
    margin-bottom: calc(1rem + var(--chrome-gap));
    /* Reach back out over the shell's padding so the strip is edge-to-edge
       like the nav above it, then pay it back inside. */
    margin-inline: calc(var(--shell-pad) * -1);
    overflow-x: auto; overflow-y: hidden;
    border-right: none; border-bottom: 1px solid var(--panel-border);
  }
  body.squad-split .ft-shell { max-width: var(--container); padding-left: var(--shell-pad); }
  body.squad-split .app-nav { padding-left: max(var(--shell-pad), calc((100% - var(--container)) / 2 + var(--shell-pad))); }
  .squad-rail-head { flex: none; padding: 0 .6rem 0 0; border-right: 1px solid var(--panel-border); }
  .squad-rail-title { display: none; }
  .squad-rail-counts { white-space: nowrap; }
  body.squad-split .squad-rail-list { flex-direction: row; }
  .squad-rail-row { width: auto; flex: none; }
  .squad-rail-meta { display: none; }
  .squad-rail-foot { margin: 0 0 0 auto; padding: 0 0 0 .5rem; }
  .squad-rail-note { padding: 0; }

  /* The strip lands exactly where the admin-panel pill was parked, and the
     pill was winning: it is `position: fixed` at a hard 4.4rem from the top,
     the strip starts under the nav, and z-index 39 beats the rail's 30. So
     it drew ON the bar rather than below it.

     Anchored to `--rail-clear` — the strip's bottom edge in document space,
     written by squad.js::publishRailHeight. Not to the pill's own 4.4rem
     plus a height: the strip is sticky, so it sits `--chrome-gap` lower
     before the page scrolls than after, and an offset built from a height
     cleared the pinned bar while still clipping the unpinned one by 6px.
     The variable already carries everything above the strip, so nothing
     from the original offset is re-added here.

     Also dropped BELOW the rail in the stack, which is what matters if the
     number is ever briefly stale — one frame at a breakpoint crossing, say.
     The strip then covers the pill, which is the right way round for a bar
     that is always on screen.

     Only inside this breakpoint: as a left-hand column the rail shares no
     space with a pill on the right, and `--rail-clear` is then the column's
     full height. */
  body.squad-split .cheats-fab {
    top: calc(var(--rail-clear) + .55rem);
    z-index: 29;
  }
}

/* Anton ships no italic face, so `font-style: italic` on it triggers Chrome's
   faux-italic — which slants these display headings the WRONG way (backwards,
   to the left). Force a real forward/right slant with a skew instead. */
.ft-display, .ft-brand, .progress-count, .creature-name, .modal-title {
  font-style: normal;
  display: inline-block;
  transform: skewX(-9deg);
}

/* =========================================================================
   Telegram Mini App overrides.
   The vault's shell was designed for a full desktop viewport; Mini Apps
   run in a phone-sized WebView by default and don't have a header/back
   button (Telegram supplies its own chrome).
   ========================================================================= */

/* Trim the shell padding — Mini Apps get much less horizontal room.
   Top padding also has to clear Telegram's own floating overlay controls
   (Close, chevron, "..." menu), which draw on top of the WebView instead
   of reserving their own header bar. telegram-web-app.js exposes that
   overlay's height as --tg-content-safe-area-inset-top (0 on clients that
   don't overlay anything, e.g. desktop) and the device notch/status bar
   as --tg-safe-area-inset-top; both fall back to 0 outside Telegram. */
.ft-shell {
  --shell-pad: clamp(.75rem, 3vw, 1.5rem);
  /* No safe-area term here any more: the fixed nav starts at the viewport top
     and carries the inset inside its own height, which #app already reserves.
     Adding it here too would push the app down by it twice. */
  padding: 1rem var(--shell-pad) 3rem;
}

/* Boot screen: full-height centred spinner + fallback error UI. */
.boot-screen {
  /* Fills the viewport, and that is a CLS fix rather than a taste call. At
     60vh the boot spinner left the static footer sitting INSIDE the first
     viewport; when #app un-hid and filled with the grid, the footer was
     re-laid-out far below, and layout-shift attribution measured that single
     move as 0.1434 of a 0.1774 mobile CLS — the dominant shift on the page,
     with no image involved anywhere in it. Pushing the boot screen to full
     height starts the footer below the fold, so the move happens off-screen
     and is not counted.
     svh, not vh: on mobile browsers vh means the LARGEST viewport (toolbars
     retracted), which would overshoot by the toolbar height and reintroduce a
     scroll during boot. */
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1rem;
  padding: 2rem 1rem;
}
/* The HTML `hidden` attribute defaults to `display: none`, but that's
   less specific than `.boot-screen { display: flex }` above — so without
   this both loading + error would render at the same time. */
[hidden] { display: none !important; }
.boot-spinner {
  width: 42px; height: 42px;
  border: 3px solid var(--panel-border);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: boot-spin 900ms linear infinite;
}
@keyframes boot-spin { to { transform: rotate(1turn); } }

/* Hero shrinks on narrow viewports so it doesn't dominate the tracker. */
.hero-title { font-size: clamp(2rem, 8vw, 3.75rem); }
.hero { padding-block: 1rem; }

/* ---- standalone web mode ------------------------------------------------
   Sign-in modal, guest catalog view, and the account modal.
   Only reachable outside Telegram's WebView; see js/env.js + main.js. */
.web-landing-notice { color: #ffc31f; max-width: 34rem; font-weight: 600; }
.web-tg-slot { min-height: 44px; display: flex; align-items: center; justify-content: center; }
.web-email-form { display: flex; flex-direction: column; gap: .6rem; width: 100%; }
.web-email-form .ft-input { width: 100%; text-align: center; }
.web-email-form .form-note { font-size: .85rem; color: #9fb4d9; }
.web-email-form .form-error { font-size: .85rem; color: #ff8a8a; }

/* Display-name editor. Same stacked shape as the email form; the actions
   sit on one row because "Save" and "Cancel" are a pair, not a sequence. */
.name-form { display: flex; flex-direction: column; gap: .6rem; width: 100%; margin-top: 1rem; }
.name-form .ft-input { width: 100%; }
.name-form .form-error { font-size: .85rem; color: #ff8a8a; }
.name-form-actions { display: flex; gap: .6rem; flex-wrap: wrap; }

/* The pencil beside the hero subtitle — the way in to that editor, and the
   only one on the embedded surfaces (the account panel is website-only).
   Baseline-aligned rather than inline-flex-centred so it rides the italic
   subtitle's line instead of stretching it, and it inherits the subtitle's
   size so it stays proportional at every clamp() step of the hero. */
.hero-name-edit {
  margin-left: .4rem; padding: 0 .35rem; cursor: pointer;
  font: inherit; font-style: normal; line-height: 1;
  color: #9fc4ff; background: #ffffff0d;
  border: 1px solid #78aaff33; border-radius: .5rem;
  transition: color .1s, background .1s, border-color .1s;
}
.hero-name-edit:hover, .hero-name-edit:focus-visible {
  color: #7fe7ff; background: #ffffff1a; border-color: #78aaff66; outline: none;
}
/* Shown only over the viewer's OWN hero. A shared or teams view rebuilds
   the hero for someone else, and a stray pencil there would read as an
   offer to rename them. */
.shared-view .hero-name-edit, .teams-view .hero-name-edit { display: none; }

/* Signed-out website. The vault is fully usable — grid, progress ring,
   marking sprites — so almost nothing is hidden. Only Share goes: it
   copies a link to an account's collection, and there is no account. */
.local-view #shareBtn { display: none; }

/* Sign-in modal — one stacked list, top to bottom: provider buttons, a
   ruled "or", then the email form. The providers block is a column, so
   every future SSO button is one more full-width row in the same stack
   and nothing about the layout has to change to take it. */
/* Why an account, above the buttons — see js/account-ui.js::openSignInModal.
   The dialog now arrives on its own after ten seconds on a signed-out page,
   so it has to answer "why would I?" before the row of providers rather than
   after it.

   Panel-tinted rather than bordered: a second boxed thing inside the modal
   reads as a warning, and this is an offer. The gold rule down the left is
   the same accent the vault marks its own claims with, and it is what stops
   three lines of body text from reading as fine print. */
.signin-promo {
  margin-top: .9rem;
  padding: .75rem .9rem .8rem;
  border-left: 3px solid #ffc31f;
  border-radius: 0 .5rem .5rem 0;
  background: #ffffff0a;
}
.signin-promo-lead {
  margin: 0 0 .5rem;
  font-weight: 700;
  color: #ffc31f;
  font-size: .95rem;
}
.signin-promo-list {
  margin: 0; padding: 0; list-style: none;
  display: flex; flex-direction: column; gap: .4rem;
  font-size: .9rem; line-height: 1.35; color: #cfe0ff;
  text-align: left;
}
/* Drawn with a marker rather than a list-style, so the hanging indent is a
   grid column and a wrapped second line stays clear of the tick. */
.signin-promo-list li {
  display: grid; grid-template-columns: 1.1em 1fr; gap: .15rem;
  align-items: start;
}
.signin-promo-list li::before {
  content: "✓"; color: #7fe7ff; font-weight: 700; line-height: inherit;
}

.signin-methods { display: flex; flex-direction: column; gap: 1rem; margin-top: 1rem; }
.signin-providers { display: flex; flex-direction: column; gap: .55rem; }
.signin-provider { width: 100%; padding-block: .7rem; }
.signin-divider {
  display: flex; align-items: center; gap: .75rem;
  color: #9fb4d9; font-style: italic; font-weight: 700;
  text-transform: uppercase; letter-spacing: .08em; font-size: .8rem;
}
.signin-divider::before, .signin-divider::after {
  content: ""; flex: 1; height: 1px; background: var(--panel-border, #78aaff33);
}
/* Telegram's own blue and paper plane, cut to the parallelogram every other
   surface here is cut to. The box is skewed and the label skewed back, so the
   slant reads as the shape of the button and not as italic text. Horizontal
   margin buys back the width the skew pushes past a full-bleed row. */
.signin-provider--telegram {
  color: #fff; border-color: transparent; border-radius: .15rem;
  margin-inline: .6rem;
  background: var(--fill-telegram);
  box-shadow: inset 0 1px #ffffff40, 0 2px 0 var(--fill-telegram-edge);
  transform: skew(-11deg);
}
.signin-provider--telegram:hover, .signin-provider--telegram:focus-visible {
  background: var(--fill-telegram-hover);
}
/* Discord's blurple, cut to the same parallelogram. */
.signin-provider--discord {
  color: #fff; border-color: transparent; border-radius: .15rem;
  margin-inline: .6rem;
  background: var(--fill-discord);
  box-shadow: inset 0 1px #ffffff40, 0 2px 0 var(--fill-discord-edge);
  transform: skew(-11deg);
}
.signin-provider--discord:hover, .signin-provider--discord:focus-visible {
  background: var(--fill-discord-hover);
}
/* Epic's black, cut to the same parallelogram. The one provider whose brand
   colour is darker than the panel it sits on, so unlike the two above it
   keeps a visible border — without it the button reads as a hole in the
   dialog rather than as a button. */
.signin-provider--epicgames {
  color: #fff; border-color: #ffffff3d; border-radius: .15rem;
  margin-inline: .6rem;
  background: linear-gradient(#3d3d3d, #232323 52%, #121212);
  box-shadow: inset 0 1px #ffffff26, 0 2px 0 #000000b3;
  transform: skew(-11deg);
}
.signin-provider--epicgames:hover, .signin-provider--epicgames:focus-visible {
  background: linear-gradient(#535353, #363636 52%, #242424);
}
/* Google's white button, cut to the same parallelogram, and the only one here
   whose label is dark. That follows from the mark rather than from taste: the
   G keeps its four brand colours (Google's guidance is that it is not
   recoloured), and four colours want a light ground to sit on — which is also
   the button Google's own sign-in spec draws. The dark label then follows the
   ground. Its border does the work Epic's does in reverse: the fill is
   lighter than the panel rather than darker, and without an edge the shape
   bleeds into the glow behind it. */
.signin-provider--google {
  color: #1f1f1f; border-color: #dadce0; border-radius: .15rem;
  margin-inline: .6rem;
  background: linear-gradient(#fff, #fff 52%, #f1f3f4);
  box-shadow: inset 0 1px #fff, 0 2px 0 #00000059;
  transform: skew(-11deg);
}
.signin-provider--google:hover, .signin-provider--google:focus-visible {
  background: linear-gradient(#fff, #f8f9fa 52%, #e8eaed);
}
.signin-provider-label {
  display: inline-flex; align-items: center; gap: .5rem; transform: skew(11deg);
}
/* `fill: currentColor` is the silhouette marks' colour; Google's G states a
   fill per path and keeps its own. */
.signin-provider-icon { display: block; width: 1.15em; height: 1.15em; fill: currentColor; }

.signin-email { display: flex; flex-direction: column; gap: .35rem; }
.signin-email .ft-input { text-align: left; }

/* "How do I save my collection?" (signed-out web only). Anchored under the
   hero rather than floating over the grid — it's an offer, not an
   interruption, and the grid is what the visitor came for.

   Dashed border, not the solid .ft-panel: this is the one card on the page
   that isn't part of the vault and that goes away for good once answered. */
.save-hint {
  margin: 0 0 1.5rem;
  border: 1px dashed rgba(159, 196, 255, .3);
  border-radius: .6rem;
  background: rgba(159, 196, 255, .05);
}
.save-hint-inner { padding: 1rem 1.15rem 1.15rem; }
.save-hint-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.save-hint-title {
  font-family: var(--font-display); font-style: italic; text-transform: uppercase;
  letter-spacing: .02em; font-size: 1.15rem; margin: 0; color: #eaf3ff;
}
.save-hint-lead { margin-top: .3rem; font-size: .9rem; line-height: 1.45; color: #cfe0ff; }
.save-hint-dismiss {
  flex: none; background: none; border: none; cursor: pointer; line-height: 1;
  font-size: 1.4rem; color: #9fb4d9; padding: 0 .1rem; margin: -.15rem -.25rem 0 0;
}
.save-hint-dismiss:hover, .save-hint-dismiss:focus-visible { color: #eaf3ff; outline: none; }

/* Two equal columns — neither route is the recommended one. Someone who
   just wants a checklist on their phone is well served by the home screen,
   and styling the account as the default would be a nudge the tracker
   doesn't need. */
.save-hint-options {
  display: flex; flex-wrap: wrap; gap: .75rem; margin-top: .9rem;
}
.save-hint-option {
  flex: 1 1 15rem;
  display: flex; flex-direction: column; align-items: flex-start; gap: .3rem;
  padding: .85rem .95rem;
  border: 1px solid rgba(159, 196, 255, .18); border-radius: .5rem;
  background: rgba(12, 18, 48, .55);
}
.save-hint-option-title {
  font-style: italic; font-weight: 800; text-transform: uppercase;
  letter-spacing: .05em; font-size: .82rem; color: #9fc4ff;
}
.save-hint-option-body { font-size: .87rem; line-height: 1.45; color: #cfe0ff; }
/* margin-top:auto keeps the two buttons on one line when the bodies differ
   in length; align-self makes the iOS option (no button) still look
   deliberate rather than short. */
.save-hint-option .ft-pillbtn { margin-top: auto; padding-top: .55rem; }
.save-hint-later {
  display: inline-block; margin-top: .85rem;
  font-size: .85rem; color: #9fb4d9;
}

/* First-sign-in chooser: this device's collection vs the account's. Two
   cards of equal weight — the app genuinely has no opinion about which is
   right, and styling one as the default would be an opinion. */
.merge-options {
  display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 1rem;
}
.merge-option {
  flex: 1 1 13rem; padding: 1rem;
  display: flex; flex-direction: column; align-items: flex-start; gap: .3rem;
}
.merge-option-label {
  font-style: italic; font-weight: 800; text-transform: uppercase;
  letter-spacing: .05em; font-size: .85rem; color: #9fb4d9;
}
.merge-option-count {
  font-family: var(--font-display); font-style: italic; font-size: 1.6rem; color: #9fdcff;
}
.merge-option .account-hint { margin-bottom: .5rem; }
.merge-option .ft-pillbtn { margin-top: auto; align-self: stretch; }

/* Account modal rows (web only). */
/* ---- the tabbed shell (account-ui.js::openAccountModal) ----

   This replaces the `.account-section` headings, which were the previous
   answer to the same problem and had stopped being enough: a heading tells
   you what a group IS, it does not save you from scrolling through the other
   three to reach it.

   The dialog is a FIXED FRAME with one scrolling part: header and tab rail at
   the top, sign out ruled off at the bottom, and only .account-body travels.
   `.modal`'s own `overflow-y: auto` is turned off here for that — with the
   whole dialog scrolling, the tabs you navigate with would scroll away from
   you, which is the one thing a tab bar must not do.

   Two arrangements, one markup: wide, a vertical rail beside the panel;
   narrow, the rail lies down into a strip above it. What does NOT change with
   the breakpoint is the HEIGHT, which is fixed at both — switching tabs must
   never resize the dialog. A `max-height` on a phone was tried first, on the
   theory that a phone-width dialog holding itself open for a two-row tab is a
   lot of empty box; using it settles it the other way, because there the
   dialog is nearly the whole screen, so it grows and shrinks under the thumb
   still resting on the tab strip and the strip itself walks up and down
   between taps — the control moves because of what you used it for.

   `dvh` before `vh`, and the fallback is the point of writing it twice: on
   mobile Safari `vh` is the LARGE viewport, the one measured with the URL bar
   collapsed, so 90vh of it is taller than what you can actually see and the
   footer — Close — sits below the fold of a dialog that deliberately does
   not scroll. `dvh` is the viewport as it stands. A browser too old to know
   it drops the second declaration and gets the old behaviour.

   `min-width: 0` on all four boxes below is not belt-and-braces: a flex item
   defaults to `min-width: auto`, so the rail refuses to be narrower than its
   widest label and pushes that width back up through the tabbed box and the
   dialog. The strip then never scrolls — instead the whole dialog overflows
   sideways, which at 320px put a third of the panel off the screen with no
   way to reach it. Measured at 288px of dialog: 165px of overflow. */
.account-modal { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
.modal--account {
  display: flex;
  overflow: hidden;
  height: min(90vh, 42rem);
  height: min(90dvh, 42rem);
  padding: 1.25rem 1.25rem 1rem;
}
.modal--account > .account-modal { flex: 1; min-height: 0; }
.account-tabbed { display: flex; flex-direction: column; gap: .85rem; flex: 1; min-height: 0; min-width: 0; }
.account-body {
  flex: 1; min-height: 0; min-width: 0; overflow-y: auto; overscroll-behavior: contain;
  /* Room for the skewed slab and the focus ring to breathe, and for a
     scrollbar not to sit on top of a Disconnect link.

     **BOTH sides, and the left one is not symmetry for its own sake.**
     `overflow-y: auto` makes this a scroll container, and a scroll container
     clips the OTHER axis too: `overflow-x` computes to `auto` rather than
     staying `visible`. A box cut to the app's -11deg hangs about 3px past its
     own edges — half its height times tan(11deg) — and the Linked accounts
     method chips are the first thing in here to sit flush against this edge.
     So their bottom-left tips came off, sliced flat, and a parallelogram read
     as a rectangle with one slanted side. Nothing else in this panel was
     affected (the pill buttons are rounded rectangles, not parallelograms),
     but anything skewed that lands here later has the same problem, and this
     is the fix for all of them. */
  padding-inline: .35rem;
}
.account-body:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: .3rem; }

/* The strip bleeds to the dialog's own padding so its rule reads as a divider
   across the whole dialog rather than a line floating in the middle of it,
   then pads back in so the first tab lines up with the rows below. It scrolls
   horizontally rather than wrapping or shrinking: three uppercase labels in
   Russian do not fit across a 320px phone, and a wrapped tab bar changes the
   dialog's height as you move through it. */
.account-rail {
  display: flex; gap: .2rem; isolation: isolate; min-width: 0;
  margin-inline: -1.25rem; padding: 0 1.25rem .5rem;
  border-bottom: 1px solid var(--panel-border);
  overflow-x: auto; scrollbar-width: none;
}
.account-rail::-webkit-scrollbar { display: none; }
.account-tab {
  position: relative; isolation: isolate;
  display: inline-flex; align-items: center; gap: .5rem;
  flex: 0 0 auto; white-space: nowrap; cursor: pointer;
  background: none; border: 0; border-radius: .35rem;
  padding: .55rem .8rem; min-height: 2.5rem;
  color: #9fb4d9; font: inherit;
  font-style: italic; font-weight: 800;
  text-transform: uppercase; letter-spacing: .05em; font-size: .84rem;
  transition: color .12s;
}
/* THE SLAB. Every surface in this app that means "this one" — a sprite tile,
   a rarity chip, the nav's own buttons — is a parallelogram leaning -11deg,
   and the tab that is open should be the same object. Drawn as a layer behind
   the label so only the slab skews and the text stays upright, exactly the way
   .ft-cell and its counter-skewed caption work; `isolation` keeps the negative
   z-index inside the button instead of dropping it behind the dialog. The
   skew puts ~3px of slab either side of the box, which the rail's padding
   already allows for. */
.account-tab::after {
  content: ""; position: absolute; inset: 0; z-index: -1;
  border: 1px solid var(--panel-border); border-radius: .3rem;
  background: #ffffff12;
  transform: skewX(-11deg);
  opacity: 0; transition: opacity .12s;
}
.account-tab:hover { color: #dbeaff; }
.account-tab:hover::after { opacity: .55; }
.account-tab.is-active { color: #eaf4ff; }
.account-tab.is-active::after {
  opacity: 1;
  border-color: #1ab4ff4d;
  border-left: 3px solid var(--accent);
  background: linear-gradient(90deg, #1ab4ff2e, #1ab4ff0f 55%, #1ab4ff05);
}
.account-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* How many of the four ways in are attached. It is the answer to the question
   the tab asks, so it reads as a value on a label rather than an alert: no
   red, no dot, and it is upright where the label is italic because it is a
   number. */
.account-tab-meta {
  font-style: normal; font-weight: 700; font-size: .74rem; letter-spacing: .02em;
  color: #7fe7ff; background: #1ab4ff1a; border: 1px solid #1ab4ff3d;
  border-radius: .25rem; padding: .05rem .32rem;
}

/* Wide: the strip stands up. */
@media (min-width: 42rem) {
  /* A touch shorter than the phone's, and capped lower: the rail takes the
     tab labels out of the panel's own height here, so the tallest tab needs
     less, and a dialog taller than this on a big monitor stops reading as a
     dialog. */
  .modal--account { height: min(88vh, 40rem); height: min(88dvh, 40rem); }
  .account-tabbed { flex-direction: row; gap: 1.4rem; }
  .account-rail {
    flex-direction: column; align-items: stretch; gap: .25rem;
    flex: 0 0 12rem;
    margin: 0; padding: 0 .25rem;
    border-bottom: 0; border-right: 1px solid var(--panel-border);
    overflow-x: visible; overflow-y: auto;
  }
  .account-rail .account-tab { justify-content: space-between; }
}

/* The lead line under a tab's title — what this drawer is for, in one
   sentence. The tab label is the heading, so nothing repeats it. */
.account-lead { font-size: .9rem; color: #9fb4d9; margin: 0 0 .2rem; line-height: 1.45; }

/* ---- a row inside the panel ----
   Label over value on the left, the action on the right, both on the row's
   first line. The rows used to be label-and-value on one line pushed apart by
   `space-between` with the button on a second line under them — which was the
   right shape in a 30rem dialog and became two problems in a 46rem one: a
   forearm of empty space between "Your display name" and the name, and every
   setting costing two lines. Reading order is now the same as the sentence:
   what this is, what it says, what you can do about it.

   Scoped to .account-body rather than changed on .account-row itself, because
   the same class dresses rows in dialogs that are still one narrow column. */
.account-body .account-row {
  display: grid; grid-template-columns: minmax(0, 1fr) auto;
  align-items: center; column-gap: 1rem;
}
.account-body .account-row-head { flex-direction: column; align-items: flex-start; gap: .1rem; }
.account-body .account-row-action { grid-column: 2; grid-row: 1; margin-top: 0; }
/* Everything a row can carry below its first line — a hint, a Connect button,
   an email form — spans both tracks and starts under the label. */
.account-body .account-hint,
.account-body .web-email-form,
.account-body .web-tg-slot { grid-column: 1 / -1; }
.account-body .web-tg-slot { justify-content: flex-start; }
.account-row { padding: .85rem 0; border-top: 1px solid var(--panel-border, #78aaff33); }
.account-row:first-of-type { border-top: none; }
.account-row-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
.account-row-label {
  font-style: italic; font-weight: 800; text-transform: uppercase;
  letter-spacing: .05em; font-size: .9rem; color: #9fb4d9;
}
.account-row-value { font-weight: 700; overflow-wrap: anywhere; }
.account-row-value.muted { font-weight: 400; }
.account-row .web-email-form { margin-top: .6rem; }
.account-row .web-tg-slot { margin-top: .6rem; }
.account-hint { font-size: .85rem; color: #9fb4d9; margin-top: .35rem; line-height: 1.4; }
.account-actions { display: flex; justify-content: flex-end; margin-top: 1rem; }

/* Disconnect: deliberately NOT a button. It's a rarely-wanted action sitting
   next to the method it undoes, and a pill there competes with Connect —
   the one thing on this row someone usually came for. Plain grey text. */
.account-textbtn {
  background: none; border: 0; padding: 0; cursor: pointer;
  font: inherit; font-size: .85rem; color: #9fb4d9;
  text-decoration: underline; text-underline-offset: .2em;
  text-decoration-color: #9fb4d966;
}
.account-textbtn:hover { color: #dbeaff; text-decoration-color: currentColor; }
.account-textbtn:disabled { opacity: .5; cursor: default; }
.account-row-action { display: flex; justify-content: flex-end; margin-top: .45rem; }

/* --- Linked accounts: the method chip ---------------------------------
   The tab is a count (its label carries `3/5`), so the chip's COLOUR is the
   row's answer: a connected method wears the brand it goes to, in the fill
   its button wears in the sign-in dialog, and an unconnected one keeps the
   shape and loses the colour. The column reads as a count down its left edge
   without being read as words. Never the only carrier of that state — the
   value still says "Not connected" and the action still says Connect.

   The head becomes a grid ONLY on these rows. `.account-row-head` is shared
   with five other rows across the three tabs that have no chip and expect
   the flex it has always been, so the override is scoped to the modifier
   rather than paid for by all of them. */
.account-row--method .account-row-head {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: .15rem .75rem;
}
/* The buttons' pressed lip (`0 2px 0 <edge>`) is deliberately NOT here. At
   this size, skewed, it reads as a soft double edge down the bottom-left
   rather than as depth — and a chip is not a control. The inset top-light
   stays: that is the material these objects are made of, not the affordance
   of being pressable. Measured by rendering, not by reading. */
.account-method {
  width: 1.9rem; height: 1.9rem;
  display: inline-grid; place-items: center;
  border: 1px solid transparent; border-radius: .15rem;
  /* The same cut as every button here, and the mark skewed back upright
     inside it — `.signin-provider-label` plays exactly this trick. */
  transform: skew(-11deg);
  color: #fff;
}
.account-method-mark {
  display: block; width: 1.05rem; height: 1.05rem;
  fill: currentColor; transform: skew(11deg);
}
.account-method--telegram {
  background: var(--fill-telegram);
  box-shadow: inset 0 1px #ffffff40;
}
.account-method--discord {
  background: var(--fill-discord);
  box-shadow: inset 0 1px #ffffff40;
}
/* Epic and Google keep the borders their sign-in buttons keep, and for the
   same two reasons: Epic's fill is darker than the panel, Google's is
   lighter, and neither edge is optional if the chip is to read as an object
   rather than as a hole or a glare. */
.account-method--epicgames {
  background: linear-gradient(#3d3d3d, #232323 52%, #121212);
  border-color: #ffffff3d;
  box-shadow: inset 0 1px #ffffff26;
}
.account-method--google {
  background: linear-gradient(#fff, #fff 52%, #f1f3f4);
  border-color: #dadce0;
  box-shadow: inset 0 1px #fff;
}
/* Ours, so the vault's own gold rather than a brand's blue. Grey — the
   colour `--fill-grey` uses to say "this one goes to us" on the squad-up
   strip — is the one thing this could not borrow: on a row whose whole job
   is to look connected, grey reads disabled. */
.account-method--email {
  background: linear-gradient(#ffd257, var(--gold) 52%, #e0a400);
  color: #2a1d00;
  box-shadow: inset 0 1px #ffffff66;
}
/* Not connected: the shape stays, the brand goes. */
.account-method.is-off {
  background: #ffffff08;
  border-color: var(--panel-border);
  box-shadow: none;
  color: #9fb4d9;
}
.account-method.is-off .account-method-mark { opacity: .55; }
/* Google's G states a fill per path, which is right on its own white chip
   and wrong on the drained one: there it would be the only colour left in
   the column that does not mean "connected". */
.account-method.is-off .account-method-mark path { fill: currentColor; }

/* On a phone the label and the value stop competing for one line — an email
   address is long and the dialog is narrow. The chip spans both rows so the
   pair still hangs off one mark. */
@media (max-width: 30rem) {
  .account-row--method .account-row-head { grid-template-columns: auto 1fr; }
  .account-row--method .account-method { grid-row: span 2; }
}
/* A subscription row is a <label>, so the whole row is the hit target rather
   than the 16px box at the end of it — the same reason the sprite cells are
   buttons and not icons. `cursor: pointer` says so before it is tried. */
.account-row--check { cursor: pointer; }
.ft-check {
  appearance: none; -webkit-appearance: none;
  width: 1.15rem; height: 1.15rem; margin: 0; flex: 0 0 auto; cursor: pointer;
  background: #ffffff0d; border: 1px solid #78aaff59; border-radius: .3rem;
  display: inline-grid; place-content: center;
  transition: background .15s ease, border-color .15s ease;
}
/* The tick is drawn rather than typed: a ✓ glyph is a different shape in every
   font the three surfaces fall back to, and this one is the same everywhere. */
.ft-check::before {
  content: ""; width: .55rem; height: .3rem;
  border-left: 2px solid #05122b; border-bottom: 2px solid #05122b;
  transform: rotate(-45deg) translate(.03rem, -.06rem) scale(0);
  transition: transform .12s ease;
}
.ft-check:checked { background: #78aaff; border-color: #78aaff; }
.ft-check:checked::before { transform: rotate(-45deg) translate(.03rem, -.06rem) scale(1); }
.ft-check:hover:not(:disabled) { border-color: #78aaffa6; }
.ft-check:disabled { opacity: .55; cursor: default; }
.account-row--check:has(.ft-check:disabled) { cursor: default; }

.ft-select {
  appearance: none; -webkit-appearance: none;
  color: #dbeaff; cursor: pointer; font: inherit; font-size: .82rem;
  font-style: italic; font-weight: 800; letter-spacing: .03em;
  text-transform: uppercase;
  background: #ffffff0d; border: 1px solid #78aaff59; border-radius: .4rem;
  padding: .3rem 1.75rem .3rem .7rem;
  /* The chevron. A background image rather than a pseudo-element because a
     <select> cannot carry one, and rather than a wrapper div because the row
     already has the layout this needs. */
  background-image: linear-gradient(45deg, transparent 50%, #dbeaff 50%),
                    linear-gradient(135deg, #dbeaff 50%, transparent 50%);
  background-position: calc(100% - 1.05rem) 55%, calc(100% - .75rem) 55%;
  background-size: .3rem .3rem, .3rem .3rem;
  background-repeat: no-repeat;
  transition: border-color .15s ease, background-color .15s ease;
}
.ft-select:hover:not(:disabled) { background-color: #ffffff1a; border-color: #78aaffa6; }
.ft-select:disabled { opacity: .55; cursor: default; }
/* The options themselves are drawn by the OS, which ignores almost everything
   above — these two are the pair every platform honours, and without them a
   dark page opens a white menu. This is the FALLBACK now; see below. */
.ft-select option { background: #101827; color: #dbeaff; }

/* ---- the open menu ----
   A native picker is drawn by the platform, so a dark app opening a light
   system menu with a blue OS highlight was the one place the design stopped
   at the edge of the control. `appearance: base-select` hands the popup back
   to CSS — `::picker(select)` is the panel, and the options inside it are
   ordinary boxes we can style.

   FENCED BEHIND @supports, AND THE FALLBACK IS THE POINT. Only Chromium
   implements this today, and the two options-colour declarations above are
   what everything else keeps — so Safari, Firefox and any older WebView get
   the native menu they always got rather than an unstyled one. Nothing here
   changes behaviour: keyboard, type-ahead and the mobile sheet are still the
   browser's. */
@supports (appearance: base-select) {
  .ft-select, .ft-select::picker(select) { appearance: base-select; }

  /* The closed control keeps its own chevron drawing; base-select supplies a
     real ::picker-icon, so the background-image pair would draw a second one. */
  .ft-select { background-image: none; padding-right: .7rem; display: inline-flex; align-items: center; gap: .5rem; }
  .ft-select::picker-icon {
    color: #9fc4ff; transition: transform .15s ease, color .15s ease;
  }
  .ft-select:hover::picker-icon { color: #dbeaff; }
  .ft-select:open::picker-icon { transform: rotate(180deg); }

  /* The panel. Opaque rather than the app's translucent --panel: it floats in
     the top layer over the grid, and a sprite showing through a menu is the
     one thing a menu must not do. */
  .ft-select::picker(select) {
    background: #0e1533;
    border: 1px solid #78aaff59;
    border-radius: .5rem;
    padding: .25rem;
    margin-top: .3rem;
    box-shadow: 0 18px 40px #00000099, inset 0 1px #ffffff0d;
    /* The browser's own open/close animation, kept but shortened to the app's
       own 0.12s register. */
    transition: opacity .12s ease, overlay .12s allow-discrete, display .12s allow-discrete;
    opacity: 1;
  }
  @starting-style { .ft-select::picker(select):popover-open { opacity: 0; } }
  .ft-select::picker(select):not(:popover-open) { opacity: 0; }

  .ft-select option {
    display: flex; align-items: center; gap: .5rem;
    padding: .4rem .6rem; border-radius: .3rem;
    background: none; color: #cfe0ff;
    font-size: .82rem; font-style: italic; font-weight: 800;
    text-transform: uppercase; letter-spacing: .03em;
    transition: background .1s, color .1s;
  }
  /* The tick is replaced by the app's own way of saying "this one" — the
     -11deg accent edge the active account tab wears — because a checkmark
     glyph is the one shape here that would have come from somewhere else. */
  .ft-select option::checkmark { display: none; }
  .ft-select option:hover,
  .ft-select option:focus { background: #ffffff12; color: #eaf4ff; outline: none; }
  .ft-select option:checked {
    color: #eaf4ff;
    background: linear-gradient(90deg, #1ab4ff2e, #1ab4ff0f 55%, #1ab4ff05);
    box-shadow: inset 3px 0 0 var(--rarity, var(--accent));
  }

  /* Each rarity carries its own colour into the menu, from the same DB value
     the cells and the masthead rails use (main.js::renderFilters). The mark is
     an empty span, so the legacy path — where an <option>'s children collapse
     to their text — shows exactly the word and nothing else. */
  .opt-mark {
    flex: none; width: .3rem; height: .62rem;
    background: var(--rarity, #9fb4d9);
    transform: skewX(-11deg);
    box-shadow: 0 0 8px -1px var(--rarity, #9fb4d9);
  }
  /* The closed control shows the chosen option's own contents, mark included. */
  .ft-select selectedcontent { display: inline-flex; align-items: center; gap: .5rem; }
}

@media (prefers-reduced-motion: reduce) {
  .ft-select::picker-icon, .ft-select::picker(select) { transition: none; }
}

/* The footer holds Close and nothing else — the one control that changes
   nothing about the account, kept out of the scrolling panel where every
   other control changes something. Sign out was here for one iteration and
   moved into General: it ends the session, which is about the person rather
   than about a channel, and a permanent button doing that a thumb's width
   from the way out of the dialog is a mis-tap waiting to happen. */
.account-foot {
  display: flex; justify-content: flex-end;
  margin-top: .9rem; padding-top: .85rem;
  border-top: 1px solid var(--panel-border, #78aaff33);
}

/* Danger zone — Reset collection and Remove account.
   Fenced off rather than sat next to Sign out, because the two look alike
   and one of them is unrecoverable. The dashed border reads as "this is
   not part of the list above"; the tint and the red button carry the
   warning without a modal doing all the work. */
.account-danger {
  margin-top: 1.25rem; padding: .9rem 1rem 1rem;
  border: 1px dashed #ff5a5a80; border-radius: .6rem;
  background: #ff5a5a0f;
}
.account-danger-title {
  display: block; text-transform: uppercase; letter-spacing: .06em;
  font-size: .78rem; font-weight: 800; color: #ff9b9b;
}
.account-danger-hint {
  margin: .35rem 0 .85rem; color: var(--ink-dim); font-size: .92rem;
}
/* Two actions share the zone, so each needs its own hint and a rule
   between them — otherwise the second hint reads as more prose about the
   first button, and the two are a tap apart. */
.account-danger-item + .account-danger-item {
  margin-top: .9rem; padding-top: .9rem; border-top: 1px dashed #ff5a5a4d;
}
.ft-pillbtn--danger {
  color: #fff; border-color: transparent;
  background: linear-gradient(#ff7b7b, #ee4550 48%, #cf2534);
}

/* =========================================================================
   BLOCKING MODAL (js/blocking-modal.js)

   A dialog that has to be answered — and, being the only one, the only place
   in the app that gets its own register. It is drawn as an INSTRUMENT READING
   rather than a message box: a band naming the channel and its condition, then
   the consequence, then what it costs, then the ways out.

   That is not a costume. The rest of this product is already a game HUD — the
   tiles are skewed -11deg, the display face is skewed uppercase Anton, the
   palette is a navy console — and the question somebody arrives here with is
   literally "which of my channels is dead". Answering it in the vernacular the
   surrounding product already speaks is what stops this reading as a stock
   dialog bolted onto a themed app.

   AMBER, NEVER RED, and the distinction is the argument. Red in this app means
   DESTROYED — Remove account, Reset collection, the mastery sheet's Remove all
   wear #ff5a5a and its neighbours. Nothing here is destroyed: a channel is
   silent, which is the state amber has named on every instrument panel there
   has ever been. Cyan (--accent) is deliberately absent too; it is this app's
   "live and interactive" colour and it would be a lie on a dead channel.

   Three structural devices, each carrying information rather than decorating:

   * the READOUT band. Instrument on the left, condition on the right, a hard
     skewed blip in front. It states the measurement before any prose does.
   * a LABEL over the list. The list is a set of features, and a feature list
     inside a fault report is a non-sequitur without a line saying what the
     features have to do with the fault.
   * the NOTE, fenced off below it. Three offers and one limit are different
     kinds of statement, and the limit is what makes the offers believable.

   The actions STACK rather than sitting in a right-aligned row. There are
   three or four of them, of genuinely different weights (fix it / check again
   / turn it off / sign out), and a row that wraps puts them in an arbitrary
   order on every screen width. Stacked, the order is the order, everywhere.

   Nothing here hides or overrides the backdrop: the vault stays visible
   behind it on purpose, because the collection is why they will answer
   instead of leaving.
   ========================================================================= */

.modal--blocking {
  /* The one alert hue, named once. Not in :root — nothing outside this
     dialog is entitled to it, and a token that exists everywhere gets used
     everywhere. */
  --blocking-alert: #ff9c4a;
  overflow: hidden; display: flex;
}
.blocking-modal {
  display: flex; flex-direction: column;
  flex: 1; min-height: 0;
}

/* A FIXED FRAME WITH ONE SCROLLING PART, which for this dialog is a safety
   property rather than a layout preference: it cannot be dismissed, so its
   exits ARE the design, and the guaranteed one must never be off screen.

   Measured before it was written — at 380x640 the actions ran 34px past the
   fold in English and 75px in Russian, putting Sign out out of sight in five
   of the eight states. The box scrolled, so nothing was trapped; but somebody
   hunting for the way out of a dialog that refuses Escape AND a backdrop
   click should not also have to discover that it scrolls.

   BOTH ENDS ARE PINNED and only the middle travels. The header used to scroll
   with the prose, which cost the dialog its subject: scroll to the last reason
   and the statement of what is wrong leaves the screen, in a dialog whose
   whole job is to state it. Same arrangement as the account panel — the parts
   you navigate by never scroll away.

   `min-height: 0` on both boxes is what actually makes it work: a flex item
   defaults to `min-height: auto`, which refuses to shrink below its content,
   so without it the body simply pushes the actions out of the frame again and
   the overflow never engages. Same trick, same reason, as the account panel's
   rail. */
.blocking-head { flex: none; }

/* HORIZONTAL PADDING ON A SCROLL CONTAINER, PAID BACK AS NEGATIVE MARGIN.
   `overflow-y: auto` forces `overflow-x` to compute to `auto` as well — the
   two axes cannot disagree about whether a box is a scroll container — so
   this box CLIPS at its padding edge, where `.modal`'s own 1.5rem sits
   outside and protects nothing.

   That is not hypothetical: the title used to live in here, and `.modal-title`
   is skewed -9deg (Anton ships no italic, so the slant is a transform rather
   than a face — see .ft-display). A transform does not affect layout, so a
   two-line heading's lower line overhangs its own box to the LEFT by
   tan(9deg) x half its height — 5.3px measured at a 380px viewport — and the
   first letter of the second line was sliced off at x=0. Pinning the header
   moved the title out of this box, which fixes that instance; the padding is
   here so the NEXT thing with a transform, a focus ring or a shadow does not
   rediscover it. The negative margin gives the measure back, so the prose
   still lines up with the header and the buttons. */
.blocking-body {
  overflow-y: auto; min-height: 0;
  padding: .9rem .8rem .15rem;
  margin: 0 -.8rem;
}
/* Set by blocking-modal.js only while there IS more below. The footer's rule
   sits directly under this box, so prose that simply stops against it reads
   as CUT rather than continued — and in Russian what is cut off is the
   restraint promise, the one line that makes the three offers believable.
   A permanent fade would dim the last real line in the states that fit, which
   is why this is toggled rather than always drawn. */
.blocking-body--more {
  mask-image: linear-gradient(to bottom, #000 calc(100% - 1.6rem), transparent);
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1.6rem), transparent);
}
.blocking-modal .account-actions--stacked {
  flex: none;
  margin-top: 0; padding-top: .8rem;
  /* Ruled off, so the pinned group reads as a footer rather than as content
     that happens to be last — and so a partly-scrolled list is visibly cut by
     something rather than just ending. */
  border-top: 1px solid #ffffff1f;
}

/* ---- the readout band ----
   Instrument, then condition, with the two pushed to opposite ends so the eye
   reads it as a gauge rather than a sentence. It wraps rather than
   overflowing: Russian's "НЕ ДОСТАВЛЯЕТСЯ" is half again the width of "NOT
   DELIVERING", and a state word cut off by the modal's edge would be the one
   piece of information this band exists to carry. */
.blocking-readout {
  display: flex; align-items: center; flex-wrap: wrap;
  gap: .2rem .55rem;
  margin-bottom: .7rem; padding-bottom: .6rem;
  border-bottom: 1px solid #ff9c4a30;
}
/* The blip. A hard skewed bar, not a round dot: every other affordance in
   this app is skewed -11deg (.ft-cell, .connect-link, the trade rails), and a
   circle here would be the one shape in the product that came from somewhere
   else. Decorative — the two words beside it say the same thing in words. */
.blocking-blip {
  flex: none; width: .3rem; height: .95rem;
  transform: skewX(-11deg);
  background: linear-gradient(var(--blocking-alert), #ef7526);
  box-shadow: 0 0 9px #ff9c4a66;
}
.blocking-readout-subject,
.blocking-readout-state {
  font-weight: 700; font-size: .7rem; line-height: 1.2;
  text-transform: uppercase; letter-spacing: .14em;
}
.blocking-readout-subject { color: #f3d8bd; }
/* Pushed to the far end: the gap between the thing and its condition is what
   makes this read as an instrument rather than as a caption. */
.blocking-readout-state { margin-left: auto; color: var(--blocking-alert); }

/* ---- the heading ----
   Tighter than the app's default 1.5, because at this size a wrapped display
   line set on body leading reads as two headings rather than one. */
.modal--blocking .modal-head { margin-bottom: 0; }
.modal--blocking .modal-title { font-size: 1.3rem; line-height: 1.12; }

/* ---- the prose ----
   The lead is BRIGHTER and larger than .account-hint, which is what it used
   to wear. It is the diagnosis — the one sentence that says what happened and
   what to do about it — and it was set in the same dim grey as a form's
   footnote. */
/* Justified, both here and on the note below — the two paragraphs of prose
   in this dialog. The box is a fixed-width column with a ruled footer under
   it, and a ragged right edge inside a frame that square reads as unfinished
   rather than as relaxed.

   The POINTS list is deliberately left ragged: each item is a short phrase
   that wraps to two lines at most, and justifying those stretches the first
   line to the full measure to carry three or four words.

   `hyphens: auto` was tried here and REMOVED, which is worth recording so it
   is not tried again. The reasoning was sound — Russian words are long, the
   column is ~300px, and justifying with nothing to break on pays for the
   flush edge in wide word-gaps — and `setLang` does write the language onto
   `<html>`, so the browser had a dictionary to work from. It simply did
   nothing: `hyphens` computed to `auto`, `lang` to `ru`, and the paragraph
   measured the same height to the pixel with it and without, in both
   locales. Dead CSS carrying a confident explanation is worse than no CSS. */
.blocking-lead {
  margin: 0;
  font-size: .95rem; line-height: 1.5;
  color: #d8e5fa;
  text-align: justify;
}

.blocking-offers { margin-top: 1rem; }
/* Small caps and nothing else. It had a hairline running out of it to the
   right for one pass, which looked deliberate and was not: this dialog is
   small and already carries three rules that each MEAN something different —
   the readout's amber one closes the instrument panel, the note's dashed one
   is the app's existing "a different kind of thing follows" fence, the
   footer's solid one pins the exits. A fourth, decorative, made those three
   read as a texture instead of as three separate statements. */
.blocking-offers-label {
  margin: 0 0 .55rem;
  font-weight: 700; font-size: .66rem; line-height: 1;
  text-transform: uppercase; letter-spacing: .15em;
  color: #8aa4cd;
}

.blocking-points {
  margin: 0; padding: 0; list-style: none;
  font-size: .88rem; line-height: 1.45;
  color: #c9d9f2;
}
.blocking-points li { position: relative; padding-left: 1rem; }
.blocking-points li + li { margin-top: .45rem; }
/* Gold, and the same -11deg as everything else pressable in this app: each of
   these is a thing the vault would hand you, so the marker is the vault's own
   colour rather than the fault's amber. */
.blocking-points li::before {
  content: "";
  position: absolute; left: 0; top: .4em;
  width: .19rem; height: .6rem;
  transform: skewX(-11deg);
  background: linear-gradient(var(--gold), #f5a300);
}

/* The restraint promise ("we don't send anything else"), which is a different
   KIND of statement from the three above it — those are offers, this one is a
   limit. Fenced off with the same dashed rule the account panel's danger zone
   uses, in the neutral ink, because a dash is how this app already says "what
   follows is not more of the same". */
/* The limit reads as ORDINARY PROSE, not as a footnote. It used to be
   italic, dimmer and fenced off behind a dashed rule — three separate ways of
   saying "this part is small print", applied to the one sentence that makes
   the three offers above it believable. Somebody deciding whether to hand
   over a notification channel is exactly who needs to read it, and small
   print is what people have learned to skip. Same size and colour as the
   lead now; only the space above it still sets it apart. */
.blocking-note {
  margin: .85rem 0 0;
  font-size: .95rem; line-height: 1.5;
  color: #d8e5fa;
  text-align: justify;
}

/* Two EQUAL choices share a row. Stacking them spends two rows of a dialog
   that has to keep its exits above the fold in Russian, and vertical order
   implies a preference between Telegram and Discord we have no basis for.
   `min-width: 0` so a long Russian label ellipsises inside its half rather
   than pushing the row wider than the dialog. */
.blocking-actions-row { display: flex; gap: .5rem; }
.blocking-actions-row > .ft-pillbtn {
  flex: 1 1 0; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* Each channel wears its OWN brand rather than the app's gold. Gold means
   "the thing we want you to press", and when the options are equal, giving it
   to one of them is a recommendation. The fills are the same custom properties
   the sign-in dialog's provider buttons use, so the two dialogs cannot drift
   into different blues. */
.blocking-btn--telegram {
  color: #fff; border-color: transparent; background: var(--fill-telegram);
}
.blocking-btn--telegram:hover, .blocking-btn--telegram:focus-visible {
  background: var(--fill-telegram-hover);
}
.blocking-btn--discord {
  color: #fff; border-color: transparent; background: var(--fill-discord);
}
.blocking-btn--discord:hover, .blocking-btn--discord:focus-visible {
  background: var(--fill-discord-hover);
}
/* Email has no brand to wear, and it must not fall through to the gold — this
   app's gold means "the thing we want you to press", and email is the channel
   the picker's own cost ordering ranks LAST. So: a neutral steel fill, filled
   like the two above it so it reads as their peer rather than as an
   afterthought, and unmistakably not either brand and not the default. */
.blocking-btn--email {
  color: #eaf2ff; border-color: transparent;
  background: linear-gradient(#4a5a75, #3d4c65 52%, #33415a);
}
.blocking-btn--email:hover, .blocking-btn--email:focus-visible {
  background: linear-gradient(#55678a, #465875 52%, #3b4b68);
}


.account-actions--stacked {
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-start;
  gap: .5rem;
}
/* Anchors are actions here too (the Telegram remedy is a real link), so they
   need the centring a <button> gets for free. */
.account-actions--stacked > a.ft-pillbtn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  text-decoration: none;
}

/* Sign out. Present in every one of these dialogs and almost never the right
   answer, so it is legible and quiet — the same argument as .account-textbtn
   next door, one step less severe because it still has to look pressable. */
.ft-pillbtn--quiet {
  background: transparent;
  border-color: #ffffff26;
  color: #9fb4d9;
}
.ft-pillbtn--quiet:hover { background: #ffffff14; color: #d6e3f7; }

/* THE FOOT ROW: answers that abandon the state rather than resolving it.
   A pill in this dialog means "something we are asking you to do", and giving
   "Turn notifications off" and "Sign out" that same weight is what grew the
   box to five identical rows — a menu to be read before it can be used, in a
   dialog nobody can dismiss. As links they are still answers, still always
   present and never behind a scroll, and they stop competing with the one
   thing that actually fixes anything.

   Centred and separated by a middot rather than stacked, so the row reads as
   a footnote at a glance and costs ONE row however many it holds. */
.blocking-exits {
  display: flex; flex-wrap: wrap; justify-content: center;
  align-items: center; gap: .15rem .85rem;
  margin-top: .55rem; padding-top: .6rem;
  border-top: 1px solid #ffffff12;
}
.blocking-exit-sep { color: #ffffff2e; user-select: none; }
.blocking-exit {
  background: none; border: 0; padding: .2rem 0;
  font: inherit; font-size: .82rem; font-style: italic; font-weight: 700;
  letter-spacing: .02em; text-transform: uppercase;
  color: #8296b8; cursor: pointer;
  text-decoration: underline; text-decoration-color: #8296b855;
  text-underline-offset: .22em;
  transition: color .1s, text-decoration-color .1s;
}
.blocking-exit:hover, .blocking-exit:focus-visible {
  color: #d6e3f7; text-decoration-color: #d6e3f7aa;
}
.blocking-exit:disabled { opacity: .5; cursor: default; }

/* Small viewports are the constraint case, not the exception: this dialog has
   to fit four stacked actions and a scrolling body inside 640px of phone with
   Russian in it. Buying back 11px of padding on each edge is worth more here
   than the roomier frame is. */
@media (max-width: 420px), (max-height: 700px) {
  .modal--blocking { padding: 1.2rem 1.2rem 1rem; }
  .modal--blocking .modal-title { font-size: 1.2rem; }
  /* The note is prose at the lead's size now, so it shrinks with it — left
     behind it would be the LARGEST text in the body on the narrowest screen. */
  .blocking-lead, .blocking-note { font-size: .9rem; }
}

/* =========================================================================
   TRADES (js/trades.js) — what is on the table, and whose move it is.

   THE LEFT EDGE OF EVERY BOX SAYS WHAT THE BOX WANTS FROM YOU. That is the
   one structural device here, and it is not decoration: a person may have
   three offers open at once, and the question they arrive with is "which of
   these needs me". So each card carries a coloured rail and an eyebrow
   naming the state — your move / their move / go and trade / paused — and
   the rail's colour is the same custom property the eyebrow's text uses, so
   the two can never disagree. Numbering them (01 / 02 / 03) would be the
   reflex and would be a lie: a list of open trades is a set, not a sequence.

   The cards are LEFT-ALIGNED. Everything here used to be centred, which is
   right for a splash screen and wrong for a work surface — a centred
   paragraph has no edge for the eye to return to, and this screen is mostly
   paragraphs about what to do next. The one thing still centred is the deal
   bar, which is symmetric by nature.

   It renders inside `.teams-view` chrome, so the progress panel and the
   filter chips are already hidden by the time any of this paints, and
   `.matrix`'s own grid gap is what spaces the cards — hence no margins
   between them here.
   ========================================================================= */

/* The state colours, named for the state rather than the hue, because the
   rail, the eyebrow and the deadline all have to move together when one
   changes. Scoped to this view rather than added to :root — nothing else in
   the app has a "whose move is it" axis. */
.trade-card, .trade-switch, .trade-conn {
  --trade-you:  #ffc31f;   /* waiting on the person reading this */
  --trade-them: #8fb4ff;   /* waiting on the other collector */
  --trade-live: #4ade9b;   /* agreed — go and do it in Fortnite */
  --trade-warn: #ffa26b;   /* blocked, paused, or overdue */
  --rail: var(--trade-them);
}

/* ---- the card ---- */
.trade-card {
  position: relative;
  max-width: 38rem;
  margin-inline: auto;
  width: 100%;
  /* The extra left padding is the rail's width plus its breathing room; the
     rail is a pseudo-element rather than a border-left so the card keeps its
     rounded corners on that side. */
  padding: 1.05rem 1.25rem 1.25rem 1.5rem;
  overflow: hidden;
}
.trade-card::before {
  content: "";
  position: absolute; inset: 0 auto 0 0; width: 4px;
  background: var(--rail);
  box-shadow: 0 0 20px -3px var(--rail);
}
.trade-card--you  { --rail: var(--trade-you); }
.trade-card--them { --rail: var(--trade-them); }
.trade-card--live { --rail: var(--trade-live); }
.trade-card--warn { --rail: var(--trade-warn); }
/* Nothing is being asked of anybody: the rail is still there, so the column
   of cards keeps one left edge, but it says nothing. */
.trade-card--quiet { --rail: #2f3c6e; }
.trade-card--quiet::before { box-shadow: none; }

.trade-card-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: .5rem 1rem; flex-wrap: wrap;
}
.trade-eyebrow {
  display: inline-flex; align-items: center; gap: .45rem;
  font-size: .68rem; font-weight: 900; letter-spacing: .14em;
  text-transform: uppercase; color: var(--rail);
}
/* Skewed like every other mark in this app, so the rail's label belongs to
   the same drawing as the cells and the buttons. */
.trade-eyebrow::before {
  content: ""; flex: none; width: .4rem; height: .4rem;
  background: currentColor; transform: skewX(-11deg);
}
.trade-deadline {
  font-size: .78rem; font-weight: 700; color: var(--ink-dim);
  font-variant-numeric: tabular-nums;
}
/* Under an hour left. Colour rather than motion: this card may sit on screen
   for a long time, and a deadline that flashes at somebody is louder than
   the thing it is about. */
.trade-deadline--soon { color: var(--trade-warn); }
.trade-card-title {
  margin: .35rem 0 0;
  font-size: 1.1rem; font-weight: 800; font-style: italic;
  text-transform: uppercase; letter-spacing: .04em; line-height: 1.2;
}
.trade-card-body { margin: .55rem 0 0; color: #cfe0ff; }
.trade-card .account-hint { margin-top: .55rem; }

/* ---- the deal bar: the signature of this screen ----
   ONE object, not two tiles and a piece of punctuation. A trade is a single
   thing two people agree to, and drawing it as a slab with two lit halves
   says that where an arrow between two floating tiles says "here are two
   sprites". Each half is washed by ITS OWN sprite's rarity colour, so every
   trade on the page is lit by its actual contents rather than by a house
   style.

   BOTH HALVES ARE LIT EQUALLY, and that is the one deliberate risk in this
   design. The intuitive move is to dim or grey the sprite you give — it is
   leaving, after all — and it would be a lie about the product: a sprite
   works like an achievement, so giving one away does not remove it from your
   own vault. Dimming it would teach the exact thing the page's own copy
   spends a sentence undoing. The direction is carried by the captions and
   the notch instead, which is all it needs to be carried by. */
.trade-deal {
  position: relative;
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: stretch;
  margin: .95rem 0 .5rem;
  border: 1px solid #78aaff33;
  border-radius: .55rem;
  overflow: hidden;
  background: #080d26a6;
}
.trade-side {
  display: flex; flex-direction: column; align-items: center; gap: .3rem;
  min-width: 0;
  padding: .85rem .65rem .8rem;
  background: radial-gradient(130% 105% at 50% 122%,
    color-mix(in srgb, var(--rarity, #78aaff) 62%, transparent) 0,
    color-mix(in srgb, var(--rarity, #78aaff) 14%, transparent) 58%,
    transparent 82%);
}
.trade-side-dir {
  font-size: .64rem; font-weight: 900; letter-spacing: .13em;
  text-transform: uppercase; color: #d7e6ffc4;
}
/* Bigger than a grid cell on purpose: this is the one sprite on the site
   that has to be IDENTIFIED rather than scanned. */
.trade-side-tile { width: 5.5rem; height: 5.5rem; margin: .15rem 0 .1rem; }
.trade-side-name {
  font-size: .87rem; font-weight: 800; line-height: 1.15;
  text-align: center; overflow-wrap: anywhere;
}
.trade-side-dust {
  font-size: .76rem; color: var(--ink-dim); font-variant-numeric: tabular-nums;
}
.trade-side + .trade-side { border-left: 1px solid #78aaff2e; }

/* The notch sits ON the seam rather than being a third column of its own.
   As a column it was a black stripe between two lit halves — a gap, which is
   the opposite of what it is for. As a badge over the join it reads as the
   clasp holding the two sides together, and it is cut to the same -11deg
   parallelogram as every cell and button in this app. */
.trade-notch {
  position: absolute; left: 50%; top: 50%; z-index: 1;
  width: 2.3rem; height: 2.3rem;
  transform: translate(-50%, -50%) skewX(-11deg);
  display: grid; place-items: center;
  border: 1px solid #78aaff73; border-radius: .3rem;
  background: #0a1030;
  box-shadow: 0 2px 12px #00000099;
}
.trade-notch-arrow {
  font-size: 1.15rem; line-height: 1; color: var(--accent);
  transform: skewX(11deg);
}

/* The verdict on the price, under the bar it is about. A line rather than a
   badge in the notch: the notch says which way the sprites go, and one
   element does one job. */
.trade-fairness {
  margin: 0; font-size: .85rem; color: var(--ink-dim); text-align: center;
}
.trade-fairness--even { color: #7fe0a8; }

/* ---- the record ----
   A row of small facts rather than one score: a single number would have to
   decide how much one bad report outweighs twenty good trades, and the
   honest answer is that the reader decides. */
.trade-chips {
  display: flex; flex-wrap: wrap; gap: .35rem;
  margin-top: .85rem;
}
.trade-chip {
  padding: .2rem .6rem; border-radius: 999px;
  border: 1px solid #78aaff33; background: #78aaff14;
  font-size: .76rem; font-weight: 700; color: #cfe0ff;
}
.trade-chip--trusted { border-color: #ffc31f80; background: #ffc31f1f; color: var(--gold); }
/* Neither good news nor bad: there is nobody to have a record yet. Drawn
   apart from --new, which is a real account that has traded nothing. */
.trade-chip--unknown { border-style: dashed; opacity: .85; }
.trade-chip--bad     { border-color: #ff5a5a80; background: #ff5a5a1a; color: #ff9b9b; }
.trade-chip--new     { color: var(--ink-dim); }

/* ---- the Epic handle ----
   The payload of the whole screen: the string that gets typed into another
   application's Add Friend box. So it is the largest type on the card, it is
   monospace because it is a string to be transcribed rather than read, and
   THE WHOLE SLAB IS THE BUTTON — copying is the only thing anybody wants to
   do to it, and a small Copy pill beside it makes the target smaller than
   the thing it acts on. */
.trade-handle {
  display: block; width: 100%; text-align: left; cursor: pointer;
  margin: 1rem 0 .1rem; padding: .7rem .85rem;
  font: inherit; color: inherit;
  border: 1px solid #ffc31f59; border-radius: .5rem;
  background: linear-gradient(#ffc31f17, #ffc31f08);
  transition: border-color .12s ease, background .12s ease, transform .05s ease;
}
.trade-handle:hover { border-color: #ffc31fa6; background: #ffc31f24; }
.trade-handle:active { transform: translateY(1px); }
.trade-handle:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
.trade-handle-head {
  display: flex; align-items: center; justify-content: space-between; gap: .75rem;
  font-size: .66rem; font-weight: 900; letter-spacing: .13em;
  text-transform: uppercase; color: #ffd98a;
}
.trade-handle-copy { color: #ffe6ad; opacity: .85; }
.trade-handle-name {
  display: block; margin-top: .3rem;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 1.3rem; font-weight: 700; line-height: 1.2;
  color: var(--gold); overflow-wrap: anywhere;
}
/* The same person on the platforms their Epic account is linked to.
   Deliberately quieter and NOT monospace: these identify, they do not
   search — typing a PSN name into Add Friend finds nobody — so they must
   never be mistaken for the string above. */
.trade-handle-aka {
  display: block; margin-top: .35rem;
  font-size: .78rem; font-weight: 400; color: #cfe0ffa8;
}

/* ---- actions ----
   Left-aligned with the rest of the card, primary first. */
.trade-actions {
  display: flex; flex-wrap: wrap; gap: .55rem;
  margin-top: 1rem;
}
.trade-actions--stack { flex-direction: column; align-items: stretch; }

/* ---- the participation switch ----
   Wears the same rail as the cards, because it answers the same question:
   green while the pool can reach you, grey while it cannot. */
.trade-switch {
  position: relative; overflow: hidden;
  display: flex; align-items: center; justify-content: space-between;
  gap: .75rem 1rem; flex-wrap: wrap;
  max-width: 38rem; margin-inline: auto; width: 100%;
  padding: .8rem 1.15rem .8rem 1.5rem;
  --rail: #2f3c6e;
}
.trade-switch::before {
  content: ""; position: absolute; inset: 0 auto 0 0; width: 4px;
  background: var(--rail);
}
.trade-switch--on { --rail: var(--trade-live); }
.trade-switch--on::before { box-shadow: 0 0 20px -3px var(--rail); }
.trade-switch-main { display: flex; flex-direction: column; gap: .05rem; min-width: 0; }
.trade-switch-label {
  font-size: .66rem; font-weight: 900; letter-spacing: .14em;
  text-transform: uppercase; color: var(--ink-dim);
}
/* The display face, for one word. It is the only thing on this row worth
   reading from across the table, and "On" is the answer to the question the
   whole screen depends on. */
.trade-switch-state { font-size: 1.35rem; line-height: 1.1; color: var(--rail); }
.trade-switch--on .trade-switch-state { color: #8ff0c0; }
/* Stopped: the flag is on and the account is not in the pool. It wins over
   --on for both the rail and the word, because "On" was the false half of
   this panel — the switch's position, printed as if it were the outcome. */
.trade-switch--stopped { --rail: var(--trade-warn); }
.trade-switch--stopped::before { box-shadow: 0 0 20px -3px var(--rail); }
.trade-switch--stopped .trade-switch-state { color: #ffc4a3; }
/* The reason, one line, full width under the row. `flex-basis: 100%` rather
   than a wrapper, so the switch and its button keep the layout they had on
   every account that is not blocked. */
.trade-switch-warn {
  flex-basis: 100%; margin: .1rem 0 0;
  display: flex; align-items: flex-start; gap: .5rem;
  font-size: .88rem; line-height: 1.35; color: #ffd0b6;
}
.trade-switch-bang {
  flex: none; display: grid; place-items: center;
  width: 1.05rem; height: 1.05rem; margin-top: .08rem; border-radius: 50%;
  background: var(--trade-warn); color: #12172e;
  font-weight: 900; font-size: .78rem; line-height: 1;
}

/* ---- the connection line ----
   Whether this page is still talking to the server. Nothing is drawn while
   it is healthy: a green "connected" badge on a page that is connected 99%
   of the time is a permanent reassurance nobody reads, and it makes the one
   moment it changes harder to notice, not easier. */
.trade-conn {
  display: flex; align-items: center; justify-content: center; gap: .5rem;
  flex-wrap: wrap; max-width: 38rem; margin: .25rem auto 0;
  font-size: .82rem; text-align: center;
}
.trade-conn::before {
  content: ""; flex: none; width: .5rem; height: .5rem; border-radius: 999px;
  background: currentColor;
  animation: trade-conn-blink .8s steps(1) infinite;
}
.trade-conn--lost { color: var(--trade-warn); }
.trade-conn--broken { color: #ff9b9b; }
@keyframes trade-conn-blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: .15; } }

/* The dot is the only moving thing on a screen someone may leave open for a
   long time; honour the OS setting and let the colour carry it instead. */
@media (prefers-reduced-motion: reduce) {
  .trade-conn::before { animation: none; }
  .trade-handle { transition: none; }
}

/* The card stops growing at 38rem, so past that the halves have room the
   sprite is not using. Identification is the job here — take it. */
@media (min-width: 48rem) {
  .trade-side-tile { width: 6.5rem; height: 6.5rem; }
}

/* THE BAR STAYS SIDE BY SIDE AT EVERY WIDTH. Stacking it was the first
   answer and it undoes the idea: turned upright it is two sprites with a
   mark between them again, and — since the notch is centred on the seam
   rather than being a column — the badge lands on top of a caption.

   It also is not needed. Two 5.5rem tiles plus their captions want about
   19rem of card interior; a 320px phone has ~15.7rem after the shell and
   card padding, so the tiles give up a little size at the very bottom of
   the range rather than the layout giving up its shape. */
@media (max-width: 24rem) {
  .trade-card, .trade-switch { padding: .9rem 1rem .9rem 1.25rem; }
}
@media (max-width: 20.5rem) {
  .trade-side-tile { width: 4.5rem; height: 4.5rem; }
  .trade-side { padding-inline: .4rem; }
  .trade-side-name { font-size: .8rem; }
}

/* The nav dot. The only hint that a trade is waiting on a screen the user
   isn't looking at, so it rides the button they'd tap to get there. */
.app-nav-btn--alert { border-color: #ffc31f80; color: #ffe08a; }
.app-nav-btn--alert::after {
  content: ""; width: .45rem; height: .45rem; border-radius: 999px;
  background: var(--gold); box-shadow: 0 0 0 3px #ffc31f33;
}

/* Archive — one full matrix per past season, each under its season heading.
   Only rendered in the archive view (a secondary, teams-style full-shell view);
   the matrices themselves reuse .creature-band--matrix, so this only styles the
   per-season heading and spacing. */
.archive-season { margin-bottom: 2.75rem; }
.archive-season:last-child { margin-bottom: 0; }
.archive-season-title {
  font-family: var(--font-display);
  font-style: italic;
  text-transform: uppercase;
  letter-spacing: .04em;
  font-size: 1.5rem;
  color: var(--ink);
  margin: 0 0 1rem;
  padding-bottom: .5rem;
  border-bottom: 1px solid var(--ink-dim);
}
.archive-empty { color: var(--ink-dim); text-align: center; padding: 3rem 1rem; }

/* Season strip — the shared view's other seasons, stacked above its grid
   (js/share.js::seasonStrip). The Archive answers this for your own vault with
   a full matrix per season, one under the next; a shared vault cannot, because
   the panel above it — the ring, the bands, the compare chips — measures ONE
   season, and stacking four matrices under it would leave three of them under
   numbers that are not theirs. So each other season is a row that swaps the
   grid instead, and the row for the live season is the way back.

   Deliberately NOT `.team-row` reused: those rows open a different view of a
   different thing, and sharing the selector would mean a change to the teams
   list silently restyling this. Same shape, spelled once each. */
.season-list { display: flex; flex-direction: column; gap: .55rem; margin-bottom: 2rem; }
.season-list-title {
  font-family: var(--font-display);
  font-style: italic;
  text-transform: uppercase;
  letter-spacing: .06em;
  font-size: .85rem;
  color: var(--ink-dim);
  margin: 0 0 .15rem;
}
.season-row {
  display: flex; align-items: center; gap: .9rem;
  width: 100%; text-align: left; cursor: pointer;
  padding: .7rem 1rem; font: inherit; color: inherit;
  transition: background .15s ease, border-color .15s ease, transform .05s ease;
}
.season-row:hover, .season-row:focus-visible {
  background: #ffffff14; border-color: #9fc4ff59; outline: none;
}
.season-row:active { transform: scale(.995); }
/* The name/count column takes the slack, so the tag and the chevron stay
   pinned right however long a translated count runs. */
.season-row-main { display: flex; flex-direction: column; gap: .1rem; min-width: 0; flex: 1; }
.season-row-name {
  font-family: var(--font-display); font-style: italic; text-transform: uppercase;
  letter-spacing: .02em; font-size: 1.05rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.season-row-meta { color: #9fb4d9; font-size: .82rem; }
/* Only ever on the live season's row, and only while you are looking at an
   archived one — which is the moment "go back" needs saying. */
.season-row-tag {
  flex: none;
  font-size: .7rem; letter-spacing: .08em; text-transform: uppercase;
  color: var(--gold); border: 1px solid #ffc31f4d; border-radius: 999px;
  padding: .15rem .5rem;
}
.season-row-chevron { color: #9fc4ff; font-size: 1.5rem; line-height: 1; flex: none; }

/* =========================================================================
   ADMIN PANEL (js/cheats.js) — the floating cheat-codes button + its modal.
   ========================================================================= */

/* The floating pill, drawn after the game's own lobby control: a thin
   light-blue border over a translucent dark fill, parked at the top right
   OVER the content rather than in the nav — it is a door into a side
   feature, not part of the constant right-hand group, and the game's
   players already know this exact shape by sight.

   Fixed like the nav and just under it: z-index 39 keeps it below the bar
   (40) so nothing ever overlaps the chrome, while everything that scrolls
   passes under both. --sticky-top keeps it clear of Telegram's floating
   controls for the same reason the nav absorbs it. */
.cheats-fab {
  position: fixed;
  top: calc(var(--sticky-top) + 4.4rem);
  right: clamp(.75rem, 2.5vw, 1.75rem);
  z-index: 39;
  display: inline-flex; align-items: center; gap: .4rem;
  padding: .5rem .95rem;
  font-family: var(--font-sans); font-weight: 700; font-size: .85rem;
  letter-spacing: .04em;
  color: #dff0ff;
  background: #0d1637d9;
  border: 1px solid #9fd4ff8c;
  border-radius: .55rem;
  cursor: pointer;
  box-shadow: 0 4px 18px #00000059, inset 0 0 14px #7fd4ff17;
  backdrop-filter: blur(6px);
  transition: background .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease;
}
.cheats-fab:hover, .cheats-fab:focus-visible {
  background: #142050d9; border-color: #c4e4ff; color: #fff; outline: none;
  box-shadow: 0 4px 22px #00000073, inset 0 0 18px #7fd4ff2e, 0 0 12px #7fd4ff40;
}
.cheats-fab-icon {
  display: inline-flex;
  color: #9fd4ffb3;
}
/* The .../ mark is deliberately wide. Give it a matching rectangular box so
   its ink is as tall as the header icons instead of being letterboxed inside
   a square icon slot. */
.cheats-fab-icon svg { display: block; width: 1.6em; height: 1.2em; }
@media (max-width: 34rem) {
  /* Match the header's icon controls: retain the label as the button's
     accessible name, but clip it visually so the fixed lobby control is
     icon-only on a phone. */
  .cheats-fab-label {
    position: absolute; width: 1px; height: 1px;
    overflow: hidden; clip-path: inset(50%); white-space: nowrap;
  }
  .cheats-fab { font-size: .78rem; padding: .4rem .6rem; }
}

/* ---- the modal ---- */
.modal--cheats {
  /* The generic modal uses max-height: 90vh + overflow:auto. On Safari that
     leaves too little room for this known five-row frame and creates a
     second, inner scrollbar. Take the whole band a dialog may occupy instead
     (--modal-avail-h, which already has Telegram's insets out of it), and
     keep scrolling out of a dialog whose contents are laid out to fit. */
  height: min(44.5rem, var(--modal-avail-h));
  max-height: var(--modal-avail-h);
  overflow: hidden;
}
.cheats-panel {
  /* Fill the enlarged shell as a column, so the frame can decide what gives
     way when it is shorter than the five-row reservation below. Nothing is
     compressed on a screen tall enough to hold the whole thing. */
  height: 100%;
  display: flex;
  flex-direction: column;
}
/* A phone frame is shorter than the five-row reservation, so the dialog's own
   chrome is trimmed FIRST — padding, the header's rule-off, the gaps between
   the three lines above the list. Every pixel taken back here is a pixel the
   list keeps, and it is worth about one whole card. The same breakpoint the
   fab uses, and width rather than height on purpose: the constraint is the
   phone, and a short desktop window merely scrolls the list a little. */
@media (max-width: 34rem) {
  .modal--cheats { padding: 1rem; }
  .modal--cheats .modal-head { margin-bottom: .45rem; }
  .modal--cheats .cheat-sub { margin: 0 0 .3rem; font-size: .88rem; }
  .modal--cheats .cheat-count { margin-bottom: .4rem; }
  .modal--cheats .cheat-search { margin-bottom: .5rem; }
  .modal--cheats .cheat-pager { margin-top: .5rem; }
}

/* Below this there is no room left to trim and the list has already given up
   everything it has, so the two explanatory lines go: a landscape phone, or a
   portrait one with the keyboard up over the search field. What must survive
   is the search, one card and the pager — a frame with no way out of page one
   is the failure this whole block exists to prevent. */
@media (max-height: 30rem) {
  .modal--cheats .cheat-sub,
  .modal--cheats .cheat-count { display: none; }
  /* And the fixed frame itself is given up: below this a pinned pager would
     be pinned to a list with no height left, showing no cards at all. One
     scroller over the whole dialog — the generic modal's own behaviour — puts
     every part of it back within reach. */
  .modal--cheats { height: auto; overflow-y: auto; }
  .modal--cheats .cheat-list { flex: none; overflow: visible; }
}

.cheat-sub { margin: .25rem 0 .5rem; color: var(--ink-dim); font-size: .95rem; }
.cheat-count {
  margin: 0 0 .6rem; color: #9fd4ffd9; font-size: .8rem; font-weight: 600;
  text-transform: uppercase; letter-spacing: .06em;
}
.cheat-search {
  width: 100%; padding: .55rem .75rem; margin-bottom: .75rem;
  font: inherit; color: var(--ink);
  background: #ffffff0d; border: 1px solid var(--panel-border); border-radius: .5rem;
}
.cheat-search:focus { outline: none; border-color: #78aaff8c; }
.cheat-list {
  display: flex; flex-direction: column; gap: .5rem;
  /* Five original-size cards plus their four gaps — a basis rather than a
     fixed height, and it never grows past it, so a screen with the room lays
     out exactly as before: five full-size cards, no inner scrollbar, the
     short final page keeping the same reservation.

     What it MAY do is shrink, and that is the whole fix. On a phone — a
     Mini App WebView is ~660px tall with Telegram's own header out of it —
     the reservation plus the header, search and pager exceeds the frame, and
     `overflow: hidden` on the shell put the PAGER outside it: clipped,
     unreachable, so page 2 could not be opened at all. The list is the one
     part with somewhere to go, so it yields first and scrolls internally;
     min-height: 0 is what permits that, since a flex item otherwise refuses
     to shrink below its content. */
  flex: 0 1 24.25rem;
  min-height: 0;
  overflow-y: auto;
  /* A drag that reaches the end of the list stops there rather than chaining
     out to the dialog and the page behind it — the rule .modal already keeps. */
  overscroll-behavior: contain;
}
.cheat-empty { margin: .5rem 0; color: var(--ink-dim); }

/* One code: the code itself is gold — like everything else here that is
   worth something — the reward sits under it, the used toggle rides the
   right edge across both rows. A used row dims as a whole rather than
   striking the code through: the code stays copyable, someone may well
   redeem it on a second account. */
.cheat-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas: "code toggle" "reward toggle";
  gap: .15rem .75rem; align-items: center;
  padding: .6rem .75rem;
  background: #ffffff08; border: 1px solid var(--panel-border); border-radius: .6rem;
}
.cheat-row.is-used { opacity: .62; }
.cheat-code-line {
  grid-area: code; min-width: 0;
  display: flex; align-items: center; gap: .4rem;
}
.cheat-code {
  min-width: 0; max-width: 100%;
  display: inline-flex; align-items: center; gap: .45rem;
  padding: .15rem .4rem; margin-left: -.4rem;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .95rem; font-weight: 700; letter-spacing: .03em;
  color: #ffe9a8; background: none; border: none; border-radius: .4rem;
  cursor: pointer; text-align: left; overflow-wrap: anywhere;
}
.cheat-code:hover, .cheat-code:focus-visible {
  background: #ffc31f1f; color: #fff3c9; outline: none;
}
.cheat-code-text { min-width: 0; }
.cheat-new-badge {
  flex: none;
  padding: .08rem .34rem;
  font-family: var(--font-sans); font-size: .62rem; font-weight: 400; line-height: 1.2;
  letter-spacing: .02em; color: #06102b; background: #79dcff;
  border: 1px solid #c5f2ff; border-radius: 999px;
  box-shadow: 0 0 9px #48c9ff80;
}
.cheat-copy-icon { display: inline-flex; flex: none; color: #ffd769a6; }
.cheat-code:hover .cheat-copy-icon, .cheat-code:focus-visible .cheat-copy-icon { color: #ffe9a8; }
.cheat-reward { grid-area: reward; margin: 0; color: var(--ink-dim); font-size: .9rem; }
.cheat-used-toggle {
  grid-area: toggle;
  display: inline-flex; align-items: center; gap: .35rem;
  padding: .35rem .6rem;
  font: inherit; font-size: .8rem; font-weight: 600;
  color: #cfe0ff; background: rgba(159, 196, 255, .06);
  border: 1px solid rgba(159, 196, 255, .18); border-radius: .4rem; cursor: pointer;
  transition: background .15s ease, border-color .15s ease, color .15s ease;
}
.cheat-used-toggle:hover, .cheat-used-toggle:focus-visible {
  background: rgba(159, 196, 255, .14); border-color: rgba(159, 196, 255, .35);
  color: #eaf3ff; outline: none;
}
/* The check only exists once it is true. */
.cheat-used-icon { display: none; }
.cheat-row.is-used .cheat-used-toggle {
  color: #b5ffcd; background: #2ee06322; border-color: #2ee0634d;
}
.cheat-row.is-used .cheat-used-toggle:hover,
.cheat-row.is-used .cheat-used-toggle:focus-visible {
  background: #2ee06333; border-color: #2ee06380; color: #d2ffe1;
}
.cheat-row.is-used .cheat-used-icon { display: inline-flex; }

/* flex: none — the pager is the one control that must survive a short frame.
   It is the only route to every code past the fifth, so it never gives way;
   the list above it does. */
.cheat-pager {
  display: flex; align-items: center; justify-content: center; gap: .75rem;
  margin-top: .85rem; flex: none;
}
.cheat-page-btn {
  padding: .25rem .7rem; font: inherit; font-size: 1rem; font-weight: 700;
  color: #cfe0ff; background: rgba(159, 196, 255, .06);
  border: 1px solid rgba(159, 196, 255, .18); border-radius: .4rem; cursor: pointer;
}
.cheat-page-btn:disabled { opacity: .35; cursor: default; }
.cheat-page-btn:not(:disabled):hover { background: rgba(159, 196, 255, .14); }
.cheat-page-num { color: var(--ink-dim); font-size: .9rem; min-width: 3.5rem; text-align: center; }
