    /* ── Typefaces (Jul 23 2026) ─────────────────────────────────────
       Miracode (pixel/bitmap-style, v1.0, 39 KB) is the UI voice.
       Victor Mono is kept installed as the immediate fallback.
       BOTH are display faces with narrow coverage (~24% of the characters
       in Kero's titles) — that's fine, because CSS font fallback is resolved
       PER CHARACTER and the tail of --font-mono handles the rest. */
    /* BD Alien Black — Büro Destruct / Typedifferent, licensed Web EULA
       (purchased Jul 25 2026). Self-hosted; the EULA forbids redistribution,
       and asks that Büro Destruct be credited wherever credits are shown.
       Single weight (Black), 353 glyphs / 345 mapped codepoints, and
       PROPORTIONAL — see the --font-ui note below before widening its use. */
    @font-face {
      font-family: 'BD Alien';
      src: url('fonts/BDAlien-Black.woff2') format('woff2'),
           url('fonts/BDAlien-Black.woff') format('woff');
      font-weight: 400; font-style: normal; font-display: swap;
    }
    /* Same file, DIGITS ONLY (Jul 25 2026). Whole-UI BD Alien read as too
       wild, so it's now a numeral face: this unicode-range means the browser
       reaches for BD Alien on 0-9 and falls through to the mono stack for
       every other character, with no per-element classes anywhere. Kept as a
       separate family so plain 'BD Alien' is still available unrestricted. */
    @font-face {
      font-family: 'BD Alien Numerals';
      src: url('fonts/BDAlien-Black.woff2') format('woff2'),
           url('fonts/BDAlien-Black.woff') format('woff');
      font-weight: 400; font-style: normal; font-display: swap;
      unicode-range: U+0030-0039;
    }
    @font-face {
      font-family: 'Miracode';
      src: url('fonts/Miracode.woff2') format('woff2');
      font-weight: 400; font-style: normal; font-display: swap;
    }
    @font-face {
      font-family: 'Victor Mono';
      src: url('fonts/VictorMono-Regular.woff2') format('woff2');
      font-weight: 400; font-style: normal; font-display: swap;
    }
    @font-face {
      font-family: 'Victor Mono';
      src: url('fonts/VictorMono-Bold.woff2') format('woff2');
      font-weight: 700; font-style: normal; font-display: swap;
    }
    @font-face {
      font-family: 'Victor Mono';
      src: url('fonts/VictorMono-Italic.woff2') format('woff2');
      font-weight: 400; font-style: italic; font-display: swap;
    }

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

    /* No text selection anywhere — this is a gallery/app surface, not a
       document. Long-pressing artwork on a phone or dragging across the
       grid was selecting titles and the ticker instead of scrolling, and
       the shuffle animations left half-selected runs of scrambled glyphs
       behind. -webkit-touch-callout also suppresses iOS's "copy / look up"
       bubble on long-press. Real inputs are re-enabled below — <select>
       and any future text field must stay selectable to work. */
    html { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
    input, textarea, select, [contenteditable] {
      -webkit-user-select: text; user-select: text;
    }
    /* ── Selectable DATA ──────────────────────────────────────────────
       The global `user-select: none` above exists so tapping chrome never
       leaves a stray highlight — but it also made every value on the page
       impossible to select or copy, which matters most exactly where the
       data is: wallet addresses, contract ids, token ids, collection names.
       Dragging across them "did nothing" on mobile.

       These opt back in. `-webkit-touch-callout: auto` is the other half —
       without it iOS suppresses the long-press Copy menu even when the text
       is selectable, so the value would still be uncopyable on a phone.

       🚨 GATED TO `any-pointer: fine`, AND THE CLAIM THAT USED TO SIT HERE WAS
       WRONG. This block previously asserted "Scrolling is unaffected: a
       touch-drag over selectable text still pans the page on iOS/Android
       (selection needs a long-press first)." That was reasoning written as
       fact, and it was reported as a bug from a device: dragging on the source
       list or on a caption starts a SELECTION and the page will not scroll
       under the finger. Reproduced here by dragging the Source table — the
       drag selected "ETHEREUM · VERSE\t1275" instead of panning.

       A finger has no way to select deliberately without also being the thing
       that scrolls, so on a coarse pointer selection loses: the page must
       always pan. A mouse can do both, so it keeps the copyable values. This
       is the same `any-pointer` gate the hover effects use, and for the same
       underlying reason — a capability test, not a device test.

       ⚠️ `.src-table td` was DROPPED from the list entirely rather than gated.
       It is the "ETHEREUM · VERSE 1275" stat row; nobody copies it, and it is
       the row a mouse-drag lands on when someone drags to scroll the Sources
       section on a narrow window. The values worth copying — the wallet
       address, an artwork title, a description — are all still here. */
    @media (any-pointer: fine) {
      .src-wallet .addr,
      .src-crow .nm,
      .fa-v, .fa-k, .fa-desc,
      .fa-c-who, .fa-c-amt, .fa-c-date, .fa-c-type,
      #fs-name, #fs-collection-badge,
      .card-name, .card-platform {
        -webkit-user-select: text; user-select: text;
        -webkit-touch-callout: default;
      }
    }

    :root {
      --bg:       #060606;
      --white:    #1c1c1c;
      --border:   #2a2a2a;
      --text:     #ebebeb;
      --sub:      #888888;
      --hover:    #242424;
      --radius:   4px;
      /* ── Card corners (Jul 30–31 2026) ─────────────────────────────────
         Gallery cards used to carry their own radius per view — 18px Feed,
         14px Mosaic, 10px Grid, 5px Micro — on the theory that roundness is
         perceived relative to box size, so a big card needs a bigger radius
         to read as equally soft. True in isolation, but it made the gallery
         the roundest thing on a site whose every other surface (the sync
         terminal, panels, dropdowns) sits at 6px, and switching view mode
         visibly changed the page's corner language. That collapsed to one
         6px value, then to **2px** (Jul 31): near-square, just enough to take
         the bite off a hard corner. The artwork now reads as a print on a
         wall rather than an app tile.
         ⚠️ BOTH tokens are 2px, and that is not an oversight. The whole
         reason for a second "tight" value was the size-relative argument
         above — a 6px corner on an 88px Micro tile reads heavier than on a
         350px Feed card. At 2px that stops being true: it is a hairline
         softening that looks identical at every tile size. The two names are
         kept so the distinction can come back with one edit if the radius is
         ever raised again; while they are equal, they are equal on purpose.
         ⚠️ .card and .card-thumb-wrap must always resolve to the SAME value:
         .card::after (the volt hover stroke) takes `border-radius: inherit`
         from .card, so a mismatch draws the stroke at a different curve than
         the thumbnail underneath and the gap shows at all four corners. */
      /* How far a full-bleed gradient overshoots the viewport edge. At
         fractional device-pixel ratios the element edge and the screen edge do
         not land on the same physical pixel, leaving a hairline of the layer
         underneath showing through. Overshooting is the reliable fix — the
         exact value only has to beat one device pixel. Shared by the viewer's
         #fs-info panel and the page's #top-gradient.
         ⚠️ Every use pairs the negative inset with matching padding (or a
         clipping ancestor), so the box grows outward without moving content or
         creating scrollable overflow. */
      --bleed:             8px;
      --card-radius:       2px;   /* Feed · Mosaic · Grid */
      --card-radius-tight: 2px;   /* Micro + Large — same value, see the note above */
      --gap:      20px;
      --col:      200px;
      --page-gap: 24px;   /* space above nav AND between nav and content */

      /* ── Accent ─────────────────────────────────────────────────────
         THE one vivid accent. Every accent tint on the page derives from
         this via color-mix(), so swapping the whole site's accent is a
         one-line change here. */
      /* ── Accent palette — BASIC 16-bit RGB (Jul 20 2026) ────────────
         Full-saturation primaries/secondaries, straight off an RGB test
         card: lime, magenta, yellow (+ cyan reserved for print-style
         misregistration FX). No tinted in-betweens — the pop comes from
         purity. */
      --accent:        #00ff00;  /* lime — THE accent */
      --accent-bright: color-mix(in srgb, var(--accent) 78%, #fff); /* hover of filled accent */
      --accent-ink:    #000;     /* text on accent fills */
      --cyan:    #00ffff;
      --magenta: #ff00ff;
      --yellow:  #ffff00;
      --violet:  #b06bff;   /* Solana, if it ever returns */
      /* Per-chain assignment (referenced by .src-bar--{chain}) —
         the lime/magenta/yellow trio maps to the three live chains. */
      --c-tez:  var(--accent);   /* Tezos    → lime    */
      --c-eth:  var(--magenta);  /* Ethereum → magenta */
      --c-base: var(--yellow);   /* Base     → yellow  */
      --c-sol:  var(--violet);

      /* ── --gutter: ONE page margin ──────────────────────────────────
         The gallery's outer padding, identical on top / left / right, and
         shared with #collection-banner so the banner's text block starts on
         the same x as the first card. Before this every surface picked its
         own numbers — banner 48/16, feed 32/20, mosaic 24/12, micro 16/8 —
         so the left edge moved when you switched view and the top margin
         never matched the side margin ("the left and top margin looks
         different, it's not clean"). Change the page margin HERE, not in the
         per-view rules; those now say `padding: var(--gutter)` and nothing
         else. Per-view density still differs — that lives in column-count and
         column-gap, which is where it belongs. */
      --gutter: 16px;

      /* ── Type system ────────────────────────────────────────────────
         TWO stacks (Jul 24 2026). --font-mono is the UI voice: **Geist Mono
         400** (Jul 25 2026, replacing Space Mono) via Google Fonts, then the
         wide-coverage Notos and system generics. Geist Mono is the free
         stand-in for Ballinger Mono — the face on the Codrops TypeShuffle
         demo this direction came from. Ballinger itself is commercial and
         its Typekit kit is domain-locked to tympanus.net, so it can't ship
         here; Geist is the closest OFL match (neutral, modern, squarish).
         Miracode and Victor Mono are still declared as @font-face above but
         are NOT in any stack — swap either into the head here to try them;
         unreferenced @font-face costs nothing (the browser won't fetch it).

         Artist-authored TITLES use --font-art instead (defined just below) —
         Miracode cannot shape combining marks at all. Both stacks END IN
         GENERIC FAMILIES, which is what lets the browser reach the OS's
         entire font library per character: measured against Kero's real
         1,677 distinct title characters, Miracode covers 24%, Victor Mono
         24%, Menlo 39%, Arial Unicode 60% — ALL of them combined only reach
         80%, so the generic tail is carrying the last fifth (Cherokee,
         Bamum, hieroglyphs). Never prune the tail for tidiness. */
      /* The UI voice — Geist Mono, uniformly. BD Alien is NOT in this stack:
         a unicode-range numeral face applies per character, which lit up the
         digits inside ordinary sentences too ("By collection (60)",
         "169 / 2929", "173ms", the ticker) and two faces inside one phrase
         reads as a rendering fault. It's opted into per element via
         --font-num below, only where the element is nothing but a number. */
      --font-mono:
        'Geist Mono',
        'Noto Sans Mono',
        ui-monospace, 'SF Mono', Menlo, Consolas,
        'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji',
        'Apple Symbols', 'Segoe UI Symbol',
        'Noto Sans Symbols 2', 'Noto Sans Math',
        monospace;

      /* --font-num: BD Alien, for elements that are a BARE NUMBER and nothing
         else — the big totals and the various counts. Deliberately the
         unrestricted family, not the numerals cut, so a thousands separator
         comes from BD Alien too and the element renders in ONE face. Never
         put this on a mixed string; see the --font-mono note above. */
      --font-num: 'BD Alien', 'Geist Mono', monospace;

      /* --font-brand: the DJKERO wordmark (loader, toolbar, viewer
         signature). Same face as --font-num today, named separately so
         wordmark and numerals can diverge without hunting selectors. */
      --font-brand: 'BD Alien', 'Geist Mono', monospace;

      /* --font-code: guaranteed FIXED-PITCH. The sync terminal aligns its
         columns in `ch` units and leans on tabular numerals, so it must not
         follow --font-mono into a proportional display face. */
      --font-code:
        'Geist Mono',
        'Noto Sans Mono',
        ui-monospace, 'SF Mono', Menlo, Consolas,
        monospace;

      /* ── --font-art: artist-authored titles ONLY ────────────────────
         Ordered by MARK ATTACHMENT, not by glyph coverage. That distinction
         is the whole point and was learned the hard way (Jul 24 2026):

         Kero's titles carry combining marks (U+0300-036F). For those to sit
         ON the letter, the base character AND its marks must be shaped by the
         SAME font, and that font needs GPOS `mark`/`mkmk` anchors. If the
         base resolves to font A and the marks fall back to font B, the run
         splits, no attachment happens, and each mark gets its own advance —
         the title smears out sideways into garbage.

         Width ratio = (string with marks) / (same string, marks stripped).
         1.0 means every mark attached and added no advance — correct.
         Measured across the catalogue's real title shapes:

           head of stack        INDEX  Tibetan  Cyrillic   catalogue smeared
           Miracode              3.1x    —         —         358 / 937
           Noto Sans Mono        4.4x    —         —         (worst)
           Menlo / SF Mono       1.0     1.0      3.0x       103 / 937
           PROPORTIONAL SANS     1.0     1.0      1.0          4 / 937  ✅

         Two separate traps, both learned the hard way:

         1. A font that lacks the marks (Miracode has ZERO of them and no
            GPOS features at all) splits the shaping run — base in font A,
            marks in font B — so nothing attaches and the title smears
            sideways. Coverage of the BASE letters is irrelevant.
         2. A MONOSPACE head is nearly as bad: fallback marks inherit the
            fixed advance instead of zero, which is why Menlo still blew up
            the Cyrillic-mark (U+0489) and Tibetan titles.

         So the head must be a PROPORTIONAL SANS — which is exactly what
         verse.works does (Work Sans). Verified against their live page on
         the same artwork: Verse scores 1.14 on INDEX, 1.00 Tibetan, 1.01
         Cyrillic; this stack scores 1.00 / 1.00 / 1.00. The only survivor is
         one pathological string mixing Bengali + Tibetan + Arabic + Hebrew
         marks, which smears on Verse too (3.19 vs our 3.28) — unfixable by
         any font choice.

         Titles therefore read as a clean sans while the UI keeps Miracode.
         Do NOT put a mono or display face at the head of this list without
         re-running the ratio test. The generic tail stays so the OS can
         supply Cherokee, Bamum, hieroglyphs etc. */
      --font-art:
        'Helvetica Neue', Helvetica, Arial,
        system-ui, -apple-system, 'Segoe UI',
        'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji',
        'Apple Symbols', 'Segoe UI Symbol',
        'Noto Sans Symbols 2', 'Noto Sans Math',
        sans-serif;

      /* ── --font-art-code: ALL artist-authored titles (Jul 26 2026) ────
         Chrome DevTools' code-view font, by request, on the gallery AND the
         viewer. Menlo on macOS, Consolas on Windows, DejaVu Sans Mono on
         Linux — that IS the DevTools default, per platform. Chosen because it
         renders Kero's stylized/zalgo titles legibly: every combining mark
         shows up as its own distinct glyph instead of being stacked into an
         unreadable pile. That is the quality being optimised for.

         ⚠️ This deliberately OVERRIDES the --font-art research above; read
         both before touching either, and don't "fix" one to match the other.
         --font-art wins the width-ratio test by a wide margin (22 of 782
         mark-carrying titles smear vs Menlo's 201; mean 1.008 vs 1.158),
         because a mono head makes fallback marks inherit the fixed advance
         instead of zero. That cost is ACCEPTED: the captions now clamp to 2
         lines with `overflow-wrap: anywhere`, so the extra width becomes a
         second line rather than a title running off the card, and the trade
         chosen is mark legibility over compactness.

         --font-art is intentionally left defined but unreferenced. To revert,
         swap this var back to var(--font-art) in the .card-name rule below —
         do not delete --font-art or its note. */
      /* ⚠️ NO symbol/emoji tail here, unlike --font-art. Adding one is the
         obvious "for coverage" move and it BACKFIRES: 'Apple Symbols' /
         'Noto Sans Symbols 2' / 'Noto Sans Math' claim the combining marks
         before the browser's own fallback gets a chance, and render them as
         .notdef tofu boxes (⌷) — each one eating a full monospace cell. The
         captions then overflow and ellipsise while LOOKING like they'd fit,
         which is exactly the "… when there's plenty of space" symptom.
         DevTools is just `Menlo, monospace`; its clean rendering comes from
         Chrome's default fallback finding a face that actually HAS the marks.
         Keep this list to the three real DevTools defaults + the generic. */
      /* ⚠️ Menlo MUST stay at the HEAD. Putting the site's own 'Geist Mono'
         first — "use the normal font first, then roll back for the other
         characters" — was tried on Jul 26 2026 and REVERTED the same day: it
         visibly broke the stylized titles.

         Why it cannot work: the per-character roll-back IS the problem. Geist
         Mono covers the base letters but not the combining marks, so the
         shaping run SPLITS — base shaped by Geist, marks by Menlo — and GPOS
         mark/mkmk attachment only happens when ONE font shapes both. A split
         run gives every mark its own advance, so the marks scatter beside the
         letters instead of sitting on them. It reads as "it doesn't roll back
         to the next font", but the roll-back is exactly what breaks it.
         `unicode-range` can't rescue this either — a title mixing ASCII bases
         with marks still splits, and you cannot know in advance which base
         letters will carry marks.
         Same lesson as trap #1 in the --font-art note above: coverage of the
         BASE letters is irrelevant; what matters is one font shaping both.
         Measured cost of the Geist-first attempt before revert: 611 of 2,952
         titles truncated at mosaic width vs 360 with Menlo first.
         ⚠️ Also do NOT write var(--font-mono) here — that stack ends in the
         symbol/emoji tail that causes the tofu described above. */
      --font-art-code: Menlo, Consolas, 'DejaVu Sans Mono', monospace;
      /* ── ONE UI SIZE (Jul 24 2026) — terminal/CLI look ──────────────
         Every piece of CHROME renders at a single size: pills, eyebrows,
         uppercase labels, buttons, meta, tables, headings, the brand block.
         Hierarchy is carried by colour, weight-of-position and spacing
         instead of type scale — which is exactly how a CLI reads.
         The four legacy step vars are kept as aliases so the ~85 existing
         references don't all have to be rewritten; change --fs-ui alone to
         retune the whole interface. */
      --fs-ui:    13px;
      --fs-micro: var(--fs-ui);
      --fs-small: var(--fs-ui);
      --fs-body:  var(--fs-ui);
      --fs-mid:   var(--fs-ui);
      --fs-head:  var(--fs-ui);
      /* CONTENT keeps its own scale — artwork titles and the big archive
         numbers are not UI and stay large (see --fs-title/--fs-display and
         the explicit sizes on #fs-name / .src-total). */
      --fs-title: clamp(32px, 5vw, 56px);   /* collection banner / shelf titles */
      --fs-display: clamp(40px, 7.5vw, 104px);  /* masthead wordmark + immersive viewer title */
    }

    /* One breakpoint, one number. The per-view mobile padding overrides that
       used to shrink the page margin independently (feed 8px, micro 6px,
       banner 8px) are deleted — retuning the phone gutter is this line. */
    @media (max-width: 680px) {
      :root { --gutter: 10px; }
    }

    html {
      /* Mobile engines "font boost" text inside elements far wider than the
         viewport, and the nowrap #marquee track — the widest box on the page
         by a mile — was getting inflated well past the site's single 13px UI
         size on phones. 100% (not `none`) disables that auto-inflation while
         still honouring the reader's own text-size setting. */
      -webkit-text-size-adjust: 100%;
      text-size-adjust: 100%;
      /* ⚠️ Needed as well as the one on <body>, not instead of it. #top-gradient
         bleeds --bleed past the viewport on both sides; <body> clips the paint
         (its scrollWidth stays at the viewport width) but the overhang was
         still counted in the ROOT scroll area — measured 654 against a 646
         client width — which is enough to raise a horizontal scrollbar. Clipping
         here is what actually makes the bleed cost nothing.
         `clip` before `hidden` on purpose, same as body: clip never creates a
         scroll container. The engine takes whichever it supports; on <html>
         either is safe, since the root is the page's scroller already. */
      overflow-x: clip;
      /* Pure black so iOS rubber-band overscroll at the top reveals
         the same color the hero gradient starts with — no visible
         seam when pulling down past the top edge.
         ⚠️ THAT IS ONLY TRUE WHILE THE GRADIENT IS SHOWN. It is `display: none`
         in ambient mode, which is the site's DEFAULT — see html.ambient-bg
         below, which is the case that actually ships. */
      background: #000;
      /* Cascade fallback: older browsers get 100vh, newer get 100lvh
         (the largest possible viewport — keeps the bg covering the
         screen even when iOS Safari's URL bar / status bar collapse). */
      min-height: 100vh;
      min-height: 100dvh;
      min-height: 100lvh;
    }
    /* Body keeps the gallery page bg so content below the gradient
       sits on the regular dark surface. */
    body { background: var(--bg); }

    /* 🚨 THE CANVAS WAS A DIFFERENT BLACK FROM THE PAGE (fixed Aug 2 2026).
       The browser paints the CANVAS — rubber-band overscroll, and the band iOS
       exposes when the layout viewport is taller than the window — from
       <html>'s background, never <body>'s. <html> is #000 above to match the
       hero gradient's first stop, but ambient mode hides that gradient, and
       ambient mode is the default. So the canvas painted pure black against a
       #060606 page, and any strip of it read as a black gap rather than as more
       page.

       Measured on the iPad, Aug 2 2026: `canvas bg rgb(0,0,0) / rgb(6,6,6)`.

       ⚠️ The class is put on <html> by setAmbient() in viewer.js specifically
       so this rule can exist — <body> cannot colour the canvas.

       ⚠️ This is NOT known to be the reported iPad top-gap fix. It removes one
       measured mismatch that would make such a band conspicuous; whether the
       band itself is page or browser chrome is still open. Do not record it as
       that bug's fix without a screenshot showing otherwise. */
    html.ambient-bg { background: var(--bg); }

    /* ── CLI text treatment (Jul 24 2026) ───────────────────────────
       Terminal look: ALL interface text is UPPERCASE, with no letter-
       spacing anywhere (every tracked value was reset to normal above).
       Uppercase is set once on <body> and inherits; the opt-outs below
       are the things that must NOT be transformed:
         • artist-authored strings — artwork titles + collection names.
           Uppercasing would rewrite the artist's exact glyphs (and does
           nothing useful for the stylized-unicode ones).
         • wallet addresses — EIP-55 checksum casing is meaningful; upper-
           casing 0xAbC… → 0XABC… discards it.
       Everything else (labels, buttons, pills, ticker, stats, brand,
       loader log) reads as caps. */
    body { text-transform: uppercase; }
    .card-name, .card-platform,
    #fs-name, #fs-collection-badge, #fs-desc,
    #collection-banner-title, #collection-select,
    .src-crow .nm, .src-wallet .addr, #lb-name {
      text-transform: none;
    }

    /* Lock scroll while the loader overlay is on screen — no scrollbar,
       no overscroll, no touch-scroll until hideLoader() removes .loading */
    /* ── Boot gate: background only until the webfonts are ready ─────────
       Without this the loader and its log painted in fallback faces and then
       re-flowed as Geist Mono / BD Alien / Menlo arrived — text appeared at
       the wrong size and metrics for a beat, which is the "it appears and it's
       broken sometimes" flash. `.booting` is set by an inline script in
       <head> (so it lands before first paint) and removed once
       document.fonts.ready settles; only then does loadAll() start.

       ⚠️ opacity — NOT display:none or visibility:hidden. A hidden subtree
       does not trigger webfont loading, so the very fonts being waited on
       would never be requested and document.fonts.ready would resolve
       immediately with nothing loaded. opacity:0 keeps the text laid out and
       the font requests in flight while hiding the flash.

       html gets --bg during boot (it normally carries #000 for the iOS
       overscroll seam) so the transparent body reveals an exactly matching
       colour rather than a slightly darker one. */
    html.booting { background: var(--bg); }
    html.booting body { opacity: 0; }
    body { opacity: 1; transition: opacity .45s ease; }
    @media (prefers-reduced-motion: reduce) { body { transition: none; } }

    html.loading,
    html.loading body {
      overflow: hidden;
      height: 100%;
      touch-action: none;
      overscroll-behavior: none;
    }
    body {
      background: var(--bg);
      color: var(--text);
      /* Broad Unicode fallback chain so artist-styled names (smallcaps,
         math symbols, enclosed glyphs, emoji, regional indicators) all
         render instead of showing ??? / tofu. Inter handles core Latin;
         then we fall through to the platform UI font, then the system
         emoji font (Apple Color Emoji on macOS/iOS, Segoe UI Emoji on
         Windows, Noto Color Emoji on Android), then symbol fonts, and
         finally Google-hosted Noto Sans Symbols 2 + Math as universal
         fallbacks for the exotic stuff. */
      font-family: var(--font-mono);
      font-size: var(--fs-body);
      min-height: 100vh;
      min-height: 100dvh;
      min-height: 100lvh;
      /* clip, not hidden — hidden makes body a scroll container, which
         swallows the toolbar's position:sticky. clip clips horizontal
         overflow without creating a scroll container. (hidden kept as
         cascade fallback for old engines without clip support.) */
      overflow-x: hidden;
      overflow-x: clip;
      /* Safe-area inset for all normal-flow content (gallery, footer).
         Fixed-position overlays handle their own insets since they aren't
         affected by body padding.
         ⚠️ TOP IS DELIBERATELY 0 HERE — the top inset is paid by #toolbar's
         own padding instead (Aug 2026). It used to be on this line, and that
         is what put a band of bare page background ABOVE the sticky toolbar
         on an iPad: body's padding-top is in-flow space that the toolbar sits
         below whenever it is unstuck, and iOS Safari changes the inset's value
         as its chrome collapses and expands. Scrolling back to the top —
         exactly when the chrome re-expands AND the toolbar un-sticks — left an
         empty strip that persisted until something forced a repaint.
         Paid by the toolbar, that band is covered by the toolbar's own plate
         at every scroll position and in either chrome state, so there is
         nothing left for a stale inset to reveal. Don't move it back without
         re-testing on a real iPad in full-screen Safari; the insets are 0 in
         every desktop browser, so this is invisible to local testing. */
      padding:
        0
        env(safe-area-inset-right, 0px)
        env(safe-area-inset-bottom, 0px)
        env(safe-area-inset-left, 0px);
      -webkit-font-smoothing: antialiased;
      /* ONE font, ONE weight — no synthesized bold/italic. Without this,
         stylized-unicode artwork titles that fall back past Geist Mono
         get faux-bolded by the browser and read as a different, heavier
         typeface (the "mix of super bold and thin" problem). */
      font-synthesis: none;
      touch-action: manipulation; /* prevent double-tap zoom */
      -webkit-overflow-scrolling: touch; /* iOS momentum scroll */
      overscroll-behavior-y: none; /* no rubber-band bounce jank */
    }

    /* ── Progress bar ─────────────────────────────────── */
    #progress-bar {
      position: fixed; top: 0; left: 0; height: 2px;
      background: var(--accent); transition: opacity 0.5s;
      /* width is driven frame-by-frame from JS for a smooth easing
         that matches the streaming counter; CSS width transition would
         fight that animation. */
      z-index: 300; pointer-events: none;
    }
    #progress-bar.done { opacity: 0; }

    /* ── Site shell ────────────────────────────────────
       The old floating "app chip" (rounded, blurred, max-width-700px
       sticky bar) is GONE — the site opens on a flat editorial masthead
       that scrolls away with the page; only the flat toolbar strip
       sticks (position:sticky on #toolbar itself). gallery.js still
       toggles .is-floating on scroll — it's a styling no-op now.
       display:contents — a sticky element can never stick past its
       parent's bounds, and this wrapper used to end exactly where the
       toolbar ends; with contents, header + toolbar become direct flow
       children of body and the toolbar can stick for the whole page. */
    #nav-wrapper { display: contents; }

    /* (Masthead band removed Jul 24 2026 — the page now starts at the
       ticker; the brand lives in the toolbar as #toolbar-brand.) */

    /* Avatars fade in only once their image has actually decoded — this
       prevents the browser's broken-image icon from flashing before the
       src is assigned / the (large, inline) GIF finishes decoding. */
    #loader-avatar { opacity: 0; transition: opacity .45s ease; }
    #loader-avatar.avatar-ready { opacity: 1; }
    /* Brand block (avatar + DJKERO / DIGITAL ART) — loader only now; the
       page brand is #toolbar-brand in the top bar (Jul 24 2026). */
    .brand { display: flex; flex-direction: row; align-items: center; gap: 11px; }

    /* ── Live-data ticker (the green banner) ───────────────
       Full-bleed volt ticker under the masthead — the loudest colour moment
       on the page, now carrying LIVE archive data (minted / listed / % —
       filled in by updateStats). The whole bar is a <button> into the
       sources report; hovering pauses the scroll so the numbers are
       readable and darkens the volt for click feedback. Motion off for
       users who ask for reduced motion. */
    #marquee {
      display: block; width: 100%;
      background: var(--accent); color: var(--accent-ink);
      overflow: hidden; position: relative; z-index: 1;
      border: none;
      border-top: 1px solid rgba(0,0,0,.25);
      border-bottom: 1px solid rgba(0,0,0,.25);
      cursor: pointer; font-family: inherit;
      transition: background .15s, box-shadow .15s;
      -webkit-tap-highlight-color: transparent;
    }
    /* Hover: deepen the volt + inset hairline (clear "clickable" feedback)
       and pause the scroll so the live numbers are readable. Gated to real
       pointers — on touch devices tapping the ticker (it scrolls to the
       stats) leaves :hover stuck, which would permanently freeze the scroll
       ("ticker not working on mobile").
       ⚠️ The gate is `any-hover`/`any-pointer`, NOT `hover`/`pointer` — see the
       note above `.card`'s hover block. */
    @media (any-hover: hover) and (any-pointer: fine) {
      /* No colour change on hover — the banner keeps its volt plate and black
         type. The affordance is the TEXT: it scrambles into a repeated
         "JUMP TO SOURCE" (see wireMarqueeHover in gallery.js). */
    }
    #marquee:focus-visible { outline: 2px solid var(--accent-ink); outline-offset: -4px; }
    .marquee-track {
      display: inline-flex; white-space: nowrap; will-change: transform;
      animation: marquee 34s linear infinite;
    }
    /* white-space: pre is load-bearing. Each unit's text ends in " · ", and
       normal whitespace processing STRIPS a trailing space — so unit 1 rendered
       "…VIDEO ·" and unit 2 began "2,929", losing the separator space at the
       loop seam ("…VIDEO ·2,929", and "LIVE DATA ·LIVE DATA" on hover). `pre`
       preserves every space, including the trailing one, so the seam reads the
       same as every other join. Pinned width from fitMarquee; overflow hidden
       so sub-pixel excess can't spill into the next unit. */
    .marquee-unit { display: inline-block; overflow: hidden; white-space: pre; }
    .marquee-track span {
      font-size: var(--fs-small); text-transform: uppercase; letter-spacing: normal;
      padding: 8px 0; flex-shrink: 0;
      font-variant-numeric: tabular-nums;
      /* Explicit, so the banner's height is fixed by the RULE rather than by
         whichever glyphs happen to be on screen. Under line-height:normal the
         line box is sized from the fonts actually used, so the hover scramble
         (which swaps ◉/· for ASCII, and briefly nests per-character spans)
         could resize the bar mid-animation. */
      line-height: 17px;
    }
    @keyframes marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } }
    @media (prefers-reduced-motion: reduce) { .marquee-track { animation: none; } }

    /* ── Toolbar — sticky GLASS filter bar, hairline top + bottom ──
       Translucent + backdrop-blur so the B&W ambient art and the thumbnails
       scrolling underneath frost through it (a "glass" filter bar) rather
       than hiding behind a solid wall. Falls back to a near-solid tint where
       backdrop-filter isn't supported. */
    #toolbar {
      position: sticky; top: 0; z-index: 100;
      background: color-mix(in srgb, var(--bg) 62%, transparent);
      -webkit-backdrop-filter: blur(16px) saturate(1.4);
      backdrop-filter: blur(16px) saturate(1.4);
      border-top: 1px solid var(--border);
      border-bottom: 1px solid var(--border);
      /* ⚠️ The TOP SAFE-AREA INSET LIVES HERE, not on <body> — see the note on
         body's padding for the iPad bug this fixes. The bar's own plate (and
         its blur) therefore extends up through the inset band, so the band is
         never bare page background whether the bar is stuck or in flow.
         ⚠️ min-height CARRIES THE INSET TOO, and that is not belt-and-braces.
         `*` sets border-box globally, so a bare `min-height: 54px` measures the
         padding as part of the 54 — the control row would be squeezed to
         54 - inset and the bar would come out ~10px short of its designed
         height. Adding the inset keeps the CONTENT row at 54px whatever the
         inset is. Both values resolve to the old `padding: 0 16px` /
         `min-height: 54px` wherever the inset is 0, which is every desktop. */
      padding: env(safe-area-inset-top, 0px) 16px 0;
      min-height: calc(54px + env(safe-area-inset-top, 0px));
      display: flex; align-items: center;
      gap: 4px;
      flex-wrap: nowrap; overflow: hidden;
    }
    /* No backdrop-filter support → keep it legible with a denser tint. */
    @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
      #toolbar { background: color-mix(in srgb, var(--bg) 92%, transparent); }
    }
    /* Icon-only back-to-top button (the toolbar wordmark was removed). Same
       footprint/feel as the .tb-control chips so it sits flush in the row. */
    #toolbar-brand {
      display: inline-flex; align-items: center; gap: 9px;
      height: 38px; flex-shrink: 0; margin-right: 2px; padding: 0 4px;
      background: none; border: none;
      cursor: pointer; color: var(--text);
      font-family: inherit; font-size: var(--fs-ui); transition: color .15s;
    }
    #toolbar-brand:hover .tb-brand-name { color: var(--accent); }
    #tb-avatar {
      width: 30px; height: 30px; border-radius: 6px; object-fit: cover;
      border: 1px solid var(--border); flex-shrink: 0;
    }
    .tb-brand-name {
      display: inline-flex; align-items: center;
      font-family: var(--font-brand); font-size: 22px; line-height: 1;
      /* Box centre already matches the avatar's (both 27px); BD Alien's ink
         sits low inside its em box, so the painted wordmark reads 1.4px
         below centre. Optical nudge only — transform, so no reflow. */
      transform: translateY(-1.5px);
    }
    /* Blinking terminal block cursor after the wordmark — the OS/CLI cue. */
    /* Sized and placed to the WORDMARK'S CAPS, not to its em box: BD Alien's
       caps run 20.3→33.5px (13.2 tall) inside a 22px line box, so a 14px block
       centred on the box sat 1.8px high and stopped 1px short of the baseline.
       Height = cap height; the nudge drops it onto the baseline like a real
       terminal caret. Re-measure if the 22px wordmark size changes. */
    .tb-cursor {
      display: inline-block; width: 7px; height: 13.2px; margin-left: 5px;
      background: var(--accent);
      transform: translateY(1.4px);
      animation: tb-blink 1.1s steps(1) infinite;
    }
    @keyframes tb-blink { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }
    @media (prefers-reduced-motion: reduce) { .tb-cursor { animation: none; } }

    /* Each "control" wraps a visual button + an invisible native <select>
       laid over it. Clicking the button area opens the native picker. */
    .tb-control {
      position: relative;
      display: inline-flex; align-items: center;
      gap: 8px;
      padding: 0 13px;
      /* ⚠️ Explicit height, matching #toolbar-brand's 38px. Without it the
         chip shrink-wraps its contents — which is just the icon — so it stood
         only 18px tall inside the 54px bar and the :hover / :focus-within
         plate below rendered as a thin strip floating in the middle of the
         row. Measured before the fix: control height 18px with 18px of dead
         bar above and below it. This is the HOVER TARGET as well as the hover
         plate, so it is a hit-area fix as much as a visual one. */
      height: 38px;
      background: transparent;
      border: none;
      /* Only ever visible as the :hover / :focus-within plate below — the chip
         is transparent at rest — so this is really the hover SHAPE.
         ⚠️ 5px, matched to `.tb-opt` (the dropdown row hover) on purpose: the
         bar and the menu it opens are one interaction, and the plate that
         follows your pointer should not change shape between them. Was 8px,
         which read as noticeably rounder than the menu rows directly beneath
         it. Keep the two in sync — change one, change the other. */
      border-radius: 5px;
      color: var(--sub);
      font-family: inherit; font-size: var(--fs-small);
      text-transform: uppercase; letter-spacing: normal;
      cursor: pointer; user-select: none;
      transition: color .15s, background .15s;
      flex: 0 0 auto; min-width: 0;
    }
    .tb-control:hover, .tb-control:focus-within { color: var(--text); background: var(--hover); }
    /* Filter is active (non-default value): text goes volt, nothing else */
    .tb-control.is-active { color: var(--accent); }
    .tb-control.is-active .tb-icon { color: var(--accent); }

    .tb-icon {
      /* 18px at EVERY width, by request (14 → 16 → 18). It used to be 14px
         here with a 16px mobile override, so icons were bigger on phones than
         on desktop; one size removes that breakpoint-dependent difference.
         ⚠️ On mobile this trades directly against the spacing between icons.
         The row is at capacity there, and
             glyph-to-glyph = (chip + gap) − icon
         with `chip + gap` capped at 36.4 by the width budget (see the mobile
         block). So every +1px of icon is −1px of visible spacing: at 16px the
         gap read 20px, at 18px it reads 18px. Nothing overflows — the chip is
         a fixed-width flex box and the glyph grows inside it — but going
         bigger again keeps eating the spacing, and the only way to buy both
         is to shrink the brand block (each 2px off the 19px wordmark frees
         ~10px, i.e. ~1.7px back). */
      width: 18px; height: 18px; flex-shrink: 0;
      display: inline-flex; align-items: center; justify-content: center;
      color: var(--sub);
      transition: color .15s;
    }
    .tb-control:hover .tb-icon { color: var(--text); }
    .tb-icon svg { width: 100%; height: 100%; }

    .tb-text {
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      max-width: 140px;
      flex: 0 1 auto; min-width: 0;
    }
    /* The collection control no longer prints the artist's name (it shows
       "Selected" — see syncToolbarControl), so the old tight 90px cap that
       existed purely to ellipsise stylized unicode is gone: "All
       collections" now fits without truncation. */
    .tb-caret {
      font-size: var(--fs-micro); line-height: 1; color: var(--sub);
      flex-shrink: 0; margin-left: 2px;
    }
    .tb-control:hover .tb-caret { color: var(--text); }


    /* The Layout control is icon-only: hide its text label across
       all sizes, keep the caret as the dropdown signal. */
    .tb-view-only .tb-text { display: none; }
    .tb-view-only         { padding: 0 8px; }

    /* Invisible native select — fills the control so it receives clicks */
    .tb-native {
      position: absolute; inset: 0;
      width: 100%; height: 100%;
      opacity: 0; cursor: pointer;
      appearance: none; -webkit-appearance: none;
      background: transparent; border: none; outline: none;
      font-size: 16px; /* prevents iOS auto-zoom on focus */
      color: transparent;
    }
    .tb-native option { background: #1c1c1c; color: var(--text); font-size: var(--fs-body); }

    /* ── Custom dropdowns for ALL toolbar selects (Jul 24 2026) ─────
       Native <select> option lists are OS-rendered and ignore our font, so
       the stylized-unicode collection names smeared in the picker. Every
       toolbar control now opens this custom panel instead (one shared
       element, rebuilt per control — see gallery.js). The <select>s stay in
       the DOM for state + keyboard a11y but get pointer-events:none so a
       click opens the panel, not the native list.
       position:fixed because #toolbar is overflow:hidden and would clip an
       absolute panel. */
    .tb-native { pointer-events: none; }
    .tb-panel {
      position: fixed; z-index: 3000; display: none;
      min-width: 180px; max-width: min(380px, 92vw);
      max-height: min(60vh, 460px); overflow-y: auto; -webkit-overflow-scrolling: touch;
      background: #161616; border: 1px solid var(--border); border-radius: 9px;
      box-shadow: 0 14px 44px rgba(0,0,0,.6);
      /* Right padding is WIDER than the other three (6px → 12px) to give the
         scrollbar its own lane. macOS draws overlay scrollbars ON TOP of the
         content box rather than reserving space, so at a uniform 6px the bar
         landed inside .tb-opt's hover wash and rode over the selected row.
         The 3px track now sits in the outer edge of that 12px gutter with
         ~9px of clearance from the option text. */
      padding: 6px 12px 6px 6px;
      /* Thin, dim scrollbar — the Collection panel is the only list long
         enough to scroll, and the default OS bar read as a heavy grey slab.
         Same treatment as .fa-body, two steps thinner: the thumb only
         resolves on hover, so at rest the list edge stays quiet. */
      scrollbar-width: thin;
      scrollbar-color: #2e2e2e transparent;
    }
    .tb-panel::-webkit-scrollbar { width: 3px; }
    .tb-panel::-webkit-scrollbar-track { background: transparent; }
    .tb-panel::-webkit-scrollbar-thumb {
      background: #2e2e2e; border-radius: 2px;
    }
    /* Non-scrolling panels (layout/sort/type/…) don't need the gutter — without
       this they'd sit visibly off-centre against the 6px on the other sides. */
    .tb-panel:not(.tb-panel--art) { padding: 6px; }
    @media (hover: hover) {
      .tb-panel:hover::-webkit-scrollbar-thumb { background: #3a3a3a; }
      .tb-panel::-webkit-scrollbar-thumb:hover { background: var(--accent); }
    }
    .tb-panel.open { display: block; }
    .tb-opt {
      display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
      width: 100%; text-align: left; cursor: pointer;
      background: none; border: none; border-radius: 5px;
      color: var(--text); font-family: var(--font-mono); font-size: var(--fs-ui);
      /* <button> doesn't inherit text-transform, so set it explicitly to keep
         the CLI uppercase (GALLERY / OLDEST / …). Collection names override
         to none below. */
      text-transform: uppercase;
      padding: 8px 10px;
    }
    /* Collection panel ONLY: names are artist strings → render in --font-art
       (the viewer/gallery title font) with combining-mark attachment, and
       NOT uppercased. Plain dropdowns (layout/sort/type/…) keep the mono UI
       font and inherit the uppercase CLI treatment. */
    /* line-height pinned for the same reason as .src-crow .nm — these are raw
       collection names in a baseline-aligned flex row, and a tall fallback
       face on one mark would make that option taller than the rest of the
       list. */
    .tb-panel--art .tb-opt { font-family: var(--font-art-code); text-transform: none; line-height: 1.5; }
    /* "All collections" is UI text, not an artist string — it goes back to the
       mono UI voice and the uppercase CLI treatment every other dropdown uses.
       Only the real collection names below it keep --font-art-code. */
    .tb-panel--art .tb-opt[data-val="all"] {
      font-family: var(--font-mono);
      text-transform: uppercase;
    }
    .tb-opt .opt-nm { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .tb-opt .opt-n  { color: var(--sub); font-variant-numeric: tabular-nums; flex-shrink: 0; }
    /* Volt wash, not grey — matches the page-nav buttons and the rest of the
       hover language. Applies to every dropdown panel, not just Collection:
       they are one component and a second hover colour for the others would
       be the inconsistency this was meant to remove. */
    .tb-opt:hover {
      background: color-mix(in srgb, var(--accent) 12%, transparent);
      color: var(--accent);
      /* The stroke itself is drawn by the `row-stroke-in` animation at the end
         of this file (inset box-shadow, so the box never grows and the rows
         below don't shift). */
    }
    .tb-opt.is-sel { color: var(--accent); }
    .tb-opt.is-sel .opt-n { color: var(--accent); }

    /* The right-most "Sort" control gets pushed to the end */
    #sort-control { margin-left: auto; }

    .tb-sep { display: none; }




        /* ── Skeleton loading cards ──────────────────────────── */
    .skel {
      background: transparent;
      border-radius: 3px; overflow: hidden;
    }
    .skel-thumb {
      aspect-ratio: 1; background: #1c1c1c;
      position: relative; overflow: hidden;
    }
    .skel-body { padding: 10px 12px 12px; }
    .skel-line {
      height: 10px; border-radius: 3px; margin-bottom: 7px;
      background: #222;
      position: relative; overflow: hidden;
    }
    .skel-line.short { width: 55%; }
    .skel-thumb::after, .skel-line::after {
      content: ''; position: absolute; inset: 0;
      background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.07) 50%, transparent 100%);
      transform: translateX(-100%);
      animation: shimmer 1.4s ease-in-out infinite;
    }
    @keyframes shimmer { to { transform: translateX(200%); } }

    /* ── Gallery ──────────────────────────────────────── */
    #gallery {
      /* Bottom padding is tight (32px) now that the stats section follows —
         the old 80px iOS URL-bar buffer is obsolete since the last row of
         cards is no longer the end of the page. */
      padding: 28px 16px 10px;
      display: grid;
      contain: layout;
      grid-template-columns: repeat(auto-fill, minmax(var(--col), 1fr));
      gap: var(--gap);
      /* Above the absolute hero gradient (z-index 0) so cards aren't
         obscured by it. */
      position: relative;
      z-index: 1;
    }
    .vs-spacer { grid-column: 1 / -1; height: 0; }

    /* ── Large view mode ───────────────────────────────
       Editorial layout: bigger cards with a clear visible frame
       around the thumbnail (same card surface as Grid, just sized
       up). Body sits inside the card but with extra breathing room
       so it feels less crammed than Grid. */
    #gallery.view-large {
      grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
      gap: 36px;
    }
    #gallery.view-large .card {
      padding: 0;
      border-radius: 0;
    }
    #gallery.view-large .card-thumb-wrap {
      border-radius: var(--card-radius-tight);
    }
    #gallery.view-large .card-body {
      padding: 16px 0 20px;
    }
    #gallery.view-large .card-name {
      font-size: var(--fs-mid); font-weight: 400;
      margin-bottom: 6px;
    }
    #gallery.view-large .card-platform { font-size: var(--fs-small); }
    #gallery.view-large .card-price    { font-size: var(--fs-small); padding: 3px 8px; }
    /* Large mode inherits the global hover treatment — no override needed. */
    @media (max-width: 680px) {
      #gallery.view-large {
        grid-template-columns: 1fr;
        gap: 28px;
      }
      #gallery.view-large .card-name { font-size: var(--fs-body); }
    }

    /* ── Micro view mode (contact-sheet / super-tiny grid) ──────────────
       Maximum-density thumbnail grid. Each cell is ~88px desktop / ~62px
       mobile (up from 72/50, Jul 26 2026 — still a contact sheet, just
       enough tile to read what a piece actually IS while scanning) —
       caption, platform line and price badge are all hidden so
       the artwork itself does 100% of the talking. Use case: scanning
       the entire portfolio at a glance, jumping straight to the viewer
       on click. Tight 2px gap, no card chrome, square aspect. */
    #gallery.view-micro {
      grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
      gap: 2px;
      padding: var(--gutter);
    }
    #gallery.view-micro .card {
      padding: 0;
      border-radius: var(--card-radius-tight);
      background: transparent;
    }
    #gallery.view-micro .card-thumb-wrap {
      border-radius: var(--card-radius-tight);
      aspect-ratio: 1/1;
    }
    /* No body content — just the thumb. */
    #gallery.view-micro .card-body { display: none; }
    /* In-thumb overlays (play icon, interactive icon) shrink to a dot
       so they don't visually dominate a 72px tile. */
    #gallery.view-micro .card-thumb-wrap > .icon-overlay,
    #gallery.view-micro .card-play-icon,
    #gallery.view-micro .card-interactive-icon {
      transform: scale(0.55);
      transform-origin: bottom right;
    }
    /* Restrained hover — the dramatic translateY + scale + amber ring
       used in Gallery/Grid would shove neighbours around at this density.
       Just a small lift and an inner amber outline. */
    @media (any-hover: hover) and (any-pointer: fine) {
      /* Stroke comes from .card::after now (same as every other view) — this
         used to paint its own inset shadow, which double-struck micro cards. */
      #gallery.view-micro .card:hover {
        transform: none;
        background: transparent;
        z-index: 2;
      }
    }
    @media (max-width: 680px) {
      #gallery.view-micro {
        grid-template-columns: repeat(auto-fill, minmax(62px, 1fr));
        gap: 2px;
      }
    }

    /* ── Gallery view mode (masonry / packed columns) ───────────────────
       Pinterest-style packing. Each card uses the artwork's own aspect, so
       the layout looks organic — closer to a Tetris wall than a uniform grid.

       🚨 GRID + ROW SPANS, NOT `column-count`. This was CSS multi-column,
       which packs beautifully and fills COLUMN BY COLUMN: item 2 lands under
       item 1, not beside it, so the newest work read top-to-bottom in the
       left-hand column and the eye had no row to follow ("it doesn't feel
       organized", Aug 2 2026). Multi-column has no knob for this — fill order
       is the whole point of the algorithm.
       The replacement keeps the packing and fixes the order: a plain grid
       where every card spans as many 1px rows as it is tall, so cards sit
       left→right in source order and still stack tightly with no gaps.
       ⚠️ `grid-auto-rows: 1px` + `row-gap: 0` is what makes the span a plain
       PIXEL HEIGHT — the span is set by masonrySpans() in gallery.js as
       `ceil(height + margin-bottom)`. Change either value and that arithmetic
       is wrong; the vertical gutter must stay on the card's margin, not on
       row-gap, or every card gains a phantom gap per row it spans. */
    #gallery.view-gallery {
      display: grid;
      grid-template-columns: repeat(4, minmax(0, 1fr));
      grid-auto-rows: 1px;
      row-gap: 0;
      column-gap: 30px;
      padding: var(--gutter);
    }
    #gallery.view-gallery .vs-spacer { display: none; }
    #gallery.view-gallery .card {
      /* ⚠️ `align-self: start` is load-bearing: without it a grid item
         STRETCHES to fill its span, so measuring the card returns the span it
         was already given and the height feeds back on itself. */
      align-self: start;
      display: block;
      width: 100%;
      margin: 0 0 30px;
      break-inside: avoid;
      -webkit-column-break-inside: avoid;
      page-break-inside: avoid;
      padding: 0;
      /* Inline-block items default to baseline alignment — that adds
         a few pixels of phantom descender space above the first card,
         making Gallery mode start slightly lower than Grid mode.
         Top-aligning kills that gap so both modes start at the same
         vertical position. */
      vertical-align: top;
      /* Rounder than the shared 10px. Feed cards are by far the largest on the
         site (4 columns at most widths, so ~350px wide), and a radius that
         reads as "soft" on a 120px Mosaic tile reads as nearly square at this
         size — corner roundness is perceived relative to the box. Set on .card
         as well as the thumb because .card::after (the volt hover stroke) takes
         `border-radius: inherit`; leaving .card at a different value would
         draw a mismatched stroke around the thumbnail and it shows at the
         corners — these two must always be set to the same number. */
      border-radius: var(--card-radius);
    }
    #gallery.view-gallery .card-thumb-wrap {
      /* The artwork's own ratio, written as --card-ar by applyThumbRatio in
         gallery.js once the thumbnail decodes; `auto` until then, and for the
         placeholders that never get one. Dense and Large deliberately do NOT
         read this var — they are square by design. */
      aspect-ratio: var(--card-ar, auto);
      /* Small min-height so loading thumbs have a visible area for
         the spinner — but small enough that most landscape artworks
         naturally exceed it and aren't padded out. Placeholder cards
         (no real img) get a square footprint from a more specific
         rule below. */
      min-height: 90px;
      background: #161616;
      border-radius: var(--card-radius);
    }
    /* ⚠️ THE BOX TAKES THE ARTWORK'S RATIO; THE IMAGE FILLS THE BOX.
       This is the base .card-thumb-wrap / .card-thumb pattern, not a special
       case — the only difference is that the ratio comes from the artwork
       (applyThumbRatio in gallery.js writes it inline on load) instead of
       being a hard 1/1.

       Two earlier versions each fixed one half and broke the other. Letting
       the IMAGE set the height (`height: auto`) with `object-fit: contain`
       fitted tall portraits inside a full-width box and left grey bars either
       side. Switching that to `cover` killed the bars but kept `height: auto`
       and a flat `max-height: 480px` — so EVERY artwork taller than the cap
       rendered at exactly 480px, cropped, and all the tall pieces came out the
       same height regardless of their real proportions.

       Ratio on the box fixes both at once: within the clamp the box IS the
       artwork's shape, so `cover` crops nothing and there is nothing to bar;
       past the clamp only genuinely extreme panoramas crop, and they still
       differ from each other up to the limit. See CARD_AR_MIN/MAX in
       gallery.js for the clamp itself. */
    #gallery.view-gallery .card-thumb {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      max-height: none;
      object-fit: cover;
      display: block;
    }
    /* Placeholders (no real image) keep a square footprint so the
       column doesn't collapse to nothing. */
    #gallery.view-gallery .card-thumb-wrap.card-video-placeholder,
    #gallery.view-gallery .card-thumb-wrap.card-interactive-placeholder {
      aspect-ratio: 1/1;
      min-height: 0;
    }
    /* Only the COLUMN COUNT and the outer padding change with width — card
       spacing, thumb min-height and the max-height cap stay identical at every
       size, so crossing a breakpoint doesn't change the layout's character. */
    @media (max-width: 1200px) { #gallery.view-gallery { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
    @media (max-width: 800px)  { #gallery.view-gallery { grid-template-columns: repeat(1, minmax(0, 1fr)); } }
    /* Skeletons + scroll sentinel need to behave like cards in column flow */
    #gallery.view-gallery .skel {
      display: inline-block;
      width: 100%;
      margin: 0 0 26px;
      break-inside: avoid;
    }
    /* Gallery cards are the largest in the grid, so their caption carries a
       size above the site's single --fs-ui — the other views stay at it. */
    /* margin-bottom 6 → 0: at 26px the name's own leading (line-height normal
       ≈ 1.52 on the art font, so ~7px of empty box below the glyphs) already
       separates the two lines, and the base 6px on top of it read as a gap.
       Only the margin is touched — .card-platform's `line-height: 1.7` and
       `padding: 2px 0`, and .card-name's `normal` leading, are what keep
       `contain: paint` from clipping zalgo combining marks, so tightening
       those instead would cut the marks off. */
    #gallery.view-gallery .card-name     {
      /* 26 → 24px (Aug 2026, by request). line-height stays 1.5, so the box
         goes 39px → 36px — the ~3px of HEIGHT that was asked for, taken out of
         the type rather than out of the leading. Tightening line-height
         instead would have hit the mark-tops headroom the note below is
         entirely about. */
      font-size: 24px; margin-bottom: 0;
      /* ONE line, ellipsised — by request, titles must never take a second
         line. (A 2-line clamp was tried and rejected.) The cost is truncation:
         with --font-art-code's mono advance, long titles hit the "…" sooner.
         Accepted deliberately — uniform card height beats whole titles.

         ⚠️ line-height is EXPLICIT, and must stay that way. With `normal`,
         the line box is sized from the metrics of whichever font actually
         shapes each run — and when a combining mark falls back to a different
         face, that face's ascent/descent govern, so one mark-heavy title
         silently inflates its row and shoves the layout around. `contain:
         paint` does NOT help: it clips the painting, not the box. 1.5 keeps
         the same box height the old `normal` produced (39px at 26px), so
         mark tops still have headroom — this pins the height, it doesn't
         tighten it. Same reasoning as .card-platform's 1.7 and
         #fs-collection-badge's 1.3. */
      line-height: 1.5;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      padding-top: .1em;
    }
    /* 15 → 13px: 1.7 line-height puts the box at 22.1px, from 25.5px. 13px is
       --fs-ui, the site's single interface size, so the collection line now
       sits exactly on it instead of a size of its own. */
    #gallery.view-gallery .card-platform { font-size: 13px; }

    #gallery.view-gallery #scroll-sentinel {
      display: block;
      width: 100%;
      grid-column: auto;
    }

    /* ── .is-few: few results → lay out as a real grid, not multicol ──────
       Set by fitColumns() when a filter returns fewer items than the view has
       columns (a 3-artwork collection in 7-column Mosaic).

       ⚠️ The ONLY thing that changes is the FILL DIRECTION. The track count is
       the view's normal column count (--few-cols), so the cards keep exactly
       the width they have with a full catalogue and 3 results sit left-to-right
       across the first row. Do NOT set the track count to the item count —
       that was tried and it stretched 1 result into a single full-width
       thumbnail and 2 results into half-width ones.

       Two earlier attempts failed, so don't "simplify" back to them:
       1. Capping column-count to the item count — balancing looks for the
          shortest height that fits in AT MOST N columns, so it still stacked
          2 cards in column 1 and left the rest empty.
       2. Adding `break-after: column` to the cards — the cards are
          `display: inline-block`, i.e. inline-level, and column breaks only
          apply to block-level boxes. Silently ignored.
       An explicit grid with exactly one track per item is the only version
       that actually guarantees one card per column.

       `column-gap` is shared between multicol and grid, so each view's own
       gap (30px feed / 6px mosaic) carries over untouched.

       ⚠️ The selectors name both views on purpose. `#gallery.is-few` alone is
       specificity (1,1,0) — exactly the same as `#gallery.view-mosaic`, which
       is declared later in this file and therefore won: mosaic stayed
       `display: block` while feed worked. Naming the view makes it (1,2,0),
       so it no longer depends on source order. It also encodes that Micro is
       excluded — that view is already a grid with working auto-fill. */
    #gallery.view-gallery.is-few,
    #gallery.view-mosaic.is-few {
      display: grid;
      grid-template-columns: repeat(var(--few-cols, 1), minmax(0, 1fr));
      align-items: start;
    }
    /* Cards are inline-block for multicol flow; as grid items they must be
       plain blocks, and their column-flow bottom margin would add a phantom
       row gap. Same specificity story — must out-rank `#gallery.view-* .card`. */
    #gallery.view-gallery.is-few .card,
    #gallery.view-mosaic.is-few .card { display: block; width: 100%; margin: 0; }
    #gallery.view-gallery.is-few #scroll-sentinel,
    #gallery.view-mosaic.is-few #scroll-sentinel { grid-column: 1 / -1; }

    /* ── Mosaic view mode (dense masonry) ───────────────────────────────
       Same masonry packing as Gallery, but with smaller thumbnails so
       you can fit roughly twice as many pieces on screen. Captions /
       prices / platform line are kept (just smaller) so it still reads
       as a portfolio rather than a contact sheet (that's Micro). */
    /* Grid + row spans, exactly like .view-gallery above — see the long note
       there for why multi-column had to go and what the 1px rows mean. */
    #gallery.view-mosaic {
      display: grid;
      grid-template-columns: repeat(7, minmax(0, 1fr));
      grid-auto-rows: 1px;
      row-gap: 0;
      column-gap: 6px;
      padding: var(--gutter);
    }
    #gallery.view-mosaic .vs-spacer { display: none; }
    #gallery.view-mosaic .card {
      align-self: start;
      display: block;
      width: 100%;
      margin: 0 0 6px;
      padding: 0;
      /* 10 → 14px. NOT a proportional copy of feed's 18px — at 197px wide,
         mosaic's old 10px was already the same *ratio* as feed's 18px on a
         328px card (5.1% vs 5.5%), so matching the ratio would have changed
         nothing visible. 14px (7.1%) is deliberately above proportional so it
         reads as rounder at this size. Kept below feed's ratio-equivalent
         ceiling because mosaic thumbs are `aspect-ratio: auto` and some run
         short and wide — a larger radius starts eating the artwork itself.
         On .card as well as the thumb because .card::after inherits it. */
      border-radius: var(--card-radius);
      vertical-align: top;
    }
    #gallery.view-mosaic .card-thumb-wrap {
      /* The artwork's own ratio, written as --card-ar by applyThumbRatio in
         gallery.js once the thumbnail decodes; `auto` until then, and for the
         placeholders that never get one. Dense and Large deliberately do NOT
         read this var — they are square by design. */
      aspect-ratio: var(--card-ar, auto);
      min-height: 50px;
      background: #161616;
      border-radius: var(--card-radius);
    }
    /* Same box-takes-the-ratio pattern as Wide — see the long note on
       `#gallery.view-gallery .card-thumb`. Mosaic had the identical bug with a
       220px cap instead of 480px, and being the denser grid it showed it more:
       a whole column of tall pieces all landing on 220px is the flat band the
       report describes. */
    #gallery.view-mosaic .card-thumb {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      max-height: none;
      object-fit: cover;
      display: block;
    }
    #gallery.view-mosaic .card-thumb-wrap.card-video-placeholder,
    #gallery.view-mosaic .card-thumb-wrap.card-interactive-placeholder {
      aspect-ratio: 1/1;
      min-height: 0;
    }
    #gallery.view-mosaic .card-body    { padding: 8px 12px; }
    /* Same treatment as feed, scaled: 13 → 15px, gap closed, one ellipsised
       line. Mosaic's job is density, so the size step is smaller than feed's
       19 → 26px, and a single line matters more here than anywhere — these
       rows are what set the packing rhythm. line-height explicit for the
       fallback-metrics reason spelled out on the feed rule above. */
    #gallery.view-mosaic .card-name    {
      font-size: 15px; font-weight: 400; margin-bottom: 0;
      line-height: 1.5;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      padding-top: .1em;
    }
    #gallery.view-mosaic .card-platform{ font-size: var(--fs-micro); }
    #gallery.view-mosaic .card-price   { font-size: var(--fs-micro); padding: 1px 4px; }
    #gallery.view-mosaic .skel {
      display: inline-block;
      width: 100%;
      margin: 0 0 6px;
      break-inside: avoid;
    }
    #gallery.view-mosaic #scroll-sentinel {
      display: block;
      width: 100%;
      grid-column: auto;
    }
    @media (max-width: 1200px) { #gallery.view-mosaic { grid-template-columns: repeat(5, minmax(0, 1fr)); } }
    @media (max-width: 800px)  { #gallery.view-mosaic { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
    @media (max-width: 680px)  {
      #gallery.view-mosaic { padding: 16px 8px 16px; grid-template-columns: repeat(4, minmax(0, 1fr)); column-gap: 4px; }
      #gallery.view-mosaic .card { margin-bottom: 4px; padding: 3px 3px 0; }
      /* ⚠️ NO `max-height` HERE. There was one (140px), left over from when the
         IMAGE set the tile's height — and once the BOX started carrying the
         artwork's aspect ratio it became actively wrong: the wrap sized itself
         to a long artwork while the img inside it was still clamped to 140px,
         so the wrap's own #161616 showed underneath as a grey band. Mobile
         only, long images only, which is exactly how it was reported.
         A pixel cap cannot coexist with a ratio-sized box. If phone tiles ever
         need to be shorter, the knob is CARD_AR_MIN in gallery.js — that caps
         the SHAPE, so the image still fills whatever box it produces. */
    }
    @media (max-width: 480px)  { #gallery.view-mosaic { grid-template-columns: repeat(3, minmax(0, 1fr)); } }

    /* ── Card ─────────────────────────────────────────── */
    /* Frameless: no card box at all — the artwork IS the card. Caption
       sits under it like a gallery label; hover = volt ring on the
       artwork + volt caption, no motion. */
    .card {
      background: transparent;
      border: none;
      position: relative;          /* containing block for the .card::after stroke */
      contain: layout style;
      /* Soft corners on the card itself — invisible at rest (transparent),
         it's the volt hover plate that reads as a rounded tile. */
      border-radius: var(--card-radius);
      overflow: visible; cursor: pointer;
      padding: 0;
    }
    /* Hover effect is gated to devices with a real fine pointer (mouse,
       trackpad) AND hover capability. Otherwise iPad / iPhone touch
       would briefly "hover" on tap and shove the card around.

       ⚠️ The gate must be `any-hover`/`any-pointer`, never `hover`/`pointer`.
       Those two report the PRIMARY pointer only, and on iPadOS the primary
       pointer is always touch — plug in a Magic Keyboard / trackpad and Safari
       still answers `hover: none` + `pointer: coarse`, so every hover effect on
       the site stayed dead while pointer-agnostic effects (the data-shuffle
       scrambles, driven by scroll/observers) kept working. `any-*` reports the
       union of ALL attached pointers, so the trackpad lights hover up while a
       bare iPad/iPhone still matches `any-hover: none` and is unaffected. */
    @media (any-hover: hover) and (any-pointer: fine) {
      .card:hover { z-index: 2; }
      /* Whole card goes volt — but the green is painted ON the artwork, not
         behind it. Filling .card's background and bleeding it with a spread
         shadow (tried first) puts the colour BEHIND an opaque thumbnail, so
         all you see is a halo poking out around the edges: it reads as a
         second, misregistered layer rather than the card being highlighted.

         Instead the v9 overlay comes back — .card-thumb-wrap::after with
         mix-blend-mode: color, which re-tints the artwork while keeping its
         luminance, so detail survives — and the caption fills flat volt.
         Together the card reads as one solid green object with no depth
         artefacts and no layout change. */
      /* Hover = a volt stroke around the WHOLE CARD. No fill: a green stroke
         on a green plate is invisible, so the plate and its black-text
         inversion go with it. The artwork is never tinted or covered.
         The stroke draws in with a slight overshoot and then holds — no
         opacity pulse. box-shadow (not a conic-gradient border) because it
         needs no @property registration or mask-composite to work. */
      .card:hover::after { animation: card-stroke-in .42s ease-out forwards; }
      .card:hover .card-name { color: var(--accent); }
      /* Collection sits well back at rest and lifts to a brighter GREY on
         hover — not white. Same reasoning as the rest state: this line is
         secondary to the title, and pure white put it level with the name. */
      .card:hover .card-platform { opacity: 1; color: #b0b0b0; }
    }
    /* ── Hover: promote to GPU layer only at hover time ─────────── */
    @media (any-hover: hover) {
      .card:hover { will-change: transform; }
    }
    /* ── Touch-only: kill all hover transitions (no pointer attached at all,
       so no hover events anyway). A trackpad-equipped iPad skips this. */
    @media (any-hover: none) {
      .card { transition: none; }
      .card:hover { transform: none; background: transparent; }
    }
    /* listed cards: same border as regular cards, price badge does the visual work */
    .card-price {
      font-size: var(--fs-micro); font-weight: 400;
      color: var(--accent);
      background: color-mix(in srgb, var(--accent) 8%, transparent);
      padding: 2px 6px; border-radius: 4px;
      white-space: nowrap;
    }

    .card-thumb-wrap {
      position: relative; width: 100%; aspect-ratio: 1/1;
      overflow: hidden; background: #161616;
      border-radius: var(--card-radius);
    }
    /* The hover stroke is drawn by a pseudo-element on .card, NOT by an inset
       shadow on .card itself: an inset shadow paints above the element's
       background but BELOW its children, so .card-thumb-wrap's own background
       would hide it. Absolute + a high z-index puts it over the artwork and
       the caption alike. Only a shadow is drawn, so it's invisible at rest,
       and the 0 → 2px spread grows the frame edge-inward. */
    .card::after {
      content: ''; position: absolute; inset: 0; z-index: 5;
      pointer-events: none;
      border-radius: inherit;
      box-shadow: inset 0 0 0 0 var(--accent);
      transition: box-shadow .22s ease;   /* the retract, on mouse-out */
    }
    /* ── The volt hover stroke: 6px, and genuinely springy ────────────────
       Settles at 6px, up from 4px (Aug 2026, "2 pixels thicker").

       THREE stages, not two, and that is what makes it feel elastic rather
       than merely fast. The old curve was a single overshoot (0 → 6 → settle
       4): the stroke only ever grew, so the "spring" was one soft landing.
       A real spring crosses its resting value more than once, so this now
       overshoots WELL past the target (10px), pulls back UNDER it (5px), then
       settles (6px). The stroke grows inward from the card edge, so those
       stages read as the frame punching in, rebounding, and coming to rest.

       Amplitudes are deliberately asymmetric — +4 over, then −1 under. A
       symmetric bounce reads as a wobble/mistake at this size; a big first
       swing with a small correction reads as weight. Keep the last two stops
       close together if you retune, or the frame visibly flickers thin.
       ease-out on the whole thing, so each stage decelerates into the next. */
    @keyframes card-stroke-in {
      0%   { box-shadow: inset 0 0 0 0    var(--accent); }
      45%  { box-shadow: inset 0 0 0 10px var(--accent); }
      72%  { box-shadow: inset 0 0 0 5px  var(--accent); }
      100% { box-shadow: inset 0 0 0 6px  var(--accent); }
    }
    @media (any-hover: hover) and (any-pointer: fine) {
      .card:hover::after { animation: card-stroke-in .42s ease-out forwards; }
    }
    @media (prefers-reduced-motion: reduce) {
      .card:hover::after { animation: none; box-shadow: inset 0 0 0 6px var(--accent); }
    }

    /* ── Type + chain pills (shared: gallery cards + fullscreen viewer) ──
       Both chips use ONE identical pill so the media-type and source-chain
       tags read as a matched set. The type pill adds an icon; a subtle
       per-type colour lives on that icon only, keeping the chrome identical. */
    .type-badge, .chain-badge {
      display: inline-flex; align-items: center; flex-shrink: 0;
      /* Explicit height (not padding-driven) — the type pill's 12px icon
         used to make it taller than the icon-less chain pill despite
         identical padding. Fixed height guarantees both are pixel-identical
         regardless of content. */
      height: 26px; padding: 0 12px;
      font-size: var(--fs-micro); font-weight: 400; letter-spacing: normal; text-transform: uppercase;
      line-height: 1; white-space: nowrap; color: rgba(255, 255, 255, 0.9);
      background: rgba(0, 0, 0, 0.55);
      border: 1px solid rgba(255, 255, 255, 0.18);
      /* Softly rounded, not a capsule — matches .card-price (4px) and the
         viewer's own .fs-link-btn, so the chrome shares one corner language
         instead of mixing capsules with rounded rectangles. */
      border-radius: 4px;
      -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
    }
    .type-badge { gap: 5px; }
    .type-badge svg { width: 12px; height: 12px; flex-shrink: 0; display: block; color: #fff; }
    /* Subtle per-type accent — icon colour only, so both pills stay identical. */
    /* Per-type icon tints neutralised for the single-accent system —
       restore the old per-type colours here if type-at-a-glance is missed
       (was: image #7fb4ff, gif #c996f5, video #ff9d6b, interactive #55d6b5). */
    .type-badge--image svg, .type-badge--gif svg,
    .type-badge--video svg, .type-badge--interactive svg { color: rgba(255,255,255,.85); }

    /* Card overlays — media type top-left, source chain top-right.
       HIDDEN for now (Jul 20 2026, by request — the artwork does the
       talking; type/chain live in the viewer). Same "hide via CSS, keep
       markup + JS" pattern as the mute button — delete the display:none
       line to bring the pills back. */
    .card-type-badge, .card-chain-badge { display: none !important; }
    .card-type-badge  { position: absolute; top: 8px; left: 8px;  z-index: 3; pointer-events: none; }
    .card-chain-badge { position: absolute; top: 8px; right: 8px; z-index: 3; pointer-events: none; }
    /* Dense modes: the chips would just be noise on tiny tiles. */
    #gallery.view-mosaic .card-type-badge,
    #gallery.view-mosaic .card-chain-badge,
    #gallery.view-micro  .card-type-badge,
    #gallery.view-micro  .card-chain-badge { display: none; }
    /* Grid mode keeps square uniform tiles — nudge the chips in a touch. */
    #gallery.view-grid .card-type-badge  { top: 7px; left: 7px; }
    #gallery.view-grid .card-chain-badge { top: 7px; right: 7px; }

    /* ── Recently minted: the artwork's NAME goes volt ──────────────────
       The newest RECENT_LIMIT pieces (isRecentToken in data.js — the same list
       the sync terminal's `mints` block prints). `.card--recent` is set by
       buildCard.

       🗑 FOUR MARKERS CAME BEFORE THIS AND ALL WERE REJECTED. Recorded so none
       of them gets re-proposed:
         • a solid volt "NEW" pill — the loudest thing on the page; it
           out-shouted the artwork it was labelling.
         • a bare round volt dot, no word — VANISHES over bright pieces (checked
           on `GRDDY`, where it lands in green glitch and reads as part of the
           picture), and is mute anyway: volt already means "listed" on
           .card-price and "live" in the terminal.
         • a black-glass chip with a volt dot and the word — calm and legible,
           and still a LABEL STUCK ONTO the artwork.
         • a 1.5px volt stroke on the thumbnail — no longer a sticker, but it
           frames the piece, and on the bright green works (`∫☾☈⊙↳↳↳☈`,
           `GRDDY`) it half-dissolved into the art it was outlining.
       Colouring the TITLE lands where none of those could: it never touches the
       artwork, it costs no element and no geometry, and one green caption in a
       column of white ones is legible at any tile size the caption itself is
       legible at. Two rejected alternatives from the same test, for the record:
       a volt underline COLLIDES with the stacked combining marks these titles
       carry (`GЧAZ≡` was unreadable), and a leading volt bullet was too faint
       to register at caption size.

       ⚠️ THIS DELIBERATELY COLLIDES WITH THE HOVER COLOUR, and the collision is
       acceptable rather than overlooked. `.card:hover .card-name` is also volt
       (see the hover block above), so on these six cards the title does not
       change on hover — but the 6px volt stroke around the whole card and the
       .card-platform lift both still fire, so hover is in no way unsignalled.
       Do NOT "fix" this by inverting the hovered title to white: that makes the
       newest work the one set of cards that behaves backwards.

       ⚠️ DENSE MODE MARKS NOTHING, and that is correct. #gallery.view-micro
       hides the caption, the platform line and the price outright — "the
       artwork itself does 100% of the talking" — so there is no text to colour,
       and re-adding a thumbnail marker for that one mode would reintroduce the
       stroke this replaced. A contact sheet is for scanning shapes. */
    .card--recent .card-name { color: var(--accent); }

    .card-thumb {
      position: absolute; inset: 0;
      /* `cover`, not `contain` (changed Jul 30 2026). The wrap is a fixed
         square here (aspect-ratio 1/1, Grid and Micro), so `contain` fitted a
         non-square artwork inside it and left the wrap's own #161616 showing
         as grey bars down the sides or across the top — on a wall of tiles
         those bars read as a rendering fault, not as letterboxing. `cover`
         fills the tile and crops the overflow instead. The full, uncropped
         piece is always one click away in the viewer, which is where seeing
         all of it actually matters. */
      width: 100%; height: 100%; object-fit: cover;
      /* Plain cross-fade — NO blur, no scale. The artwork simply comes up over
         the volt plate behind it (see .card-thumb-wrap below), which is what
         carries the loading state now. Blur was tried and dropped: on a wall
         of thumbnails it read as smeared rather than loading. */
      opacity: 0;
      transition: opacity .4s ease;
    }
    /* ⚠️ No `will-change` here on purpose. Hundreds of cards are mounted at
       once; promoting every thumbnail to its own compositor layer costs far
       more than the transition it would smooth. */
    .card-thumb.visible { opacity: 1; }
    @media (prefers-reduced-motion: reduce) {
      .card-thumb { transition: opacity .2s ease; }
    }
    .card-thumb.error   { display: none; }


    /* The waiting plate is DARK GREY (#161616) — it comes from
       `.card-thumb-wrap`'s own background and each `#gallery.view-*`
       override, so there is deliberately no extra rule here. The artwork
       cross-fades over the top and the plate is simply covered: no blur, no
       transform, no animation.
       Two accent versions were tried and dropped (Jul 26 2026, by request):
       accent-at-14% with a `thumb-wait` shimmer sweep across it, then a flat
       full-strength volt block. Both read as too loud with 48 tiles waiting
       at once. If either is ever revived, note the selector needs a
       `#gallery` prefix — the per-view `#gallery.view-* .card-thumb-wrap
       { background: #161616 }` rules carry an id and beat any class-only
       selector no matter how many :not()/:has() clauses it has. */

    /* spinner */
    .card-spinner {
      position: absolute; top: 50%; left: 50%;
      width: 18px; height: 18px;
      border: 1.5px solid var(--border);
      border-top-color: var(--sub);
      border-radius: 50%;
      animation: spin 0.75s linear infinite;
      pointer-events: none;
    }
    @keyframes spin {
      from { transform: translate(-50%, -50%) rotate(0deg); }
      to   { transform: translate(-50%, -50%) rotate(360deg); }
    }
    .card-thumb.visible + .card-spinner,
    .card-thumb.error   + .card-spinner { display: none; }

    /* GIF canvas-freeze: img is always invisible; canvas is the visible surface */
    .card-thumb--hidden,
    .card-thumb--gif-ready { opacity: 0 !important; }
    /* On hover the playing class is added and canvas fades; img becomes visible */
    .card-thumb--playing   { opacity: 1 !important; transition: opacity .15s; }

    /* Canvas overlay — sized by JS; CSS stretches it to fill the thumb wrap */
    .card-gif-freeze {
      position: absolute; inset: 0;
      width: 100%; height: 100%;
      pointer-events: none;
      transition: opacity .15s;
      will-change: opacity; /* only this element; removed after transition */
    }


    /* broken image icon */
    .card-broken {
      display: none;
      position: absolute; inset: 0;
      align-items: center; justify-content: center;
      background: #1a1a1a;
    }
    .card-thumb.error ~ .card-broken { display: flex; }
    .card-broken svg {
      width: 32px; height: 32px;
      color: var(--sub);
      opacity: 0.5;
    }

    .card-body {
      /* Wider left/right than top/bottom, by request — the caption reads better
         with the text pulled in from the card edges. (It was uniform 10px
         before that, itself a fix for an earlier 12/10/18 that was inconsistent
         the other way round.) */
      padding: 10px 16px;
      transition: background .15s ease, box-shadow .15s ease;
    }
    /* ── Artist-authored text surfaces ──────────────────────────────
       Titles use --font-art-code (see :root) — Chrome DevTools' code-view
       font, chosen so every combining mark renders as a distinct glyph on
       Kero's stylized titles. It REPLACED --font-art, whose stack was ordered
       by combining-mark ATTACHMENT (Zalgo shaping the way Work Sans does it
       on verse.works); read both :root notes before changing this.
       The text itself is raw/unsanitised, matching Verse.

       `contain: paint` is our ONE deviation from Verse. Their own series
       page shows why: an un-contained 139-mark tower in the <h1> paints
       straight down over the description text beside it. Clipping each
       title to its own box costs nothing visually (the glyphs still render
       identically) and keeps a Zalgo stack from overlapping neighbouring
       cards in a grid that's far denser than theirs. */
    /* ⚠️ NEITHER #fs-name NOR .card-name is in this list any more — the artwork
       TITLE, in both places it appears, has its own rule further down (search
       "transient.xyz treatment"). What is left here is everything that is not
       an artwork title: the platform label, the collection name beside the
       title, the collection banner and the collection picker. Those keep Menlo
       deliberately — the request was to change the artwork name only. */
    .card-platform, #fs-collection-badge,
    #collection-banner-title, .src-crow .nm, #collection-select {
      font-family: var(--font-art-code);
    }
    /* The artwork title wherever it appears — gallery caption and viewer — so
       the two cannot drift apart. Only the FAMILY is shared; the sizes and the
       tracking are per-context, see the #fs-name rule. */
    .card-name { font-family: 'Geist', var(--font-art); }
    /* ⚠️ NO negative tracking here, unlike #fs-name, and that is deliberate
       rather than an omission. -0.02em is a DISPLAY-size correction: it closes
       up 40–88px text that would otherwise read loose. At caption size it just
       cramps the letters and costs legibility, which is why transient itself
       tracks its 52px h1 at -1.04px and leaves its 16px text at `normal`. */
    .card-name.has-marks { font-family: var(--font-art); }
    .card-name, .card-platform, .src-crow .nm, #fs-collection-badge {
      contain: paint;
    }
    .card-name {
      font-size: var(--fs-body); font-weight: 400;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      /* Explicit, never `normal` — a combining mark that falls back to a
         tall-metric face would otherwise size the line box and make one
         card's caption taller than its neighbours'. See the feed rule. */
      line-height: 1.5;
      margin-bottom: 6px;
      transition: color .15s ease;
    }
    .card-row { display: flex; align-items: center; justify-content: space-between; }
    /* line-height 1.7 + breathing padding: collection names stack combining
       marks above/below the x-height; at default leading they bled into the
       rows around them (contain:paint then CUT them — either way broken).
       The taller box lets the marks render inside the clipped area. */
    .card-platform {
      /* A real grey, not a dimmed colour. This was var(--sub) at opacity .42,
         which composites to roughly #3d3d3d on the page background — the
         "white with opacity" look. Stating the grey directly makes the value
         legible in the source and predictable over any backdrop (opacity also
         faded the text against the volt hover plate, where the composite
         changes completely). */
      font-size: var(--fs-small); color: #6f6f6f; opacity: 1;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      line-height: 1.7; padding: 2px 0;
      transition: color .15s ease;
    }

    /* ── Skeletons ────────────────────────────────────── */
    @keyframes shimmer {
      0%   { background-position: 200% 0; }
      100% { background-position: -200% 0; }
    }

    /* ── Video placeholder (when no static thumb available) ── */
    .card-video-placeholder {
      display: flex; align-items: center; justify-content: center;
      background: #1a1a1a;
    }
    .card-video-placeholder svg {
      width: 32px; height: 32px;
      color: var(--sub); opacity: 0.5;
    }

    /* ── Interactive/generative art placeholder ── */
    .card-interactive-placeholder {
      display: flex; align-items: center; justify-content: center;
      background: #1a1a1a;
    }
    .card-interactive-placeholder svg {
      width: 32px; height: 32px;
      color: var(--sub); opacity: 0.5;
    }

    /* ── Collection banner ────────────────────────────────
       Shown above the gallery when a specific collection is picked.
       Centered hero block: eyebrow, title, meta stacked + horizontally
       centered, so it reads as a section header rather than a list
       item. */
    #collection-banner {
      position: relative;
      z-index: 1;
      padding: 48px var(--gutter) var(--gutter);
      text-align: center;
      max-width: 100%;
    }
    #collection-banner-eyebrow {
      font-size: var(--fs-micro);
      letter-spacing: normal;
      text-transform: uppercase;
      color: var(--sub);
      margin-bottom: 6px;
    }
    /* Typing effect (CLI feel): while gallery.js types the collection name
       a block cursor rides the end of the text; it keeps blinking once done
       until the banner changes again. */
    /* Height is the CAP height of --font-art (measured: Helvetica Neue caps are
       0.731em), not an arbitrary .95em — with vertical-align:baseline the block
       sits on the baseline, so .95em stuck out 12px above the letters. .73em
       makes it span baseline → cap-top, level with the title. */
    #collection-banner-title::after {
      content: ''; display: inline-block;
      width: .45em; height: .73em; margin-left: .12em;
      background: var(--accent); vertical-align: baseline;
      animation: tb-blink 1.1s steps(1) infinite;
    }
    @media (prefers-reduced-motion: reduce) { #collection-banner-title::after { animation: none; } }
    #collection-banner-title {
      font-size: var(--fs-title);
      font-weight: 400;
      color: var(--text);
      /* Use the conservative wrap mode so combining-mark clusters
         (e.g. P҉SX̻̮̮̮̯͇͉̤ͤ͛ͤͬ̏ͥ̓͘͜͡͝🎮) stay shaped as single grapheme units instead
         of having each mark counted as its own break opportunity. Also
         no letter-spacing — negative tracking can push combining marks
         off their base glyph and make the whole stack render wider. */
      /* ── Height is PINNED — the banner must not grow when you pick a
         collection. Two separate ways it used to expand:
           1. a long name wrapped onto 2-3 lines, and
           2. a name carrying combining marks pulled in a fallback face whose
              taller ascent/descent sized the line box (the same trap as
              .card-name / .src-crow .nm).
         `height` + `line-height` in the SAME em unit pins the box to exactly
         one line at any --fs-title clamp value, and nowrap + ellipsis stops
         the wrap. 1.25 rather than the old 1.1 so mark towers have some
         headroom before `overflow: hidden` clips them — clipping is the
         accepted trade here, since a stable banner matters more than a
         complete zalgo stack. */
      line-height: 1.25;
      height: 1.25em;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      overflow-wrap: anywhere;
      word-break: normal;
    }
    #collection-banner-meta {
      /* 6 → 18px. The eyebrow sits 6px above the title, so at 6px below it the
         count read as part of the same block instead of a separate line under
         the name. Asymmetric on purpose: eyebrow+title are one unit, the count
         is a caption hanging off it. */
      margin-top: 18px;
      font-size: var(--fs-small);
      color: var(--sub);
    }
    @media (max-width: 680px) {
      #collection-banner { padding: 32px var(--gutter) var(--gutter); }
    }

    /* ── Status ───────────────────────────────────────── */
    #status-msg { text-align: center; padding: 80px 24px; color: var(--sub); }

    /* ── Lightbox ─────────────────────────────────────── */
    #lightbox {
      display: none; position: fixed; inset: 0; z-index: 1000;
      background: rgba(17,17,17,0.95);
      backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
      align-items: center; justify-content: center;
    }
    #lightbox.open { display: flex; }

    #lb-close {
      position: absolute; top: 20px; right: 24px;
      background: none; border: none; color: var(--sub);
      font-size: var(--fs-head); font-weight: 400; cursor: pointer;
      transition: color 0.1s; z-index: 10;
    }
    #lb-close:hover { color: var(--text); }

    #lb-inner {
      display: flex; gap: 48px; align-items: flex-start;
      max-width: 1000px; width: 100%; padding: 56px 40px 40px;
    }

    #lb-media-wrap { flex: 0 0 auto; width: 440px; }

    #lb-img {
      width: 100%; max-height: 70vh; object-fit: contain;
      display: block; border-radius: var(--radius); border: 1px solid var(--border);
    }
    #lb-iframe {
      width: 100%; height: 440px; display: none;
      border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg);
    }

    #lb-info { flex: 1; min-width: 0; padding-top: 2px; }

    #lb-name {
      font-size: var(--fs-head); font-weight: 400;
      line-height: 1.3; word-break: break-word; margin-bottom: 6px;
    }
    #lb-platform { font-size: var(--fs-small); color: var(--sub); margin-bottom: 16px; display: flex; align-items: center; gap: 6px; }

    #lb-desc {
      font-size: var(--fs-small); color: var(--sub); line-height: 1.7;
      margin-bottom: 24px; max-height: 160px; overflow-y: auto; word-break: break-word;
    }
    #lb-desc::-webkit-scrollbar { width: 3px; }
    #lb-desc::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }

    .lb-row {
      display: flex; justify-content: space-between; align-items: center;
      padding: 9px 0; border-bottom: 1px solid var(--border); font-size: var(--fs-small);
    }
    .lb-key { color: var(--sub); }
    .lb-val { color: var(--text); font-weight: 400; }

    #lb-actions { margin-top: 20px; display: flex; gap: 8px; }

    .lb-btn {
      display: inline-flex; align-items: center; gap: 5px;
      padding: 8px 16px; border-radius: var(--radius);
      font-family: inherit; font-size: var(--fs-small); font-weight: 400;
      text-decoration: none; cursor: pointer; border: 1px solid; transition: all 0.1s;
    }
    .lb-btn-primary  { background: var(--text); border-color: var(--text); color: var(--white); }
    .lb-btn-primary:hover { opacity: 0.8; }
    .lb-btn-secondary { background: none; border-color: var(--border); color: var(--sub); }
    .lb-btn-secondary:hover { border-color: var(--text); color: var(--text); }

    /* ── Footer ───────────────────────────────────────── */
    footer {
      border-top: 1px solid var(--border); padding: 20px 16px;
      color: var(--sub); font-size: var(--fs-small); display: flex; gap: 16px;
    }
    footer a { color: var(--sub); text-decoration: none; }
    footer a:hover { color: var(--text); }

    /* ── Responsive ───────────────────────────────────── */
    @media (max-width: 680px) {
      :root { --page-gap: 8px; }
      /* ⚠️ Only the HORIZONTAL values are reduced on phones. min-height is
         deliberately NOT overridden any more (was 50px against desktop's 54)
         — by request the bar keeps its full height on mobile, since it is not
         a tall bar to begin with and the shrink made it read as cramped. */
      /* ⚠️ READ THIS BEFORE CHANGING `gap` TO ADJUST SPACING — on its own it
         does nothing. The row is at CAPACITY, so what the eye reads as the
         space between two icons is
             glyph-to-glyph = (chip width + gap) − icon width
         and the width budget fixes `chip + gap`. Raising the gap forces the
         chip down by the same amount and glyph-to-glyph comes out IDENTICAL.
         Measured: gap 6 / chip 28 and gap 8 / chip 26 both render exactly
         18px between glyphs. Moving space from inside the button to between
         buttons is invisible — it only costs tap target. Spacing changes have
         to come out of the BUDGET (padding, or the brand block), and the two
         numbers below are solved together, not chosen independently.
         Current solve, at 375px:
             available   = 375 − 2×8  = 359
             brand block = 30 avatar + 5 gap + 87 wordmark + 12 cursor = 134
             6 × (chip + gap) ≤ 225   →  chip + gap ≤ 37.5
             chip 30 + gap 7 = 37     →  glyph-to-glyph = 37 − 18 = 19px
         The chip takes the larger share deliberately: at equal glyph spacing
         a wider chip is a bigger tap target, so spend spare width there.
         ⚠️ Padding is back to 8px, which also re-aligns the bar with
         #gallery's own 8px inline padding — it had been dropped to 6px purely
         to buy spacing, which left the brand sitting 2px left of the card-grid
         edge below it. That 2px is now paid for out of the gap instead. */
      /* ⚠️ The TOP value must stay `env(safe-area-inset-top)`, not 0. This is a
         shorthand, so writing `padding: 0 8px` here silently drops the inset
         the base rule puts on the top — on exactly the notched phones that
         need it, and reopening the bare-band-above-the-toolbar bug the base
         rule exists to fix. Only the 8px horizontal value belongs to this
         breakpoint. */
      #toolbar   { padding: env(safe-area-inset-top, 0px) 8px 0; gap: 7px; }
      /* margin-right: auto pushes the whole icon group hard right (desktop
         instead splits filters-left / sort+view-right). The zeroed padding
         and tightened internal gap are part of the width solve above. */
      #toolbar-brand { margin-right: auto; gap: 5px; padding: 0; }
      /* ⚠️ Cancels the DESKTOP `#sort-control { margin-left: auto }` (~line
         740), which is what splits the two control groups on wide screens.
         Left in place it competes with the brand's auto margin — the free
         space gets shared between them, so sort/view drift away from the
         other four and the spacing is uneven by construction. This was the
         actual cause of the odd 3.8px gap that sat before sort-control. */
      #sort-control { margin-left: 0; }
      /* Icon-only controls on mobile: hide the label + caret, give them a
         square footprint so the row stays readable. Filter applied =
         accent border so the user can still tell which ones are active.
         ⚠️ This width is SOLVED against the row budget together with
         #toolbar's `gap` — see the worked arithmetic on #toolbar above; the
         pair is `chip 30 + gap 7` out of a 37.5 allowance. Changing either
         one alone either overflows or silently changes the icon spacing.
         A 18px glyph in a 30px chip leaves 6px a side, which is about the
         floor before it reads as cramped.
         History, all of it forced by this same budget: 43px (silently
         overflowed and cut the sixth control off behind #toolbar's
         `overflow: hidden`), 36px (fit only with 0 gaps and the cursor
         hidden), 30px (4px gaps), 28px (6px, then 8px gaps), now 30px again
         once the wordmark dropped to 17px and freed 10px.
         ⚠️ Re-solve if a seventh control is added, or if the icon, cursor,
         wordmark, avatar or padding change size — the failure mode is an
         unreachable button, not a scrollbar. */
      .tb-control { padding: 0; width: 30px; justify-content: center; }
      .tb-text, .tb-caret { display: none; }
      /* ⚠️ No .tb-icon override here any more. It used to force 16px against
         a 14px desktop base; the base rule now sets one size everywhere, so
         this had become a no-op restating the inherited value — the kind of
         duplicate that silently goes stale when the base moves.
         Note the icon size IS load-bearing for the width solve above: it is
         the `icon` term in `glyph-to-glyph = (chip + gap) − icon`. */
      /* Brand: the DJKERO wordmark stays visible on phones, and so does the
         blinking cursor after it — by request, so the brand block behaves
         like the desktop one. There is no `.tb-cursor { display: none }` here
         any more; it inherits the desktop rule (7px block + 5px margin, ~12px,
         which IS part of the .tb-control width solve above).
         ⚠️ #tb-avatar carries no mobile size step (it was 24px against
         desktop's 30px) — the bar keeps desktop sizing on phones.
         ⚠️ 17px, down from 19 — and this is a SPACING decision, not a type
         one. The wordmark is the only remaining source of width in a row
         that is at capacity, so it is what buys space between the icons:
         at 19px the wordmark measures 97px and the chips solve to 28px with
         18px between glyphs; at 17px it measures 87px, which affords chips
         of 30px and 20px between glyphs. Measured — 18px was the hard
         ceiling at 19px wordmark, and 21px overflows even at 17px.
         Raising this back to 19px is a valid choice; it just costs 2px of
         icon spacing and 2px of tap target, and both must be reverted with
         it (.tb-control 30 → 28px above).
         `display: inline-flex` is not a size rule and has to stay — the
         desktop rule leaves it inline, and it needs to be a flex box to sit
         on the bar's centre line. */
      .tb-brand-name { display: inline-flex; font-size: 17px; }
      /* ⚠️ The old `#toolbar-brand { gap: 6px; padding: 0 2px }` that used to
         sit here is GONE — its gap/padding are now set in the single
         #toolbar-brand rule at the top of this block, where they are part of
         the documented width solve. Two rules for one selector in the same
         media block is how that solve silently gets undone: this one came
         later in source order, so at equal specificity it simply won. */
      #gallery   { padding: 20px 8px 32px; gap: 8px; --col: 150px; }
      #lb-inner  { flex-direction: column; padding: 48px 20px 24px; gap: 24px; }
      #lb-media-wrap { width: 100%; }
    }
    /* ── Fullscreen overlay ──────────────────────────────── */
    /* IMPORTANT: iOS 26 Safari samples background-color + backdrop-filter
       from ANY position:fixed element near the viewport edges — even when
       opacity:0 and pointer-events:none. That means a fixed black overlay
       sitting in the DOM "invisibly" still tints the Liquid Glass URL bar
       black. Workaround: use display:none when closed so the element
       isn't in the render tree at all. The opacity fade-in is preserved
       by switching display via JS before the .fs-open class is added.
       Refs: https://1ar.io/updates/safari-26-liquid-glass-web/ +
             https://benfrain.com/ios26-safari-theme-color-tab-tinting-with-fixed-position-elements/ */
    #fs-overlay {
      position: fixed; inset: 0; z-index: 2000;
      height: 100dvh;
      background: #000;
      display: none; flex-direction: column;
      opacity: 0; pointer-events: none;
      transition: opacity .25s;
      /* The viewer's info gradient bleeds by the shared --bleed token (see
         :root). Used in TWO places per edge — the negative inset AND the
         matching padding — so the box grows outward while its CONTENT stays
         exactly where it was. */
      /* ⚠️ Required by the bleed, and the reason it costs nothing: the
         overhang is clipped at the viewport instead of becoming scrollable
         overflow. Without this, a fixed element's overflowing child can extend
         the document's scroll area and produce a stray horizontal scrollbar.
         Nothing legitimate paints outside this box, so clipping is otherwise
         a no-op. */
      overflow: hidden;
    }
    /* While the viewer is open, hide the gallery behind it (it's fully
       covered anyway) so it stops painting — combined with pausing its
       GIFs in JS, this keeps browsing pieces smooth. */
    body.viewer-open #gallery,
    body.viewer-open #page-nav { visibility: hidden; }
    /* Lock page scroll while the viewer is open (scroll position preserved). */
    html.fs-scroll-lock, html.fs-scroll-lock body { overflow: hidden; overscroll-behavior: none; }
    #fs-overlay.fs-open {
      opacity: 1; pointer-events: all;
      /* Prevent touch-scroll from leaking to the body underneath */
      touch-action: none; overscroll-behavior: contain;
    }

    #fs-media-wrap {
      flex: 1; min-height: 0;
      display: flex; align-items: center; justify-content: center;
      /* Symmetric vertical padding → top:50% of children lands exactly on
         the visual centre of the media, so spinner + arrows align.
         Top zone (70px) clears the 34-px close button at top:14 with a
         ~22 px margin; bottom matches for symmetry. Horizontal zone (88px)
         clears the 48-px chevrons at left:20px / right:20px the same way.
         max() with safe-area-inset means landscape iPhone notch / rounded
         corners can never clip the media either. */
      padding-top:    calc(70px + env(safe-area-inset-top, 0px));
      padding-bottom: 70px;
      padding-left:   max(88px, env(safe-area-inset-left, 0px));
      padding-right:  max(88px, env(safe-area-inset-right, 0px));
      position: relative;
    }
    #fs-spinner {
      display: none; position: absolute;
      top: 50%; left: 50%; transform: translate(-50%, -50%);
      width: 36px; height: 36px;
      /* Track transparent, arc solid #fff — no translucent whites anywhere.
         A fully white RING would hide the rotation entirely (a solid circle
         spinning looks static), so the contrast comes from transparency in
         the track rather than from alpha on the white. */
      border: 2px solid transparent; border-top-color: #fff;
      border-radius: 50%; animation: spin .7s linear infinite; z-index: 5;
    }
    /* Loading readout (loaded / total · %) + progress bar under the spinner. */
    #fs-loadsize {
      position: absolute; top: 50%; left: 50%;
      transform: translate(-50%, calc(-50% + 40px));
      font-size: var(--fs-small); color: #fff; letter-spacing: normal;
      font-variant-numeric: tabular-nums; z-index: 6; pointer-events: none;
      white-space: nowrap;
    }
    #fs-loadbar {
      position: absolute; top: 50%; left: 50%;
      transform: translate(-50%, calc(-50% + 10px));
      width: 150px; height: 3px; border-radius: 3px;
      /* Same reasoning as the spinner: a solid-white track would swallow the
         solid-white fill and progress would be invisible. */
      background: transparent; overflow: hidden;
      z-index: 6; pointer-events: none; display: none;
    }
    #fs-loadbar.show { display: block; }
    #fs-loadbar-fill { height: 100%; width: 0; background: #fff; border-radius: 3px; transition: width .12s linear; }
    /* All viewer media fills the media-wrap content box and uses
       object-fit:contain (or the equivalent for iframes) so tiny pieces
       scale UP and large pieces scale DOWN, always preserving aspect ratio. */
    #fs-img {
      width: 100%; height: 100%;
      object-fit: contain; display: block; border-radius: 4px;
      opacity: 0;
      transition: opacity .18s ease;
    }
    #fs-video {
      width: 100%; height: 100%;
      object-fit: contain; border-radius: 4px; display: none;
      background: #000;
    }
    #fs-iframe {
      width: 100%; height: 100%;
      border: none; border-radius: 4px; display: none; background: #000;
    }

    /* Info bar — floating glass shelf on desktop: max-width ~1100px,
       centred, rounded corners, subtle blur. Mirrors the floating nav
       bar at the top so the viewer feels symmetric and contained. Two
       children: the bare title/description, and the bounded details card
       (see #fs-details-card below) — bottom-aligned so both sit flush. */
    #fs-info {
      flex-shrink: 0;
      width: calc(100% - 32px);
      max-width: 1100px;
      min-height: 100px; /* locks shelf height across tokens */
      margin: 0 auto calc(16px + env(safe-area-inset-bottom, 0px));
      padding: 18px 28px;
      background: rgba(20, 20, 20, 0.78);
      backdrop-filter: blur(14px);
      -webkit-backdrop-filter: blur(14px);
      border: 1px solid rgba(255, 255, 255, 0.08);
      border-radius: 18px;
      box-shadow: 0 4px 16px rgba(0,0,0,.45), 0 18px 48px rgba(0,0,0,.35);
      display: flex;
      align-items: flex-end;
      gap: 32px;
      overflow: hidden;
    }
    /* name + desc — left-aligned, bottom-anchored, fixed height, stays
       OUTSIDE the details card (bare over the gradient/shelf). */
    #fs-col-main {
      /* ⚠️ `flex: 1 1 auto`, NOT the shorthand `flex: 1`. `flex: 1` expands to
         `flex: 1 1 0%` — a ZERO flex-basis — so this column only ever received
         leftover space. In contained mode #fs-info is a 1044px-wide row whose
         other two children (#fs-details-card 540px at `flex: 0 0 auto`, plus
         #fs-signature, plus two 32px gaps) already total ~1042px, leaving ~2px:
         the column computed to 0px wide and the title AND collection name were
         both cropped to nothing even when the name was four characters long.
         An `auto` basis sizes it from its content first, so it claims a real
         share before shrinking. min-width: 0 stays — it's what still allows
         the ellipsis to trigger when a name genuinely doesn't fit. */
      min-width: 0; flex: 1 1 auto; display: flex; flex-direction: column;
      gap: 4px; min-height: 70px;
      justify-content: flex-end;
    }
    /* Lets the signature yield some of the row to the title/collection column
       instead of holding its full content width. */
    #fs-signature { min-width: 0; }
    /* ── #fs-name: the transient.xyz treatment (Aug 2026) ─────────────────
       The ARTWORK TITLE ONLY. The collection name beside it, the gallery
       captions and everything else artist-authored still use --font-art-code
       (Menlo) — this is deliberately the one element that diverges.

       WHY IT LOOKED WRONG AT LARGE SIZES: it was in the Menlo rule with the
       card captions and had `letter-spacing: normal`. A MONOSPACE at
       40–88px with zero tracking gives every glyph the same fixed advance, so
       the title sprawls and no pair ever optically closes up. Caption sizes
       hide that; display sizes cannot.

       MATCHED TO the live page, measured rather than eyeballed:
       transient.xyz/nfts/base/0xf1ac2f…/258 renders its <h1> at 52px/62px,
       weight 400, letter-spacing -1.04px — i.e. a proportional sans at
       -0.02em. The tracking is the half that actually fixes "big": display
       sizes need negative tracking, caption sizes do not.

       FONT — --font-art, NOT transient's own stack. Theirs heads with FK
       Grotesk Regular, which is commercial, so it cannot ship here. But the
       structure is the transferable part and --font-art already IS that
       structure (proportional sans + generic tail). Measured at 48px over
       this catalogue's 691 mark-carrying titles, width-with-marks ÷
       width-without (1.000 = every mark attached, correct):

         stack                        smeared >1.5   mean    worst
         Menlo — what was here             71        1.148    2.99
         --font-art — now                   0        1.007    1.26
         transient's exact stack            2        1.048    2.52

       So this matches their approach AND beats their stack, because
       --font-art's tail carries Noto Sans Symbols 2 / Noto Sans Math while
       theirs stops at the emoji fonts — dropping the block-drawing glyphs
       (U+2588 █) that Kero's own collection names are built from.

       ⚠️ THIS REVERSES THE Jul 26 2026 CHOICE of --font-art-code for titles,
       for this element only. That choice was made for a real reason — a mono
       spreads a combining-mark tower into separately legible glyphs, where a
       proportional attaches them into a compact stack. The width test above
       measures ATTACHMENT, not legibility, so it does not by itself overrule
       that preference; the request to match transient does. Read both this
       note and the --font-art / --font-art-code notes in :root before
       touching it. To revert: delete the two properties below and put
       #fs-name back in the --font-art-code rule above.

       ── THE FONT ITSELF, and why it is conditional ──────────────────────
       Geist (sans) heads this stack as the free stand-in for FK Grotesk —
       but ONLY on titles with no combining marks. Measured at 48px over the
       same 691 mark-carrying titles, putting ANY webfont at the head:

         head of stack        smeared >1.5   mean    WORST
         Helvetica Neue (sys)       0        1.007    1.26
         Geist                     16        1.116    9.45
         Inter                     17        1.104   10.33
         Archivo                   17        1.102   10.55

       A worst case of 9.45x is a title smeared to ten times its width. The
       cause is trap #1 from the --font-art note in :root: these faces cover
       the Latin BASE letters but not the marks, so the shaping run splits —
       base in Geist, marks in a fallback — and nothing attaches. Coverage of
       the base letters is irrelevant; that is the whole lesson.

       So .has-marks (set per piece in fsShow) drops back to the pure system
       stack, which scores a perfect 0 / 1.007. 691 of 2,547 titles carry
       marks, so ~73% get the transient face and the rest get one that can
       actually render them. Two faces across the set is the price; a title
       smeared to 10x its width is not worth avoiding it.

       ⚠️ Do NOT "simplify" this by dropping the .has-marks branch and putting
       Geist on everything. Re-run the ratio test above first — it is the same
       test that has now caught this twice. */
    #fs-name {
      font-family: 'Geist', var(--font-art);
      letter-spacing: -0.02em;
      /* ── Optical left-alignment: a FIXED 2px, deliberately ────────────────
         The title and the collection name below it share a column and their
         BOXES already start at the same x — that was never wrong. What shows
         is the INK: the title is now a proportional face (Geist) while the
         collection stays monospace (Menlo), and their left side bearings
         differ. Measured at 50px: the title's first glyph starts 3.88px inside
         its box, Menlo's at -0.21px — flush, because a monospace centres each
         glyph in a fixed cell. So the title read indented.

         ⚠️ THIS WAS MEASURED PER TITLE IN JS AND THAT WAS WRONG — do not put
         it back. Compensating each first glyph by its exact bearing is
         technically precise and looks unstable: the bearing belongs to the
         GLYPH, so the nudge changed with every piece (-0.014em on 'v',
         -0.08em on 'D'), and full compensation over-corrects round and pointed
         glyphs, which optical alignment actually wants to overhang slightly.
         Navigating the gallery, the title visibly shifted from piece to piece.

         A constant fixes the systematic half of the offset and leaves the
         per-glyph half alone, which is what the eye expects. 2px rather than
         the full ~4px for the same reason — it is a nudge, not a correction.
         Fixed px, not em: this is an optical tweak at display size, not a
         metric that has to scale with the clamp() above. */
      margin-left: -2px;
    }
    /* Marks present → the webfont head is removed entirely, not merely
       demoted: it only has to win the BASE letters to split the run. */
    #fs-name.has-marks { font-family: var(--font-art); }
    #fs-name {
      /* CONTENT — explicit size, not --fs-ui (contained mode only; the
         immersive override below is what normally renders). */
      font-size: 24px; font-weight: 400; color: #fff; line-height: 34px;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    }
    #fs-desc {
      font-size: var(--fs-small); color: #ffffff70; line-height: 18px;
      display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
      overflow: hidden;
      min-height: 36px; /* always reserve 2 lines so name stays put */
    }
    /* Description hidden (Jul 20 2026, by request — "hide artwork
       information under title"): title + big collection name carry the
       viewer hierarchy now. Usual hide-but-keep pattern — JS still
       populates it; delete this line to bring it back. */
    #fs-desc { display: none !important; }

    /* ── Details block ────────────────────────────────────────────────
       Everything that ISN'T the title/description — collection, type,
       chain, date, format, price, and the action buttons — stacked
       top-to-bottom. No box/background/border of its own (that was tried
       and reverted — read as a heavier, separately-styled element instead
       of belonging to the rest of the info panel) — it's bare, floating
       directly on the immersive gradient, same as the title/description
       above it. Individual pills (type/chain/meta) keep their own glass
       chip look; only the CONTAINER lost its box. FIXED width on
       desktop/tablet (doesn't stretch to fill #fs-info); goes full-width
       only on mobile (≤600px — see that breakpoint below). */
    #fs-details-card {
      flex-shrink: 0;
      width: 540px;
      display: flex; flex-direction: column; gap: 10px;
    }
    /* Explicit min-heights on every row so the block's total height stays
       constant across tokens — otherwise a piece with no meta / a short
       collection name could render a visibly shorter block than the next
       one, "jumping" the whole info panel (and media area above it) up/down
       on every navigation. */
    #fs-badges-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; min-width: 0; min-height: 26px; }
    /* No type pill for this token (unrecognised mime) → typeBadgeHTML()
       returns '' and #fs-type-badge is truly empty. Without this, flex gap
       still reserves space around the empty span, leaving a stray gap where
       the pill would have been. display:none removes it from the row. */
    #fs-type-badge:empty { display: none; }
    /* #fs-chain-badge visuals come from the shared .chain-badge class (same
       pill as the gallery cards). Nothing chain-specific needed here. */
    /* "COLLECTION" eyebrow + the collection name on one line, vertically
       centred against the (taller) name. */
    #fs-collection-line {
      /* CENTER, by request (Jul 26 2026) — this reverses an earlier
         "BASELINE, not center" decision, so don't flip it back on the
         strength of that old note. That call was measured against 22px
         --font-art, whose ascent/descent no longer apply: the name now
         renders in --font-art-code (Menlo), and its taller box made the
         baseline pairing sit visibly low against the 13px eyebrow.
         ⚠️ The badge's own `padding-top: 2px` is removed for this — with
         box-centring, asymmetric padding shifts the TEXT off-centre inside
         a box that is itself perfectly centred, which is a confusing way to
         be 1px wrong. Its combining-mark headroom comes from line-height 1.3,
         not from that padding. */
      display: flex; align-items: center; gap: 10px; min-width: 0;
    }
    /* The "COLLECTION" label is a PILL, matching .type-badge / .chain-badge
       exactly — same 26px height, 0/12px padding, 4px radius, black-55 plate,
       white-18 hairline and blur. Those two are deliberately ONE shared pill;
       this is the third member of that set, not a fourth look, so any retune
       of the badge chrome must be applied here too (or move all three onto a
       shared class). Height is explicit rather than padding-driven for the
       same reason it is on the badges: it guarantees the pills match
       pixel-for-pixel regardless of content. */
    #fs-collection-eyebrow {
      flex-shrink: 0;
      display: inline-flex; align-items: center;
      height: 26px; padding: 0 12px;
      font-size: var(--fs-micro); text-transform: uppercase; letter-spacing: normal;
      line-height: 1; white-space: nowrap;
      color: rgba(255, 255, 255, 0.9);
      background: rgba(0, 0, 0, 0.55);
      border: 1px solid rgba(255, 255, 255, 0.18);
      border-radius: 4px;
      -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
    }
    /* Eyebrow hidden (Jul 26 2026, by request — "remove collection tag in
       viewer"), same hide-but-keep pattern as #fs-desc: the markup and the
       pill styling above are left intact, so deleting this one line brings
       the tag back exactly as it was. The collection NAME still shows. */
    #fs-collection-eyebrow { display: none; }
    /* No collection on this token → hide the eyebrow too (don't leave a
       stray "COLLECTION" label next to an empty name). */
    #fs-collection-line:has(#fs-collection-badge:empty) { display: none; }
    #fs-collection-badge {
      display: block;
      /* CONTENT, not chrome — so it keeps an explicit size rather than
         following --fs-ui when the interface collapsed to one CLI size
         (Jul 24 2026). It's the artist's collection name and carries real
         hierarchy under the title. line-height 1.3 (was 1.15) leaves room
         for the combining marks these names stack above the cap height —
         at 1.15 the tops were being clipped. */
      font-size: 22px; line-height: 1.3; min-height: 30px;
      color: #fff; /* pure white, no opacity — unlike other secondary text here */
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      min-width: 0;
      /* It navigates now — see fs-collection-badge's click handler in
         viewer.js.
         ⚠️ NO `width: fit-content` HERE, though shrinking the hit area to the
         name itself is the obvious thing to want. This element is a flex item
         with `min-width: 0` and `white-space: nowrap` + ellipsis; `fit-content`
         resolves to ZERO against that combination (measured: 0px wide, 23.8px
         tall, text still painted). The whole line stays the target instead —
         predictable, and it cannot collapse.
         🚨 THE z-index IS WHAT MAKES IT CLICKABLE AT ALL. #fs-artwork-controls
         is `position: absolute; z-index: 12; pointer-events: all` and its box
         covers this name completely — hit-testing every point across the badge
         returned #fs-artwork-controls, so the link was dead on arrival and
         looked fine in a screenshot. Raising the BADGE (a small box) rather
         than #fs-info keeps every control around it clickable. */
      position: relative; z-index: 13;
      cursor: pointer;
      transition: color .15s ease;
      -webkit-tap-highlight-color: transparent;
    }
    /* Gated like every other hover affordance on the site (`any-hover` /
       `any-pointer`, never `hover` / `pointer`) — on a phone a bare :hover
       latches on after the tap and leaves the name stuck green. */
    @media (any-hover: hover) and (any-pointer: fine) {
      #fs-collection-badge:hover { color: var(--accent); }
    }
    #fs-collection-badge:focus-visible {
      outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 2px;
    }
    /* ⚠️ Nothing to click when there is no collection — the :empty rule above
       hides the whole line, but the cursor would still be a pointer for the
       split second before that applies on a token with no collection. */
    #fs-collection-badge:empty { cursor: default; }
    /* Date + Format now live in their own pill, same visual family as
       .type-badge/.chain-badge (same height/bg/border/radius/blur), sitting
       next to them in #fs-badges-row. "Date Jul 9, 2026   Format
       video/quicktime" — each .fs-meta-item (label+value) is its own inline
       group inside the pill. */
    #fs-meta-row {
      display: inline-flex; align-items: center; flex-shrink: 0;
      height: 26px; padding: 0 12px; gap: 12px;
      background: rgba(0, 0, 0, 0.55);
      border: 1px solid rgba(255, 255, 255, 0.18);
      border-radius: 4px;   /* matches .type-badge/.chain-badge beside it */
      -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
      min-width: 0; overflow: hidden;
    }
    .fs-meta-item {
      display: inline-flex; align-items: baseline; gap: 4px;
      min-width: 0;
    }
    .fs-meta-label {
      font-size: var(--fs-micro); color: #ffffff70;
      text-transform: uppercase; letter-spacing: normal;
      white-space: nowrap;
    }
    .fs-meta-value {
      font-size: var(--fs-micro); font-weight: 400; color: #ffffffe0;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
    }
    /* Price + link buttons in one aligned row — same explicit height (26px)
       on #fs-price-badge and every .fs-link-btn so they share one exact
       vertical center, not just an approximate visual line-up. */
    #fs-actions-row { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; min-height: 34px; }
    #fs-price-badge {
      display: inline-flex; align-items: center;
      height: 34px;
      font-size: var(--fs-body); font-weight: 400;
      color: var(--accent); background: color-mix(in srgb, var(--accent) 9%, transparent);
      border: 1px solid color-mix(in srgb, var(--accent) 19%, transparent);
      padding: 0 14px; border-radius: 6px; white-space: nowrap;
    }
    /* Collapse the slot entirely when there's no price (unlisted piece) —
       no reserved gap inside the card. */
    #fs-price-badge:empty { display: none; }
    /* Roomier than the 8px it used to be — the buttons are bordered
       pills with the same low-contrast stroke, so a tight gutter made
       "View on OpenSea / Open file / Activity / Share" read as one
       segmented control instead of four separate actions. */
    #fs-links { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
    /* The primary/secondary wrappers are structural only — transparent to
       layout everywhere except the ≤600px shelf, which uses them to put
       price+Buy on one line and Open file/Activity on the next. */
    .fs-links-primary, .fs-links-secondary { display: contents; }
    .fs-link-btn {
      display: inline-flex; align-items: center; justify-content: center;
      height: 34px;
      font-family: inherit; font-size: var(--fs-small); line-height: 1; color: #ffffff70;
      text-transform: uppercase; letter-spacing: normal;
      text-decoration: none; background: transparent; cursor: pointer;
      border: 1px solid #ffffff20; border-radius: 4px;
      padding: 0 18px; transition: color .15s, border-color .15s; white-space: nowrap;
      -webkit-appearance: none; appearance: none;
    }
    /* The button is already inline-flex; the gap sets the label off the arrow
       and replaces the space that used to precede the ↗ character. */
    .fs-link-btn { gap: 7px; }
    .fs-link-btn:hover { color: #fff; border-color: #ffffff50; }

    /* ── .ext-arrow — "this leaves the site" ──────────────────────────────
       lucide `arrow-up-right`, replacing a literal ↗ (U+2197) everywhere
       (Aug 2026). The character isn't in Geist Mono, so it came from whatever
       the OS fallback supplied: a different weight, optical size and baseline
       per platform, set against type chosen deliberately. Drawn, it sits on
       the same 24-grid and 2px stroke as every other icon on the site.

       Sized in `em`, not px, so the mark tracks whatever text it follows —
       this rides a 13px button label, a 13px info-panel value and the page
       footer, and a fixed px size would read correctly against only one.
       0.92em keeps it fractionally under the cap height; an arrow matching the
       full em box looks larger than the letters beside it, because its ink
       fills the box corner to corner while a capital's does not.

       `flex-shrink: 0` matters on the buttons: they carry `min-width: 0`
       descendants on the mobile shelf, and without it a long marketplace name
       squashes the arrow into a sliver rather than wrapping. */
    .ext-arrow {
      /* 1.08em ≈ 14px against the 13px UI size — was .92em (~12px), bumped by
         ~2px on request. Above the cap height now rather than under it, which
         works because the glyph is a thin 2px-stroke diagonal: its ink covers
         far less of its box than a letter does, so matching the caps optically
         means overshooting them metrically. Still `em`, so it tracks whatever
         it follows — see the note above. */
      width: 1.08em; height: 1.08em; flex-shrink: 0;
      display: inline-block; vertical-align: -.16em;
      /* Quieter than its label at rest — the arrow is a modifier on the text,
         not a second thing to read. It inherits colour on hover along with
         everything else, since the parent's colour transition drives it. */
      opacity: .72;
    }
    a:hover > .ext-arrow, button:hover > .ext-arrow { opacity: 1; }
    /* In the info panel the arrow follows a value, not a centred button
       label — a hair of space before it, and no baseline nudge, since the
       row is baseline-aligned rather than flex-centred. */
    /* Both of these sit in normal INLINE flow, with no flex gap to space them
       — unlike .fs-link-btn, which is inline-flex and gets its gap above. */
    .fa-v .ext-arrow { margin-left: .4em; }
    body > footer .ext-arrow { margin-left: .35em; }
    .fs-link-btn.copied { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
    /* Listed pieces get a filled amber CTA — go straight to the marketplace
       page to buy / bid. Black text for max contrast on the amber fill. The
       double-class selectors below out-specify the generic immersive
       `.fs-link-btn` colour rule (which would otherwise tint the text white). */
    .fs-link-btn--buy,
    .fs-link-btn.fs-link-btn--buy {
      background: var(--accent); border-color: var(--accent); color: var(--accent-ink);
      font-weight: 400; letter-spacing: normal;
    }
    .fs-link-btn.fs-link-btn--buy:hover { background: var(--accent-bright); border-color: var(--accent-bright); color: var(--accent-ink); }
    #fs-overlay.fs-immersive .fs-link-btn.fs-link-btn--buy { border-color: var(--accent); color: var(--accent-ink); }

    /* Nav arrows */
    #fs-prev, #fs-next {
      position: absolute;
      bottom: calc(24px + env(safe-area-inset-bottom, 0px));
      background: #ffffff12; border: 1px solid #ffffff20;
      color: #fff;
      width: 46px; height: 46px; border-radius: 50%; padding: 0;
      cursor: pointer; display: flex; align-items: center; justify-content: center;
      transition: background .15s, border-color .15s; z-index: 10;
    }
    #fs-prev svg, #fs-next svg { width: 20px; height: 20px; }
    /* Prev/next grouped at the bottom-right corner (46 + 10px gap). */
    #fs-next { left: auto; right: calc(24px + env(safe-area-inset-right, 0px)); }
    #fs-prev { left: auto; right: calc(80px + env(safe-area-inset-right, 0px)); }
    #fs-prev:hover, #fs-next:hover { background: #ffffff22; border-color: #ffffff40; }

    /* Dice — random artwork, bottom-right next to the arrows. */
    #fs-dice {
      position: absolute;
      bottom: calc(24px + env(safe-area-inset-bottom, 0px));
      right:  calc(136px + env(safe-area-inset-right, 0px));
      background: #ffffff12; border: 1px solid #ffffff20; color: #fff;
      width: 46px; height: 46px; border-radius: 50%; padding: 0;
      cursor: pointer; display: flex; align-items: center; justify-content: center;
      transition: background .15s, border-color .15s; z-index: 10;
    }
    #fs-dice svg { width: 22px; height: 22px; }
    #fs-dice:hover { background: #ffffff22; border-color: #ffffff40; }
    #fs-overlay.fs-immersive #fs-dice {
      background: rgba(0,0,0,.42); border-color: rgba(255,255,255,.24);
      -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
    }

    #fs-close {
      position: absolute;
      top:   calc(24px + env(safe-area-inset-top, 0px));
      right: calc(24px + env(safe-area-inset-right, 0px));
      background: #ffffff10; border: 1px solid #ffffff20;
      color: #fff; font-size: var(--fs-mid);
      width: 46px; height: 46px; border-radius: 50%;
      cursor: pointer; display: flex; align-items: center; justify-content: center;
      transition: background .15s; z-index: 10;
    }
    #fs-close svg { width: 20px; height: 20px; }
    #fs-close:hover { background: #ffffff28; }
    /* Kill the lingering click focus ring on viewer buttons; keep a
       keyboard-only outline for accessibility. */
    #fs-overlay button { -webkit-tap-highlight-color: transparent; }
    #fs-overlay button:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 65%, transparent); outline-offset: 2px; }
    /* Kill the click/tap focus ring on ALL controls (keep it for keyboard
       users via :focus-visible). Also removes the mobile tap highlight. */
    button, a, select, [tabindex] { -webkit-tap-highlight-color: transparent; }
    :focus:not(:focus-visible) { outline: none; }
    /* Artwork position ("x / total") — no longer a standalone top-center
       element. It now lives INSIDE the always-visible eye/info pill
       (#fs-info-peek, bottom-left; see below) as plain inline text; the
       button itself supplies the pill background/border/blur. */
    #fs-counter {
      font-size: var(--fs-small); font-weight: 400; color: #ffffff99; /* less opaque still — was #ffffffcc */
      font-variant-numeric: tabular-nums; pointer-events: none;
      white-space: nowrap; line-height: 1; /* crisp vertical centring next to the icon */
    }

    /* ── Viewer: brand header + mode toggle + immersive mode ───────── */
    /* Persistent DJKERO title at the top of the viewer — a link back to
       the gallery, tying fullscreen to the home view. No pill/glass
       backdrop here (removed by request). The text-shadow that used to carry
       legibility over busy art is gone too (Jul 26 2026) — the bottom gradient
       is what does it now. */
    /* Fit / fill toggle — grouped bottom-left with #fs-info-peek inside
       #fs-artwork-controls (both act on/describe the artwork), leaving the
       bottom-right cluster as pure navigation (dice · prev · next). Immersive
       art fills the screen (object-fit: cover) by default; this switches it
       to fit (contain). Position now comes from the wrapper, not this rule. */
    #fs-fit {
      background: #ffffff10; border: 1px solid #ffffff20; color: #fff;
      width: 46px; height: 46px; border-radius: 50%; cursor: pointer;
      display: flex; align-items: center; justify-content: center;
      transition: background .15s, border-color .15s, transform .3s ease;
    }
    #fs-fit svg { width: 20px; height: 20px; }
    #fs-fit:hover { background: #ffffff28; }
    #fs-overlay.fs-immersive #fs-fit {
      background: rgba(0,0,0,.42); border-color: rgba(255,255,255,.24);
      -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
    }
    /* ── Icon swap: STATE, not action ─────────────────────────────────────
       The glyph names the mode the artwork is CURRENTLY in — outward arrows
       while it fills the screen, inward arrows once it is fitted inside it —
       matching #fs-sound, where the speaker reports whether audio is on rather
       than what a press would do. See the icon-semantics note above #fs-fit in
       index.html for why the two clusters agree on this.

       This was ACTION semantics until Aug 2026 (the glyph showed what pressing
       it would do, so the inward arrows appeared while the art was filling).
       If you are reading a diff and it looks inverted, that is the change —
       the class names now match what they show, which they did not before.

       ⚠️ The pairing is easy to get backwards either way. Sanity check: with
       the artwork edge to edge (the DEFAULT, no .fs-fit-contain), the button
       must show `maximize-2`. */
    #fs-fit .fit-contain { display: none; }
    #fs-overlay.fs-fit-contain #fs-fit .fit-fill { display: none; }
    #fs-overlay.fs-fit-contain #fs-fit .fit-contain { display: block; }
    /* ⚠️ NO accent tint when fit-to-screen is active (removed Jul 31 2026).
       The button used to go volt while contained, as an "is on" signal from
       back when both states shared one ambiguous glyph. It now swaps between
       lucide `minimize-2` and `maximize-2`, and the icon alone says which mode
       you are in — so the colour was a second, louder signal for something
       already answered, and it made one control in the row shout while its
       neighbours (explore, fullscreen) stayed quiet in the same active state.
       Accent in this cluster now means hover only. */

    /* Fitted mode: the whole artwork fits inside the screen (letterboxed)
       instead of cover-cropping to fill it. The blur-up poster/thumbnail
       matches the same fit so there's no cover→contain jump during the
       load transition when moving between pieces. */
    /* Padding (with border-box) shrinks the object-fit content box, so the
       fitted art keeps a margin off the screen edges instead of touching the
       corners. Applied to the media + the blur-up poster so both match. */
    #fs-overlay.fs-immersive.fs-fit-contain #fs-img,
    #fs-overlay.fs-immersive.fs-fit-contain #fs-video,
    #fs-overlay.fs-immersive.fs-fit-contain #fs-poster {
      object-fit: contain;
      /* Comfortably bigger than the top-left avatar block + its inset
         (24px offset + 46px avatar ≈ 70px) so the art clears the brand
         with real breathing room, not just barely. */
      padding: 108px;
    }
    #fs-overlay.fs-immersive.fs-fit-contain #fs-poster { transform: none; }

    /* ── Auto-hiding chrome ────────────────────────────────────────────
       After a few idle seconds the info + controls fade away for an
       uninterrupted view of the art; any mouse move / tap (or moving to the
       next piece) brings them back. Transitions live here; the .fs-chrome-hidden
       state is toggled from viewer.js. */
    #fs-info { transition: opacity .35s ease; }
    #fs-overlay #fs-close, #fs-overlay #fs-counter,
    #fs-overlay #fs-sound, #fs-overlay #fs-dice, #fs-overlay #fs-prev,
    #fs-overlay #fs-next, #fs-overlay #fs-fit, #fs-overlay #fs-explore,
    #fs-overlay #fs-full,
    #fs-overlay #fs-scrim-top {
      /* ⚠️ `color` belongs in this list, not on the per-button rules. The hover
         treatment tints the GLYPH to the accent as well as the plate, and the
         per-button `#fs-explore { transition: … }` declarations are one-id
         selectors that this two-id rule overrides — so a colour transition
         written there never applies and the glyph snaps while its plate fades. */
      transition: opacity .35s ease, background .15s, border-color .15s, color .15s;
    }
    /* When the chrome auto-hides, the info/counter pill (#fs-info-peek)
       intentionally stays — everything else fades (permanent HUD). */
    #fs-overlay.fs-chrome-hidden #fs-info,
    #fs-overlay.fs-chrome-hidden #fs-close,
    #fs-overlay.fs-chrome-hidden #fs-sound,
    #fs-overlay.fs-chrome-hidden #fs-dice,
    #fs-overlay.fs-chrome-hidden #fs-prev,
    #fs-overlay.fs-chrome-hidden #fs-next,
    #fs-overlay.fs-chrome-hidden #fs-fit,
    /* #fs-explore belongs to the auto-hiding set, same as its neighbour
       #fs-fit. It was omitted when the button was moved into this cluster, so
       the magnifier stayed lit over the artwork after everything else faded.
       ⚠️ #fs-info-peek is deliberately NOT in this list — it stays visible as
       the constant "tap to bring the UI back" affordance. */
    #fs-overlay.fs-chrome-hidden #fs-explore,
    #fs-overlay.fs-chrome-hidden #fs-full,
    #fs-overlay.fs-chrome-hidden #fs-scrim-top { opacity: 0; pointer-events: none; }
    /* Hide the cursor too while the chrome is away (desktop) — moving it
       reveals everything again. */
    #fs-overlay.fs-chrome-hidden { cursor: none; }

    /* Bottom-left "artwork" cluster wrapper — info/counter pill + fit
       toggle, side by side. Owns the position; the two children below are
       plain flex items now (see their own rules). */
    #fs-artwork-controls {
      position: absolute;
      bottom: calc(24px + env(safe-area-inset-bottom, 0px));
      left:   calc(24px + env(safe-area-inset-left, 0px));
      display: flex; align-items: center; gap: 10px;
      z-index: 12;
    }
    /* Info/position pill — ALWAYS visible (not part of the auto-hide fade
       above), so there's a constant, obvious "click to bring the rest of
       the UI back" affordance and a constant read on where you are in the
       set. Eye icon + "x / total" (#fs-counter, nested inside) side by
       side, matching #fs-home's permanence on the opposite corner.
       Click → fsShowChrome(). */
    #fs-info-peek {
      width: auto; height: 46px; padding: 0 16px 0 14px;
      border-radius: 999px;
      /* Buttons do NOT inherit font-family — without this the nested
         #fs-counter fell back to the UA default (Arial) instead of the mono
         UI face, which every other control here opts into. */
      font-family: inherit;
      background: rgba(0,0,0,.42); border: 1px solid rgba(255,255,255,.24); color: #fff;
      -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
      display: flex; align-items: center; gap: 8px;
      cursor: pointer;
      transition: background .15s, border-color .15s;
    }
    #fs-info-peek svg { width: 20px; height: 20px; flex-shrink: 0; }
    #fs-info-peek:hover { background: rgba(0,0,0,.6); border-color: rgba(255,255,255,.42); }

    /* Sound — last item in #fs-artwork-controls (bottom-left); volume is part
       of the artwork, so it sits with the artwork cluster. Only shown for video
       pieces, hence `display: none` by default and .show from viewer.js.
       ⚠️ In FLOW, sized to match #fs-explore / #fs-fit exactly (46px circle,
       20px icon) so the row reads as one set. It was absolutely positioned at
       left:80px until Aug 2026 — a coordinate from before #fs-artwork-controls
       existed, which now sits directly on top of that cluster. Do not
       reintroduce `position: absolute` (or the bottom/left/z-index that came
       with it): the cluster's flex row owns the placement now, and the mobile
       block below no longer supplies a fallback position. */
    #fs-sound {
      background: #ffffff10; border: 1px solid #ffffff20; color: #fff;
      width: 46px; height: 46px; border-radius: 50%; cursor: pointer;
      display: none; align-items: center; justify-content: center;
      transition: background .15s, border-color .15s;
    }
    #fs-sound.show { display: flex; }
    /* Dimmed and inert until the video is actually playing — there is no audio
       to toggle before that. Same values as #fs-explore[disabled] so the three
       loading controls in this cluster fade identically; set by fsUpdateSound().
       ⚠️ One id + one qualifier, for the same reason spelled out on
       #fs-explore[disabled]: the .fs-chrome-hidden fade is a two-id selector
       further up, and it has to keep winning over this dim state. */
    #fs-sound[disabled] { opacity: .32; cursor: default; pointer-events: none; }
    #fs-sound svg { width: 20px; height: 20px; }
    #fs-sound .snd-off { display: none; }
    #fs-sound.muted .snd-on { display: none; }
    #fs-sound.muted .snd-off { display: block; }
    #fs-sound:hover { background: #ffffff28; }
    #fs-overlay.fs-immersive #fs-sound {
      background: rgba(0,0,0,.42); border-color: rgba(255,255,255,.24);
      -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
    }
    /* Base brand text (loader + viewer instances; the masthead's giant
       version overrides these under #header). Same voice as the masthead:
       tight bold wordmark + wide-tracked sub. */
    /* gap:0 — BD Alien carries a lot of built-in leading (its line box runs
       48px at a 40px size), so the wordmark and the sub-line already sit
       further apart than the flex gap suggests. */
    .fs-home-text {
      display: flex; flex-direction: column; align-items: flex-start; gap: 0;
      /* align-items:center already centres the BOXES against the avatar
         (both land on 28.5px). BD Alien sits low inside its own em box —
         the caps start 10px down a 40px line box — so the PAINTED block
         still reads ~3px low. transform, not margin, so nothing reflows. */
      transform: translateY(-3px);
    }
    .fs-home-title { font-family: var(--font-brand); font-size: 40px; font-weight: 400; color: #fff; line-height: 1; letter-spacing: normal; }
    .fs-home-sub { font-size: var(--fs-micro); font-weight: 400; letter-spacing: normal; color: #ffffff66; }
    /* Pill backdrop removed — text now sits directly over the artwork, so
       a shadow carries the contrast that the glass background used to.
       Subtitle keeps the SAME dim nuance as the header/loader instances of
       this class (#ffffff66) — it's a subtitle, it's supposed to read as
       secondary everywhere, not just here. */

    /* ── Signature (bottom of the viewer info stack) ────────────────
       Small avatar + "— DJKERO" sign-off, like the artist signing the
       piece. Replaces the removed #fs-home brand block. */
    #fs-signature {
      display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
      margin-top: 2px;
    }
    #fs-sig-avatar {
      width: 30px; height: 30px; border-radius: 7px; object-fit: cover;
      border: 1px solid rgba(255,255,255,.22); flex-shrink: 0;
    }
    /* Same optical nudge the toolbar wordmark gets (see .tb-brand-name):
       BD Alien's ink sits low inside its em box, so with every box in this
       row sharing one centre line the painted DJKERO still read low against
       the avatar and the Date/chain/type pills beside it.

       -0.05em is measured, not eyeballed: rendered at 100px, the glyph ink
       spans -60..0 from the baseline (centre -30) while line-height:1 puts
       the line box centre at -35 — so the ink sits 5px/100px low. Kept in em
       so it holds at both the 20px desktop and 16px mobile sizes. Transform,
       so it's paint-only — no reflow, no effect on the flex centring. */
    .fs-sig-text {
      font-family: var(--font-brand); font-size: 20px; line-height: 1; color: #fff;
      transform: translateY(-0.05em);
    }
    /* The white cut of the mark, replacing the 💽💾☠ emoji this line used to
       carry (Jul 27 2026) — same three glyphs as paths, so the sign-off no
       longer depends on the platform's emoji font or its colour palette.
       Everything below is em-based, so it holds at the 20px desktop and 16px
       mobile sizes of .fs-sig-text without a second rule. */
    .fs-sig-logo {
      /* 0.611em is the LITERAL cap-height match (BD Alien's measured 0.605em
         cap height ÷ the SVG's ink fraction) — see .foot-logo for the full
         derivation. Sized up from that to 0.75em on purpose: at exact cap
         height the thin single-weight mark reads visibly smaller than the
         bold BD Alien caps beside it — same reason the emoji this replaced
         was ALSO oversized relative to its naive metric (0.76em, well past
         where its own baseline math landed). Matching height isn't matching
         visual weight for a linear mark next to a blocky wordmark. */
      height: 0.75em; width: auto;
      /* An inline <img>'s bottom edge sits ON the baseline, which is where the
         caps sit — unlike the emoji (which painted below the baseline,
         -133..+18 at 200px, so sharing a baseline didn't centre its ink) this
         one only needs the small optical nudge below, not a compensating one. */
      vertical-align: baseline;
      /* Same optical correction as .foot-logo, in this element's own em: the
         mark's ink top measured fractionally above the wordmark's cap line
         even with the boxes aligned. ~1px at the 20px desktop size, and it
         scales down with .fs-sig-text's 16px mobile size since it's in em. */
      transform: translateY(0.05em);
      /* Gap in PARENT ems, so it tracks the wordmark rather than the mark.
         0.3em ≈ half a Geist Mono space at 20px — the emoji's old 0.6em
         separator was set against three loose glyphs; a single tight lockup
         wants less. */
      margin-left: 0.3em;
    }
    /* Date/chain/type pills now sit on the signature line, just after the
       "BY DJKERO" sign-off (moved out of the details card). A little extra
       left margin sets them apart from the wordmark. Strokeless — the glass
       fill alone reads as a pill here, next to the avatar. */
    #fs-signature #fs-badges-row { margin-left: 6px; }
    #fs-signature #fs-meta-row,
    #fs-signature .chain-badge,
    #fs-signature .type-badge { border: none; }
    /* No date on this token → the meta pill is empty; collapse it so it
       doesn't render as a stray empty circle next to the avatar. */
    #fs-meta-row:empty { display: none; }

    /* Fullscreen button — bottom-LEFT, inside #fs-artwork-controls, sized to
       match #fs-fit exactly (46px circle, 20px icon) so the cluster reads as
       one set. It was previously absolute-positioned top-right AND carried a
       `display: none !important` that hid it entirely; both are gone. Being
       in flow now, it takes its position from the cluster's flex row — do not
       reintroduce `position: absolute` here. */
    #fs-explore {
      background: #ffffff10; border: 1px solid #ffffff20;
      color: #fff; width: 46px; height: 46px; border-radius: 50%;
      cursor: pointer; display: flex; align-items: center; justify-content: center;
      transition: background .15s, border-color .15s;
    }
    #fs-explore svg { width: 20px; height: 20px; }
    #fs-explore:hover { background: #ffffff28; }
    /* Zoom is only offered once there is something to zoom into — the states are
       set by fsUpdateExplore() in viewer.js.
       [disabled] = applies to this piece, but the artwork hasn't decoded yet:
       dim and inert, still holding its slot so the cluster doesn't shift
       sideways the instant the art lands.
       .is-na = zoom can never apply here (interactive piece, or a video that
       came back undecodable and left a still poster) → out of the row entirely.
       ⚠️ Kept at one id + one qualifier on purpose, so the .fs-chrome-hidden
       fade (two ids, further up) still wins over the dim state. */
    #fs-explore[disabled] { opacity: .32; cursor: default; pointer-events: none; }
    #fs-explore.is-na { display: none; }
    #fs-overlay.fs-immersive #fs-explore {
      background: rgba(0,0,0,.34); border-color: rgba(255,255,255,.28);
      -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
    }

    /* ── Fullscreen button — twin of #fs-explore, same cluster ──────────── */
    #fs-full {
      background: #ffffff10; border: 1px solid #ffffff20;
      color: #fff; width: 46px; height: 46px; border-radius: 50%;
      cursor: pointer; display: flex; align-items: center; justify-content: center;
      transition: background .15s, border-color .15s;
    }
    #fs-full svg { width: 20px; height: 20px; }
    #fs-full:hover { background: #ffffff28; }
    #fs-full[disabled] { opacity: .32; cursor: default; pointer-events: none; }
    #fs-full.is-na { display: none; }
    #fs-overlay.fs-immersive #fs-full {
      background: rgba(0,0,0,.34); border-color: rgba(255,255,255,.28);
      -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
    }

    /* ── FULLSCREEN MODE — the artwork, alone ───────────────────────────────
       The piece edge to edge with every control gone except one cross.
       Toggled by fsSetFull() (viewer.js), which also asks the Fullscreen API
       for the real thing; this class is what actually does the visual work, so
       the mode still looks and behaves right on iOS Safari, where the API
       refuses on anything that is not a <video>.

       ⚠️ The MEDIA ELEMENTS ARE NOT TOUCHED HERE — no display swap, no
       re-parenting, no src change. That is the whole trick: a <video> that is
       never detached or reloaded keeps playing and keeps its loop, its current
       time and its audio state across the mode change. Anything that removes
       #fs-video from the layout tree (display:none on an ancestor, moving the
       node) restarts or stalls playback. Style the WRAPPER, never the media. */
    #fs-overlay.fs-full-on {
      background: #000;
      /* The real Fullscreen API paints its own black backdrop; matching it
         here keeps the faux-fullscreen fallback identical. */
    }
    #fs-overlay.fs-full-on #fs-info,
    #fs-overlay.fs-full-on #fs-close,
    #fs-overlay.fs-full-on #fs-sound,
    #fs-overlay.fs-full-on #fs-dice,
    #fs-overlay.fs-full-on #fs-prev,
    #fs-overlay.fs-full-on #fs-next,
    #fs-overlay.fs-full-on #fs-scrim-top,
    #fs-overlay.fs-full-on #fs-artwork-controls,
    #fs-overlay.fs-full-on #fs-explore-hint,
    #fs-overlay.fs-full-on #fs-explore-exit {
      display: none !important;
    }
    /* Artwork fills the screen but is never cropped — fullscreen is the one
       place where seeing the WHOLE piece beats filling every pixel, so this
       overrides the immersive `cover` for both stills and video.
       ⚠️ FULLSCREEN IS INDEPENDENT OF THE FIT TOGGLE. `.fs-fit-contain` adds
       `padding: 108px` (80px on mobile) to the media so fitted art clears the
       brand block and the chrome — but fullscreen has removed all of that, so
       the padding is just a fat inset that stops the piece reaching the edges.
       Whether Fit was on or off when you entered must make no difference to
       what fullscreen looks like, hence `padding: 0`. The `!important`s are
       load-bearing: the fit rules carry three classes and would otherwise
       out-specify this one. */
    #fs-overlay.fs-full-on #fs-img,
    #fs-overlay.fs-full-on #fs-video,
    #fs-overlay.fs-full-on #fs-poster {
      object-fit: contain !important;
      padding: 0 !important;
      border-radius: 0 !important;
      width: 100%; height: 100%;
    }
    #fs-overlay.fs-full-on #fs-iframe { border-radius: 0 !important; }
    #fs-overlay.fs-full-on #fs-poster { display: none !important; }

    /* The one control. Rides the same .fs-chrome-hidden fade as normal chrome,
       so it appears on any move/tap and leaves on its own. */
    #fs-full-exit {
      display: none;
      position: absolute;
      /* ⚠️ SAME corner anchor and SAME box as #fs-close and #fs-explore-exit —
         24px/24px at 46px here, 16px/16px at 42px on mobile (see the rule
         beside #fs-close's own mobile override). All three are "get me out of
         this" controls that occupy the top-right in different modes, so they
         must land on exactly the same pixel; at 18px this one sat 6px inboard
         and the corner visibly shifted when you entered fullscreen. Change one,
         change all three. */
      top:   calc(24px + env(safe-area-inset-top, 0px));
      right: calc(24px + env(safe-area-inset-right, 0px));
      width: 46px; height: 46px; border-radius: 50%;
      background: rgba(0,0,0,.42); border: 1px solid rgba(255,255,255,.28);
      -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
      color: #fff; cursor: pointer;
      align-items: center; justify-content: center;
      z-index: 40;
      transition: opacity .35s ease, background .15s, border-color .15s, color .15s;
    }
    #fs-full-exit svg { width: 20px; height: 20px; }
    /* Coarse-pointer fallback. On a real pointer the accent hover in the
       `any-hover` block far below wins (two ids) and tints plate, border and
       glyph together — same treatment as every other viewer control. */
    #fs-full-exit:hover { background: rgba(0,0,0,.62); }
    #fs-overlay.fs-full-on #fs-full-exit { display: flex; }
    #fs-overlay.fs-full-on.fs-chrome-hidden #fs-full-exit { opacity: 0; pointer-events: none; }
    /* Pointer goes away with the UI and comes back with it — the cross fading
       out while an arrow still floats over the artwork half-defeats the mode.
       `cursor` is inherited, so the overlay rule further up already reaches most
       of the tree, but this is stated explicitly (and with the `*`) because the
       exception is exactly the interesting case: #fs-full-exit sets
       `cursor: pointer` on itself, and any future child rule would silently
       punch another hole. Nothing to do for the "comes back" half — fsShowChrome()
       drops .fs-chrome-hidden on any move or tap, which restores the cursor with
       the same gesture that restores the cross. */
    #fs-overlay.fs-full-on.fs-chrome-hidden,
    #fs-overlay.fs-full-on.fs-chrome-hidden * { cursor: none; }
    /* Belt and braces: closeViewer() already leaves fullscreen before handing
       the overlay over to the ambient home background, but if it ever did not,
       a stray cross floating over the home page would be baffling. Ambient wins
       over everything. (#fs-full itself needs no rule — it lives inside
       #fs-artwork-controls, which .fs-ambient already hides.) */
    #fs-overlay.fs-ambient #fs-full-exit { display: none !important; }

    /* Mode toggle hidden for now (immersive is the default). Delete this
       display:none to bring the toggle back. */
    #fs-mode { display: none !important; }
    #fs-mode-legacy {
      position: absolute;
      top:   calc(14px + env(safe-area-inset-top, 0px));
      right: calc(60px + env(safe-area-inset-right, 0px));
      background: #ffffff10; border: 1px solid #ffffff20;
      color: #fff; width: 34px; height: 34px; border-radius: 50%;
      cursor: pointer; align-items: center; justify-content: center;
      transition: background .15s, border-color .15s, color .15s; z-index: 10;
    }
    #fs-mode svg { width: 17px; height: 17px; }

    /* Blurred poster: the small cached thumbnail shown full-bleed and
       blurred the instant the viewer opens, then faded out once the real
       media (video / full image / iframe) is ready underneath. */
    #fs-poster {
      position: absolute; inset: 0; width: 100%; height: 100%;
      object-fit: cover; object-position: center;
      filter: blur(26px) saturate(1.15);
      transform: scale(1.1);            /* hide the soft blurred edges */
      opacity: 0; transition: opacity .55s ease, filter .4s ease, transform .4s ease;
      z-index: 4; pointer-events: none; display: none; background: #000;
    }
    /* Video couldn't actually play (unsupported container/codec — e.g. many
       Base pieces are .mov/quicktime, which only Safari decodes — or a file
       whose metadata is unreachable in reasonable time). Rather than leave
       the poster blurred forever (reads as "still loading" when nothing more
       is coming), sharpen it into the final, intentional static preview. */
    #fs-poster.fs-poster-sharp { filter: none; transform: none; }
    #fs-scrim-top { display: none; }

    /* Immersive: media covers the whole screen; info becomes a light
       bottom gradient (no glass box); bigger title; album-art thumbnail. */
    #fs-overlay.fs-immersive #fs-media-wrap { padding: 0; }
    #fs-overlay.fs-immersive #fs-img,
    #fs-overlay.fs-immersive #fs-video { object-fit: cover; border-radius: 0; }
    #fs-overlay.fs-immersive #fs-iframe { border-radius: 0; }
    /* No video chrome in immersive mode — clean, uninterrupted playback. */
    #fs-overlay.fs-immersive #fs-video::-webkit-media-controls,
    #fs-overlay.fs-immersive #fs-video::-webkit-media-controls-enclosure,
    #fs-overlay.fs-immersive #fs-video::-webkit-media-controls-panel { display: none !important; }

    /* Top scrim removed — only the bottom gradient remains. The top controls
       carry their own glass backgrounds for legibility (text-shadows removed
       Jul 26 2026). */
    #fs-overlay.fs-immersive #fs-scrim-top { display: none; }
    #fs-overlay.fs-immersive #fs-close,
    #fs-overlay.fs-immersive #fs-mode,
    #fs-overlay.fs-immersive #fs-prev,
    #fs-overlay.fs-immersive #fs-next {
      background: rgba(0,0,0,.42); border-color: rgba(255,255,255,.24);
      -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
    }
    /* Consistent hover across the whole glass control set. */
    #fs-overlay.fs-immersive #fs-close:hover,
    #fs-overlay.fs-immersive #fs-prev:hover,
    #fs-overlay.fs-immersive #fs-next:hover,
    #fs-overlay.fs-immersive #fs-dice:hover,
    #fs-overlay.fs-immersive #fs-fit:hover,
    #fs-overlay.fs-immersive #fs-sound:hover {
      background: rgba(0,0,0,.6); border-color: rgba(255,255,255,.42);
    }

    #fs-overlay.fs-immersive #fs-info {
      position: absolute;
      /* Bleeds --bleed past three edges; every padding below adds the same
         amount back, so the gradient grows outward and the text does not move. */
      left: calc(-1 * var(--bleed)); right: calc(-1 * var(--bleed));
      bottom: calc(-1 * var(--bleed));
      width: auto; max-width: none; margin: 0; min-height: 0;
      padding: 230px calc(6vw + var(--bleed)) calc(96px + var(--bleed) + env(safe-area-inset-bottom, 0px)) calc(24px + var(--bleed) + env(safe-area-inset-left, 0px));
      /* Single bottom gradient (no top scrim). Taller and softer so it
         reaches further up the screen — the info text + control row sit on
         one continuous darkened base and stay legible over bright artwork.
         Went .95/.9/.68/.4/.15 → .8/.72/.52/.28/.08 (too thin, art showed
         through too much) → thicker/darker than even the original here. */
      background: linear-gradient(to top, rgba(0,0,0,1) 0%, rgba(0,0,0,.92) 15%, rgba(0,0,0,.75) 38%, rgba(0,0,0,.5) 60%, rgba(0,0,0,.22) 80%, transparent 100%);
      backdrop-filter: none; -webkit-backdrop-filter: none;
      border: none; box-shadow: none; border-radius: 0;
      display: flex; flex-direction: column; align-items: flex-start;
      /* Same 14px between the title+collection group and the actions/signature
         rows at EVERY width. This used to be 44px on desktop and 14px below
         680px; the wide-screen version read as too airy, so the phone spacing
         is now the single value (the ≤680px rule no longer sets gap). */
      gap: 14px; overflow: visible; z-index: 7;
    }
    /* …but on a big screen 14px everywhere let the title/collection group run
       straight into the buttons. Rather than reopen the flex gap (which was
       tried at 44px and read as too airy, and would also loosen the
       buttons→signature seam), only the ONE seam under the collection line
       opens up: margin on the details card, so the actions row and signature
       stay the tight pair they are. 14 + 20 = 34px, roughly midway between
       the phone value and the old desktop one. */
    @media (min-width: 1024px) {
      #fs-overlay.fs-immersive #fs-details-card { margin-top: 20px; }
    }
    /* ⚠️ No text-shadow on viewer text — removed Jul 26 2026, don't add it
       back "for legibility over bright art". It produced visible artefacts:
       stylized titles carry dozens of combining marks and EACH mark casts its
       own shadow, so the halos stack into a grey smear around the glyphs. The
       backdrop gradient behind the info panel already does this job. */
    /* #fs-media-wrap is mathematically full-height in immersive mode (the
       info panel above is absolutely positioned, not a flex sibling that
       would shrink it) — so top:50% (the base #fs-spinner rule) is the true
       geometric centre of the viewer, both axes. */
        /* align-self: stretch is load-bearing — #fs-info is a column flex with
       align-items: flex-start, so without it this column shrink-wraps its
       widest line: a long unbroken (zalgo/stylized) title made it wider
       than the screen and pushed the line-clamp "…" off-screen. Stretched,
       the title wraps inside the padded area and the ellipsis is visible. */
    /* Title + collection are one tight group (gap 9px); the larger #fs-info
       gap below then separates that group from the actions/signature rows. */
    #fs-overlay.fs-immersive #fs-col-main { min-height: 0; gap: 9px; align-self: stretch; max-width: 100%; }
    #fs-overlay.fs-immersive #fs-name { max-width: 100%; }
    #fs-overlay.fs-immersive #fs-name {
      /* Was var(--fs-display) (104px max) — pulled back a step (Jul 20
         2026, by request) so the collection name below can carry more of
         the hierarchy. */
      /* line-height 1.04 → 1.18 (Jul 24 2026): at 1.04 the -webkit-box
         clamp cut the tops off titles whose combining marks stack above the
         cap height, and descenders on the last line. 1.18 gives the marks
         room without loosening the two-line block much. padding-top pairs
         with it so the first line's marks aren't clipped by overflow:hidden
         at the element's own top edge. */
      font-size: clamp(40px, 6.5vw, 88px); font-weight: 400; line-height: 1.18;
      padding-top: .12em;
      /* Clamp to 2 lines WITH a visible "…" — text-overflow was `clip`,
         which suppressed the line-clamp ellipsis and cut titles mid-glyph.
         overflow-wrap lets long unbroken (stylized-unicode) titles wrap
         into the clamp instead of running off the right edge. */
      white-space: normal; overflow: hidden; text-overflow: ellipsis;
      overflow-wrap: anywhere; word-break: normal;
      display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
    }
    #fs-overlay.fs-immersive #fs-desc {
      font-size: var(--fs-body); color: #ffffff9a; line-height: 1.5; min-height: 0;
      max-width: min(720px, 100%); -webkit-line-clamp: 3;
      overflow: hidden; overflow-wrap: anywhere; word-break: break-word;
    }
    #fs-overlay.fs-immersive .fs-link-btn { border-color: rgba(255,255,255,.3); color: #ffffffcc; }

    @media (max-width: 680px) {
      /* 601–680px: icons are STILL desktop-size (46px @ bottom:24 → 70px tall),
         so keep the desktop 94px bottom-padding here (≤600px shrinks both the
         icons AND this padding together — see the 600px block). This keeps the
         info-to-buttons gap constant while resizing instead of the text
         dropping onto the icons. */
      #fs-overlay.fs-immersive #fs-info { padding: 180px calc(24px + var(--bleed)) calc(94px + var(--bleed) + env(safe-area-inset-bottom, 0px)) calc(24px + var(--bleed) + env(safe-area-inset-left, 0px)); }
      /* ── Title + collection: ONE lockup, resized TOGETHER ────────────────
         Was clamp(34px, 9.5vw, 50px) over a 22px collection name; both step
         down ~14% here (Aug 2026, by request — the pair still read too big on
         a phone). They move as a pair on purpose: the collection sits directly
         under the title as a single title group, so shrinking only the title
         closes the size step between them and the two lines start reading as
         one wrapped sentence instead of a name and its collection.
         At a 390px viewport this is 31px over 19px, from 37px over 22px. */
      #fs-overlay.fs-immersive #fs-name { font-size: clamp(30px, 8vw, 44px); }
      /* min-height follows the font: it exists to keep the block's height
         constant across tokens (see the base rule), so it has to stay just
         above the rendered line box. Left at the base 30px it would reserve
         5px of dead space under every collection name and give back part of
         what the type step just saved.
         line-height 1.3 → 1.25 trims ~2.5px more off the line box (19 x 1.25 =
         23.75, vs 26px before), by request. It stays ABOVE the base rule's
         1.15 deliberately: 1.15 is the value that clipped the tops of the
         combining-mark towers these collection names carry, which is why the
         base is 1.3 in the first place. The extra room the smaller font buys
         is what makes 1.25 safe here and would not be at 22px — re-check the
         mark tops before going lower. */
      #fs-collection-badge { font-size: 19px; line-height: 1.25; min-height: 23px; }
    }

    /* Viewer chrome on phones: prev/next to the vertical-centre sides,
       smaller top controls + brand so the top row fits a narrow screen. */
    @media (max-width: 600px) {
      /* Bottom controls — dice + prev + next grouped bottom-right, all
         40px circles on one row 16px above the edge. Artwork cluster
         (info/counter pill + fit) stays bottom-left, sized down to match
         (40px tall, tighter padding). */
      /* ⚠️ #fs-sound is NOT in this list any more — it moved into
         #fs-artwork-controls and is a flex item, so the absolute-positioning
         props here (top/bottom/transform) mean nothing to it and would only
         suggest it is still pinned. Its phone size lives with its cluster
         siblings on the #fs-fit/#fs-explore line below. */
      #fs-dice, #fs-prev, #fs-next {
        width: 40px; height: 40px;
        top: auto; bottom: calc(16px + env(safe-area-inset-bottom, 0px)); transform: none;
      }
      #fs-artwork-controls {
        bottom: calc(16px + env(safe-area-inset-bottom, 0px));
        left: calc(16px + env(safe-area-inset-left, 0px));
        gap: 8px;
      }
      #fs-fit, #fs-explore, #fs-sound { width: 40px; height: 40px; }
      #fs-sound svg { width: 18px; height: 18px; }
      /* ── No fullscreen on phones ────────────────────────────────────────
         The button is removed from the row entirely (not just dimmed) because
         the mode has nothing left to offer here: the viewer is ALREADY edge to
         edge and chrome-free on a phone, so "fullscreen" would take away a UI
         that is already hidden and give back no pixels. On top of that, iOS
         Safari refuses requestFullscreen on anything that is not a <video>, so
         it could only ever be the faux-fullscreen fallback — a mode switch the
         user cannot see the point of, guarding an artwork they were already
         looking at full-bleed.
         ⚠️ The CSS is only half of it — fsSetFull() refuses to enter the mode
         at this width too, and drops out of it on a resize down. Hiding a
         control does not disable it. */
      #fs-full { display: none !important; }
      #fs-info-peek {
        height: 40px; padding: 0 13px 0 11px; gap: 6px;
      }
      #fs-counter { font-size: var(--fs-micro); }
      #fs-next { right: calc(16px + env(safe-area-inset-right, 0px)); left: auto; }
      #fs-prev { right: calc(64px + env(safe-area-inset-right, 0px)); left: auto; }
      #fs-dice { right: calc(112px + env(safe-area-inset-right, 0px)); left: auto; }
      #fs-fit svg, #fs-dice svg, #fs-prev svg, #fs-next svg, #fs-info-peek svg { width: 18px; height: 18px; }

      #fs-close { width: 42px; height: 42px; top: calc(16px + env(safe-area-inset-top, 0px)); right: calc(16px + env(safe-area-inset-right, 0px)); }
      #fs-close svg { width: 18px; height: 18px; }
      /* ⚠️ All three top-right EXIT controls shrink together, in this one block,
         precisely so they cannot drift apart — they are the same affordance in
         three different modes (normal / fullscreen / explore) and are never on
         screen at the same time, so any difference shows up as the corner
         jumping when you switch mode. #fs-explore-exit used to live in a
         max-width:680px block and broke exactly that way between 601–680px.
         Same offsets, same height; the explore pill is only wider because it
         carries an "Exit" label. */
      #fs-full-exit { width: 42px; height: 42px; top: calc(16px + env(safe-area-inset-top, 0px)); right: calc(16px + env(safe-area-inset-right, 0px)); }
      #fs-full-exit svg { width: 18px; height: 18px; }
      /* ⚠️ TWO ids on purpose. #fs-explore-exit's base rule sits ~1700 lines
         BELOW this block, and a media query adds no specificity — so a plain
         `#fs-explore-exit {…}` here loses to it on source order alone and the
         button silently kept its desktop size on phones. (#fs-close and
         #fs-full-exit need no such bump: their base rules come earlier.) */
      #fs-overlay #fs-explore-exit {
        top: calc(16px + env(safe-area-inset-top, 0px));
        right: calc(16px + env(safe-area-inset-right, 0px));
        width: 42px; height: 42px; padding: 0;
      }
      #fs-overlay #fs-explore-exit svg { width: 18px; height: 18px; }

      /* ≤600px: icons shrink to 40px @ bottom:16 (→ 56px tall); info
         bottom-padding of 78px keeps a clearer ~22px info-to-buttons gap
         (was 62px / ~6px — too tight, read as touching). */
      #fs-overlay.fs-immersive #fs-desc { -webkit-line-clamp: 2; max-width: 100%; }
      #fs-overlay.fs-immersive #fs-info {
        padding-left: calc(16px + var(--bleed) + env(safe-area-inset-left, 0px));
        padding-right: calc(16px + var(--bleed));
        padding-bottom: calc(78px + var(--bleed) + env(safe-area-inset-bottom, 0px));
      }
      .fs-home-title { font-size: var(--fs-mid); }
      .fs-home-sub { font-size: var(--fs-micro); letter-spacing: normal; }

      /* ── Fit mode on a phone: PER-EDGE, sized to the actual chrome ────────
         Was a flat `padding: 80px` on all four sides, inherited from the
         desktop rule's shape. On a 390px-wide phone that spent 160px — 41% of
         the screen width — on margin, and since `object-fit: contain` fits the
         art inside the CONTENT box, the artwork was then scaled down to
         whatever was left. A 16:9 piece came out 230x129 in an 844px-tall
         viewport, which is the "reduced a lot" report.

         The four values are measured against what is actually at each edge,
         not chosen for symmetry:
           top    16 + 42 close button + 18 = 76
           bottom 16 + 40 control row  + 10 = 66
           sides  nothing is there          = 14, a plain edge margin

         ⚠️ The top value CLEARS the close button, it does not merely reach it.
         It was 60 — two pixels under the button — so a wide piece butted
         straight up against the cross. The 18px gap is what stops the artwork
         and the control reading as one stuck-together object. It is also very
         nearly free: with `contain` a wide piece is width-limited, so those
         pixels were empty anyway, and for a tall piece it costs 16px of 735.
         Same 390x844 phone now yields a 362x718 content box, so that 16:9
         piece renders 362x204 and a 2:3 portrait 362x543 — a bit over 50%
         larger on the limiting axis in both cases.

         ⚠️ WHICH AXIS LIMITS IS THE ARTWORK'S BUSINESS, NOT THIS RULE'S.
         With `contain`, padding on the non-limiting axis costs nothing: a wide
         piece is width-limited and never reaches the top/bottom padding, a
         tall piece is height-limited and never reaches the sides. So there is
         no orientation branch here and there should not be one — the reason
         both wide AND tall artworks were shrinking is simply that BOTH axes
         were over-padded at once. Fixing each edge to its own chrome fixes
         both orientations at the same time. */
      #fs-overlay.fs-immersive.fs-fit-contain #fs-img,
      #fs-overlay.fs-immersive.fs-fit-contain #fs-video,
      #fs-overlay.fs-immersive.fs-fit-contain #fs-poster {
        padding:
          calc(76px + env(safe-area-inset-top, 0px))
          max(14px, env(safe-area-inset-right, 0px))
          calc(66px + env(safe-area-inset-bottom, 0px))
          max(14px, env(safe-area-inset-left, 0px));
      }
    }

    /* Phone held LANDSCAPE (or any short viewport): height is now the scarce
       axis for almost every piece, and 126px of vertical padding is 32% of a
       390px-tall screen. The controls have not moved, so the clearance still
       has to exist — it is just no longer affordable at full size, and a
       slightly tighter margin beats an artwork half the height of the screen.
       Top still clears the close button (16 + 42 = 58) — it was 46, i.e. UNDER
       it, so the artwork ran behind the cross in landscape. That one is not
       negotiable however scarce the height gets.
       Keyed on HEIGHT, not `orientation`, so a short desktop window gets it
       too and a tall tablet in landscape does not. */
    @media (max-width: 900px) and (max-height: 500px) {
      #fs-overlay.fs-immersive.fs-fit-contain #fs-img,
      #fs-overlay.fs-immersive.fs-fit-contain #fs-video,
      #fs-overlay.fs-immersive.fs-fit-contain #fs-poster {
        padding:
          calc(64px + env(safe-area-inset-top, 0px))
          max(14px, env(safe-area-inset-right, 0px))
          calc(52px + env(safe-area-inset-bottom, 0px))
          max(14px, env(safe-area-inset-left, 0px));
      }
    }

    /* ── Narrow phones: the bottom row is at capacity ──────────────────────
       Adding the sound button (Aug 2026) made the bottom row SEVEN controls on
       a video piece — pill + explore + fit + sound on the left, dice + prev +
       next on the right. Measured at the default phone spacing, the two
       clusters collide below ~380px: at 375 they sit 5px apart, at 360 they
       OVERLAP by 2px, so the speaker lands directly on the dice.

       This block buys back ~28px, and only where it is needed — 390px+ phones
       (every current iPhone and most Android) keep the roomier default rather
       than being compressed to solve an edge they do not have. At a true 360px
       viewport that turns the 2px overlap into a 19px gap.

       ⚠️ ONLY THE LEFT MARGIN AND THE PITCH ARE WORTH TOUCHING. Narrowing the
       RIGHT margin is the obvious move and it backfires: it drags the whole
       nav cluster leftward, so every pixel it saves on the right is a pixel
       taken straight out of the gap between the two clusters. Same for the
       pill, which the 600px block above has already tightened to 13/11 — there
       is far less left in it than it looks.

       ⚠️ MEASURE, DO NOT EYEBALL, if you add another control to either
       cluster: #fs-counter has a compact form on phones ("12", not "12 / 1867")
       that is painted in JS, and a PROGRAMMATIC viewport resize does not
       repaint it — the pill stays ~54px too wide and fakes an overlap that a
       real phone would not have. Call fsPaintCounter() before reading any
       rect, or you will chase a collision that is not there. (Same gotcha the
       counter's own comment in viewer.js warns about.)

       Still not enough at 320px (iPhone SE 1st gen), which overflows by ~21px.
       That width was already marginal before the sound button — the clusters
       met at a 2px gap — and fitting seven controls there means dropping one,
       not tightening further. */
    @media (max-width: 380px) {
      /* 8 → 6 between the four left-cluster items. */
      #fs-artwork-controls { gap: 6px; }
      #fs-info-peek { padding: 0 9px; gap: 6px; }
      /* 40 → 38 for every circle in the bottom row, both clusters together —
         they read as one row and must stay one size. Below the 44px tap-target
         guideline, as the 40px default already was; two overlapping controls
         is the worse failure of the two. */
      #fs-fit, #fs-explore, #fs-sound, #fs-dice, #fs-prev, #fs-next {
        width: 38px; height: 38px;
      }
      #fs-info-peek { height: 38px; }
      /* Nav pitch follows the smaller circle: 38 + 6 = 44 (was 48). Offsets are
         cumulative from the right edge — 16, 16+44, 16+88. */
      #fs-prev { right: calc(60px  + env(safe-area-inset-right, 0px)); }
      #fs-dice { right: calc(104px + env(safe-area-inset-right, 0px)); }
    }

    @media (max-width: 600px) {
      /* Mobile safe-zones: top clears the close button + counter; bottom
         clears the bottom icon row (40px @ bottom:16). Chevrons now live in
         that bottom row (like desktop), so the sides need only a small inset. */
      #fs-media-wrap {
        padding-top:    calc(60px + env(safe-area-inset-top, 0px));
        padding-bottom: calc(68px + env(safe-area-inset-bottom, 0px));
        padding-left:   max(16px, env(safe-area-inset-left, 0px));
        padding-right:  max(16px, env(safe-area-inset-right, 0px));
      }

      /* Mobile: same floating glass shelf as desktop, just sized for a
         phone — 8px inset from the screen edges, safe-area at the bottom.
         Title/desc stack above the details card, which now goes FULL WIDTH
         (was a fixed 300px on desktop/tablet) — the one piece of this
         layout that's actually responsive per screen size. */
      #fs-info {
        width: calc(100% - 16px);
        max-width: none;
        margin: 0 auto calc(8px + env(safe-area-inset-bottom, 0px));
        padding: 14px 16px;
        background: rgba(20, 20, 20, 0.78);
        backdrop-filter: blur(14px);
        -webkit-backdrop-filter: blur(14px);
        border: 1px solid rgba(255, 255, 255, 0.08);
        border-radius: 16px;
        box-shadow: 0 4px 14px rgba(0,0,0,.45), 0 14px 36px rgba(0,0,0,.32);
        flex-direction: column;
        align-items: stretch;
        gap: 10px;
      }
      #fs-name { font-size: var(--fs-mid); line-height: 26px; }

      #fs-col-main {
        min-height: 64px;
        justify-content: flex-start;
      }
      #fs-desc {
        -webkit-line-clamp: 2;
        min-height: 36px;             /* reserves 2 lines even when empty */
      }

      /* Details card goes full-width on mobile (was fixed 300px). */
      #fs-details-card { width: 100%; }

      /* ── Mobile shelf running order (Jul 26 2026, by request) ────────
         The desktop shelf packs price, all four buttons, the date/chain/
         type pills and the DJKERO sign-off into two loose wrapping rows,
         which on a phone spilled into a ragged 4-line block with pills
         orphaned under the wordmark. Fixed running order instead:
           1. price + Buy          2. Open file + Activity
           3. date · chain · type  4. DJKERO
         Everything also steps down a size — 34px controls and a 20px
         wordmark ate most of the shelf on a 375px screen. */
      #fs-actions-row {
        display: flex; flex-wrap: wrap; align-items: center;
        gap: 8px; min-height: 0;
      }
      /* display:contents on #fs-links hoists the two group wrappers into
         this flex container, so price / primary / secondary are siblings
         and the flex-basis below can force the line break. */
      #fs-links { display: contents; }
      /* The wrappers do the LINE BREAKING only — the buttons inside keep the
         desktop treatment and stay content-width. Only .fs-links-secondary
         takes a 100% basis, and that's what forces Open file / Activity onto
         their own row; it's the wrapper that spans the width, not the
         buttons, which sit left-aligned inside it at their natural size.
         (An earlier version grew the buttons to fill each row — full-width
         bars read as a mobile form, not as this design's link chips.) */
      .fs-links-primary   { display: flex; gap: 8px; flex: 0 1 auto;  min-width: 0; }
      .fs-links-secondary { display: flex; gap: 8px; flex: 0 0 100%;  min-width: 0;
                            justify-content: flex-start; flex-wrap: wrap; }
      .fs-links-primary   .fs-link-btn,
      .fs-links-secondary .fs-link-btn { flex: 0 0 auto; }
      #fs-price-badge { height: 30px; font-size: var(--fs-small); padding: 0 11px; }
      .fs-link-btn    { height: 30px; padding: 0 14px; font-size: var(--fs-micro); }

      /* Pills move ABOVE the sign-off: order -1 + a full-width basis makes
         #fs-badges-row its own line, leaving avatar + DJKERO on the last.

         margin-left is 0, not the base rule's 6px and not a negative pull:
         the PILL'S LEFT EDGE is the alignment reference here, so the pill box
         starts flush on the shelf's 16px inset like the title, the buttons
         and the avatar. Its 10px inner padding setting the label in from that
         edge is the pill working as intended — an earlier -10px pull that
         aligned the label instead pushed the pill visibly past everything
         else. Don't reintroduce it. */
      #fs-signature { gap: 8px; }
      #fs-signature #fs-badges-row {
        order: -1; flex: 0 0 100%; margin-left: 0; gap: 8px; min-height: 0;
      }
      #fs-sig-avatar { width: 24px; height: 24px; border-radius: 6px; }
      .fs-sig-text   { font-size: 16px; }
      #fs-meta-row   { height: 24px; padding: 0 10px; gap: 8px; }
      #fs-badges-row .type-badge,
      #fs-badges-row .chain-badge { height: 24px; padding: 0 10px; }
      /* Put the pills' hairline BACK on phones, undoing the strokeless
         treatment #fs-signature gives them on desktop. Borderless + a
         55 %-black fill is invisible against both the immersive gradient and
         the glass shelf, so the row read as a date label floating 10px right
         of the title/buttons/avatar — the pill box was flush the whole time,
         there was just no visible edge to prove it. With the stroke back the
         inset reads as the pill's interior, which is what it is. Fill is
         nudged up too so the shape holds over bright artwork. box-sizing is
         border-box globally, so the 1px doesn't change the 24px height. */
      #fs-signature #fs-meta-row,
      #fs-signature .chain-badge,
      #fs-signature .type-badge {
        border: 1px solid rgba(255, 255, 255, 0.18);
        background: rgba(0, 0, 0, 0.62);
      }

      /* Chevrons stay in the bottom-row cluster (set above, matching desktop);
         no side positioning here — that would overlap the bottom-left icons. */
    }


    /* ── Top hero gradient ────────────────────────────────
       Positioned absolutely at the top of the document so it sits
       BEHIND the menu and content (doesn't push them down). Because
       it's absolute (not fixed), it still scrolls with the page —
       once you've scrolled past its height, it's gone. The sticky
       nav (z-index 100) floats over it; other content paints above
       it via document order.

       It reaches the very top edge of the screen — through the iOS notch /
       Dynamic Island — because <body> no longer holds the content down by the
       safe-area inset (that moved to #toolbar, Aug 2026). It used to need a
       negative top and matching extra height to climb back through that
       padding; see the note on `top` below before reinstating either. */
    #top-gradient {
      position: absolute;
      /* Plain 0. The `calc(-1 * env(safe-area-inset-top))` that was here did
         one job — climb back up through <body>'s padding-top so the gradient
         still started at the true top of the page. body no longer has that
         padding (the inset moved to #toolbar), so this element's containing
         block already begins at the top and the compensation would now push
         the gradient ABOVE it. Both halves of that pairing changed together;
         they have to. */
      top: 0;
      /* Same hairline fix as the viewer's #fs-info, and free here: <body>
         already carries `overflow-x: clip`, so the overhang is clipped without
         body becoming a scroll container (which is why it is `clip` and not
         `hidden` — see the note there). No bottom bleed is needed: this
         gradient ENDS on var(--bg), the page's own background, so there is
         nothing for a seam to reveal. No padding compensation either — the
         element has no content. */
      left: calc(-1 * var(--bleed)); right: calc(-1 * var(--bleed));
      width: auto;
      height: 280px;
      background: linear-gradient(to bottom, #000 0%, var(--bg) 100%);
      pointer-events: none;
      z-index: 0;
    }
    @media (max-width: 680px) {
      #top-gradient { height: 160px; }
    }

    /* ── Loader overlay ──────────────────────────────────── */
    #loader-overlay {
      position: fixed; inset: 0; z-index: 1000;
      background: var(--bg);
      display: flex; flex-direction: column;
      align-items: center; justify-content: center;
      padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
      transition: opacity .7s ease;
    }
    #loader-overlay.fade-out { opacity: 0; pointer-events: none; }
    /* Card surface — matches the gallery's elevated-card aesthetic
       (var(--white) card bg, 1px border, soft shadow, generous radius
       similar to the fullscreen info shelf). Starts compact and grows
       organically as each log line slides in — block-level box-shadow
       and border are transition-friendly properties so the card stays
       sharp during height changes. The entry animation pops it in
       from a slightly smaller size for a "settling in" feel. */
    #loader-card {
      display: flex; flex-direction: column; align-items: center;
      gap: 16px;
      padding: 0;
      width: min(320px, calc(100vw - 32px));
      background: transparent;
      border: none;
      box-shadow: none;
      animation: cardPop .35s cubic-bezier(.2,.7,.2,1) both;
    }
    @keyframes cardPop {
      from { opacity: 0; transform: scale(.96); }
      to   { opacity: 1; transform: scale(1); }
    }
    @media (max-width: 480px) {
      #loader-card { gap: 14px; }
    }
    /* The loader is the ONLY place .fs-home-title still renders (#fs-home
       was removed from the viewer, Jul 24 2026), and the ≤600px viewer-chrome
       block above drops it to --fs-mid (13px) — a leftover from when this
       class lived in the viewer. At 13px the wordmark read as a caption next
       to the avatar instead of a logo, so the loader takes it back to a
       proper size on phones (desktop stays 40px). Scoped to #loader-card so
       the viewer rule is untouched if that block ever returns. */
    @media (max-width: 600px) {
      #loader-card .fs-home-title { font-size: 30px; }
    }
    /* ⚠️ The loader's subtitle does NOT keep the #ffffff66 the class carries
       everywhere else, and that is the point. Here it sits directly above the
       log lines, which render at var(--text) in the same face at the same 13px
       — so the only difference between "DIGITAL ART" and "READING THE
       BLOCKCHAIN…" was 40% white against 92%, and the gap read as a size
       difference rather than a deliberate dim. Matching the log makes the block
       one piece of terminal output. Scoped to #loader-card so the class's own
       secondary-subtitle rule is untouched anywhere it comes back. */
    #loader-card .fs-home-sub { color: var(--text); }
    /* Stretch to fill the card so the bar inside can use the full
       inner width — without this the column flex hugs its widest
       child (the title), leaving the bar narrow. */
    #loader-center { display: flex; flex-direction: column; align-items: center; gap: 24px; align-self: stretch; }
    /* Title + subtitle sit tight together as a single heading block */
    #loader-headings { display: flex; flex-direction: column; align-items: center; gap: 2px; }
    #loader-avatar {
      width: 56px; height: 56px; border-radius: 8px;
      border: 1px solid rgba(255, 255, 255, 0.2);
      object-fit: cover; flex-shrink: 0;
    }
    #loader-bar-wrap {
      width: 100%;
      display: flex; align-items: center; justify-content: center;
      position: relative;
    }
    /* Track has a visible background tint so the bar feels substantial
       even when the fill is near 0 % — empty/early state shouldn't look
       like an absent line. */
    #loader-bar-track {
      width: 100%; height: 3px;
      background: rgba(255, 255, 255, 0.08);
      border-radius: 999px; overflow: hidden;
      transition: opacity .3s;
    }
    #loader-bar {
      height: 100%; width: 0%; background: var(--accent);
      border-radius: 999px;
      /* width is driven frame-by-frame from JS — see loadAll() */
    }
    #loader-log {
      font-family: inherit;
      font-size: var(--fs-small); line-height: 1.7;
      color: var(--sub);
      width: 100%;
      text-align: left;
      /* No min-height — the card starts compact and grows as each
         log line slides in, driven by the per-line max-height
         animation below. */
    }
    .loader-line {
      display: flex; justify-content: space-between; align-items: baseline; gap: 12px;
      overflow: hidden;
      animation: logIn .26s cubic-bezier(.2,.7,.2,1) forwards;
    }
    .loader-line.ok   { color: var(--text); }
    .loader-line.ok .loader-pct { color: var(--sub); }
    .loader-line.err  { color: #f87171; }
    .loader-line.sub  { color: #ffffff40; font-size: var(--fs-micro); margin-top: 2px; }
    .loader-pct { font-size: var(--fs-micro); color: #4a4a4a; flex-shrink: 0; }
    .loader-pct--ok { color: var(--accent); animation: logIn .2s ease forwards; }
    @keyframes logIn {
      from { opacity: 0; max-height: 0;    transform: translateY(-2px); }
      to   { opacity: 1; max-height: 32px; transform: translateY(0); }
    }
    #loader-cursor {
      /* Block-level on its own row sits flush at the left edge of the
         log column. The previous inline-block + margin-left + vertical-align
         combo would float it a couple pixels off-axis relative to the
         text above. */
      display: block; width: 6px; height: 13px; border-radius: 1px;
      background: var(--accent);
      margin: 4px 0 0;
      animation: blink .75s step-end infinite;
    }
    @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }

    /* Loading-mode (Pages/Scroll) toggle hidden per request — paged mode
       stays the default; remove this rule to bring the control back. */
    #mode-control { display: none !important; }

    /* ── Sources button + overlay ────────────────────────────────── */
    /* Sources button used to live in the toolbar as an icon control —
       hidden now that the masthead stats block opens the report (same
       "hide via CSS, keep markup + JS" pattern as the mute button).
       Delete the display:none line to bring the toolbar icon back. */
    #sources-btn { display: none !important; }
    #sources-btn.tb-icon-btn { padding: 0 9px; }
    #sources-btn.tb-icon-btn .tb-icon { display: inline-flex; }
    #sources-btn:hover { border-color: var(--accent); color: var(--accent); }
    @media (max-width: 845px) { #sources-btn.tb-icon-btn { padding: 0; } }

    /* ── Stats section — inline, right under the gallery ────────────
       Was a #sources-overlay modal; now a full-width section between the
       pager and the footer so the archive numbers read like a live
       dashboard at the end of the scroll. Same z-index/background rules
       as #gallery/footer (solid wall normally, transparent in ambient
       mode over the fixed veil — see body.ambient-bg overrides). */
    #stats {
      border-top: 1px solid var(--border);
      padding: clamp(18px, 2.5vw, 30px) 16px clamp(36px, 5vw, 56px);
      position: relative; z-index: 1;
      background: var(--bg);
      /* Ticker click scrolls here — land below the sticky glass toolbar
         instead of underneath it. */
      scroll-margin-top: 64px;
    }
    #stats-inner { max-width: 1240px; margin: 0 auto; }

    /* ── "Live sync" terminal readout (top of the sources report) ──────
       Frames the archive numbers as a live pull off-chain: window chrome
       with a pulsing status LED and a ticking UTC clock, a shell prompt, one
       aligned status row per chain endpoint, a stats readout, and a trailing
       idle prompt. Everything stays at --fs-ui — the site runs ONE type
       size, so hierarchy here comes from colour, rules and spacing only. */
    .src-terminal {
      font-family: var(--font-code, monospace);   /* must stay fixed-pitch */
      /* Capped, not full-bleed: stretched to the full 1240px the dotted
         leaders turn into 800px of dead space and the stats row below has to
         hold 5 columns ~230px wide for 12-character values. A terminal
         window should look like a window. */
      max-width: 760px;
      margin-inline: auto;           /* centred in its full-width grid area */
      /* ⚠️ width:100% IS LOAD-BEARING, and non-obvious next to the auto margins
         above. An auto inline margin on a grid item TURNS OFF the default
         `stretch`, so the item stops filling its track and sizes to its own
         max-content instead. With `white-space: nowrap` content inside (the
         .src-term-note snapshot line, the chain-row cells) that max-content was
         523px — so the terminal rendered 523px wide inside a correctly-sized
         392px track and hung off the right edge of the screen.
         width:100% restores "fill the track"; max-width still caps it at 760px
         on desktop, and the auto margins still centre it once the track is
         wider than that. Remove either one and the other stops working. */
      width: 100%;
      color: var(--sub); line-height: 1.6;
      font-size: var(--fs-ui);
      /* Lowercase like a real shell — overrides body's global uppercase; the
         bar title + chain names opt back into caps below. */
      text-transform: none;
      /* ── One block per command (Jul 30 2026) ──────────────────────────
         This was a single bordered window with the title bar, both commands,
         their output and the caret all stacked inside it. It is now a column
         of separate framed blocks with a small gap: title bar, `sync`,
         `sales`, `ticker`, and the trailing prompt. Four short readouts are
         much easier to scan than one tall pane of monospace, and each block
         visually owns its own command's output.
         The frame moved OUT of this rule and onto .src-term-block below —
         .src-terminal is now only the stack. */
      display: flex; flex-direction: column;
      gap: 8px;
      /* ⚠️ min-width:0 IS WHAT KEEPS THE TERMINAL INSIDE THE SCREEN. As a grid
         item this defaults to `auto`, which means "never shrink below your
         max-content width" — and this terminal contains `white-space: nowrap`
         text (.src-term-note, the chain-row cells). One long nowrap line then
         sets the floor for the whole block: measured at a 428px viewport it
         forced .src-terminal to 523px inside a 392px column, i.e. ~130px off
         the right edge.
         The note's own overflow:hidden + text-overflow:ellipsis CANNOT save it,
         because its width is being DERIVED from the text rather than
         constrained by a parent — the ellipsis only engages once an ancestor
         refuses to grow. That refusal is this line, and the matching one on
         .src-term-block below; both are needed, one per nesting level.
         ⚠️ The overflow was INVISIBLE to a scrollWidth check on <html>, which
         carries overflow-x:hidden for the --bleed gradients: the page reported
         no horizontal overflow while the terminal hung off the side. Measure
         the element, not the document. */
      min-width: 0;
    }
    /* ── The command blocks, two-up on a wide screen (Aug 2026) ───────────
       The stack above is right on a phone and wrong on a desktop: six framed
       blocks down one 760px strip, with the other half of a 1240px page empty
       beside it, read as "one long block" rather than as a set of commands.

       MULTICOL, not a grid, and that is the load-bearing choice. Five of these
       blocks fill from the network — collects, listings, mints, gas, ticker —
       so their heights are not known when the markup is built and change again
       on every refresh. A hand-assigned 2-column grid has to guess which
       blocks balance, and guesses wrong the moment a marketplace returns six
       rows instead of two; `columns: 2` re-balances itself against whatever
       actually arrived. It also means adding a seventh command needs no layout
       change at all.

       ⚠️ `break-inside: avoid` is not optional — without it a block splits
       across the column boundary, so a command's output lands under a
       different command's prompt. That is worse than the single column.
       ⚠️ The wrapper must be `display: block`: `columns` does nothing on a
       flex container, and this is a flex column below the breakpoint.
       ⚠️ Spacing is margin-bottom, not gap, for the same reason — multicol has
       no gap between items, only `column-gap` between COLUMNS. */
    .src-term-cols { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
    @media (min-width: 1024px) {
      /* Uncapped so it fills its full-width grid area (`term term`), which
         #stats-inner already limits to 1240px. Each column then lands near
         615px — NARROWER than the 760px single column it replaces, so the
         dotted leaders the 760px cap exists to protect get tighter, not
         looser. Don't remove that cap upstream on the strength of this. */
      .src-terminal { max-width: none; }
      .src-term-cols {
        display: block;
        columns: 2; column-gap: 8px;
      }
      .src-term-cols > .src-term-block {
        break-inside: avoid; -webkit-column-break-inside: avoid;
        margin-bottom: 8px;
      }
    }
    .src-term-block {
      min-width: 0;                  /* see the note on .src-terminal above */
      background: #000;
      border: 1px solid var(--border); border-radius: 6px;
      overflow: hidden;              /* clips the title bar's fill to the radius */
      box-shadow: inset 0 0 80px rgba(0, 255, 0, .025);
      padding: 12px 14px 11px;
    }
    /* The title bar fills its block edge to edge, so that block carries no
       padding of its own — otherwise the lifted surface would float inside a
       black border instead of being the block. */
    .src-term-block--bar { padding: 0; }
    /* The trailing `$` is a one-line block; the standard padding makes it look
       like an empty pane rather than a prompt waiting for input. */
    .src-term-block--caret { padding: 8px 14px; }
    /* Title bar — the one lifted surface, so it reads as window chrome
       rather than just another content row. */
    .src-term-bar {
      display: flex; align-items: center; gap: 9px;
      padding: 9px 14px;
      background: #131313;
    }
    .src-term-title { color: var(--text); text-transform: uppercase; }
    /* Slow breathing pulse, so the tag reads as a heartbeat rather than a
       static label. ⚠️ The TEXT COLOUR is animated in opposition to the
       background, which is the whole point: fading the fill alone made the word
       fade out with it and looked like the tag was disappearing (or the page
       was dropping its connection — the opposite of what "live" should say).
       Instead the two swap roles across the cycle:
         filled beat — lime plate, ink text
         ghost beat  — near-transparent plate, LIME text
       so "LIVE" is legible at full contrast at every point in the animation;
       only its treatment changes. Never animate `opacity` here for the same
       reason. Background stays a faint tint rather than fully transparent so
       the pill keeps its shape and nothing shifts. No border/padding change
       either — the box must stay exactly the same size or the bar's flex row
       will jitter once a second. */
    .src-term-live {
      color: var(--accent-ink); background: var(--accent);
      padding: 0 6px; border-radius: 2px; text-transform: uppercase;
      animation: term-live-pulse 2.8s ease-in-out infinite;
    }
    @keyframes term-live-pulse {
      0%, 100% { background: var(--accent);
                 color: var(--accent-ink); }
      50%      { background: color-mix(in srgb, var(--accent) 12%, transparent);
                 color: var(--accent); }
    }
    @media (prefers-reduced-motion: reduce) {
      .src-term-live { animation: none; }
    }
    /* Clock lives far-right in the chrome, where a status readout belongs —
       it used to be buried at the end of the third footer line. */
    .src-term-clock { margin-left: auto; font-variant-numeric: tabular-nums; }
    .src-term-clock [data-src-clock] { color: var(--text); }

    /* Legacy wrapper — the blocks carry their own padding now. Kept as a
       no-op so any stale cached markup still renders sanely. */
    .src-term-body { padding: 0; }
    /* Wraps rather than nowrap — a wrapped shell command is authentic, and
       nowrap forced the whole block into horizontal scroll on mobile. */
    .src-term-cmd { color: var(--text); overflow-wrap: anywhere; }
    .src-term-prompt { color: var(--accent); margin-right: 8px; }

    /* One row per chain. The column count MUST match the cell count in
       buildSourcesReport() — six cells in a five-column grid is what made
       "200 OK" wrap onto its own row inside the 14px dot column. */
    .src-term-lines {
      margin: 10px 0; padding: 8px 0;
      /* ⚠️ border-TOP removed by request — the chain rows read as the direct
         output of the `$ djkero sync …` command above them, and a rule between
         a command and its own output cut that link. The bottom one stays: it
         separates this output from the NEXT block (the stats readout), which
         is a different thing. Same principle as .src-fx-head's rule, which
         sits above its command rather than under it. */
      border-bottom: 1px solid var(--border);
    }
    /* ⚠️ FIVE COLUMNS, FIVE CELLS — count them if you touch either.
       Was six of each; the endpoint cell was moved out and then removed
       because it was by far the longest string in the row and, once it became
       multi-source ("verse · graphql + alchemy · snapshot"), it left nothing
       for the rest of the row on a phone.
       ⚠️ The row was NOT broken before that — an earlier version of this note
       claimed grid was silently collapsing `.tl-ms` to 0px, and that was a
       misreading: the base template really did declare six tracks, and the
       zero-width `.tl-ms` seen at 375px is the deliberate `display: none` in
       the ≤680px block further down. Recorded because the wrong diagnosis is
       the more plausible-looking one. */
    .src-term-line {
      display: grid;
      grid-template-columns: 12px minmax(0, 1fr) auto auto auto;
      gap: 0 12px; align-items: baseline;
      padding: 2px 0; white-space: nowrap;
    }
    .src-term-line .tl-dot   { color: var(--accent); }
    /* Dotted leader eats the slack so the numbers stay hard-right without a
       dead 900px gap sitting in the middle of the row. It moved from .tl-ep
       (deleted) onto the chain name, which is now the row's only flexible
       column. */
    .src-term-line .tl-chain {
      color: var(--text); text-transform: uppercase;
      display: flex; align-items: baseline; gap: 12px; min-width: 0;
    }
    .src-term-line .tl-chain::after {
      content: ''; flex: 1 1 auto; min-width: 12px;
      border-bottom: 1px dotted var(--border); transform: translateY(-4px);
    }
    .src-term-line .tl-n  { color: var(--accent); text-align: right; font-variant-numeric: tabular-nums; min-width: 5ch; }
    .src-term-line .tl-ms { text-align: right; font-variant-numeric: tabular-nums; min-width: 6ch; }
    .src-term-line .tl-ok { color: var(--accent); }

    .src-term-note {
      margin-top: 6px; padding-top: 6px;
      border-top: 1px dotted var(--border);
      color: #5f5f5f; white-space: nowrap;
      overflow: hidden; text-overflow: ellipsis;
    }

    /* ── `djkero sales` rows ─────────────────────────────────────────────
       Same column language as .src-term-line (dot · name with a dotted
       leader · right-aligned facts) so the two blocks read as output from
       one program. The price column keeps its slot even when empty —
       Ethereum rows carry no price by design (Verse reports no currency
       unit; see fetchVerseSales in data.js), and collapsing the column
       would misalign the dates between the two chains. */
    .src-sales-row {
      display: grid;
      grid-template-columns: 12px minmax(0, 1fr) auto auto auto;
      gap: 0 12px; align-items: baseline;
      padding: 2px 0; white-space: nowrap;
    }
    /* Tint the glyph, never its background — see the note in salesRowHTML.
       The chain vars are all accent-derived under the single-accent system, so
       these read as one family with a slight per-chain shift rather than as
       three different colours. */
    .src-sales-row .sl-dot  { color: var(--accent); }
    .src-sales-row .sl-dot--tez  { color: var(--accent); }
    .src-sales-row .sl-dot--eth  { color: color-mix(in srgb, var(--accent) 62%, #0a1f0a); }
    .src-sales-row .sl-dot--base { color: color-mix(in srgb, var(--accent) 62%, #0a1f0a); }
    .src-sales-row .sl-name {
      display: flex; align-items: baseline; gap: 12px; min-width: 0;
      color: var(--text);
    }
    .src-sales-row .sl-name > span {
      overflow: hidden; text-overflow: ellipsis; min-width: 0;
    }
    .src-sales-row .sl-name::after {
      content: ''; flex: 1 1 auto; min-width: 12px;
      border-bottom: 1px dotted var(--border); transform: translateY(-4px);
    }
    .src-sales-row .sl-who  { color: #5f5f5f; }
    .src-sales-row .sl-px   { color: var(--accent); text-align: right; font-variant-numeric: tabular-nums; }
    .src-sales-row .sl-date { text-align: right; font-variant-numeric: tabular-nums; }
    .src-sales-wait { color: #5f5f5f; }

    /* ── `gas` block ─────────────────────────────────────────────────────
       Three right-aligned tiers under a header row, sharing ONE set of column
       tracks with the rows below it — the header and the numbers must line up,
       and separate grids would each size their own `auto` columns to their own
       content (the same trap noted on the FX rows). */
    /* Six columns: dot · chain · SPARKLINE · slow · normal · fast.
       The sparkline takes the flexible track (it was the chain name's before) so
       the graph absorbs whatever width the block has spare, and `align-items`
       is overridden to center just for that cell below — everything else in the
       row is text on a shared baseline, but a graph has no baseline to sit on.
       The standalone base-fee cell that used to sit between the graph and the
       tiers is gone (Aug 2026) — the tiers already include the base fee. */
    .src-gas-head, .src-gas-row {
      display: grid;
      grid-template-columns: 12px 8.5ch minmax(40px, 1fr) 5.5ch 5.5ch 5.5ch;
      gap: 0 12px; align-items: baseline;
      padding: 2px 0; white-space: nowrap;
    }
    /* ⚠️ The chain column is a FIXED 8.5ch, not `auto`. Every .src-gas-row is its
       own independent grid, so an auto track is sized per row — "ETHEREUM" (8ch)
       against "BASE" (4ch) made the two sparklines start 31px apart and drew the
       tier numbers out of line. 8.5ch is the longest name plus a little slack;
       widen it if a chain with a longer name is ever added. */
    .src-gas-row .gas-spark { display: block; align-self: center; height: 22px; min-width: 0; }
    .src-gas-row .gas-spark .fx-svg { display: block; width: 100%; height: 22px; }
    .src-gas-head {
      color: #4d4d4d; font-size: var(--fs-micro);
      text-transform: uppercase;
    }
    /* n+4 — dot · chain · sparkline occupy 1/2/3, so the tier headings are 4/5/6. */
    .src-gas-head span:nth-child(n+4) { text-align: right; }
    .src-gas-row .gas-chain { color: var(--text); text-transform: uppercase; }
    /* (.gas-base and its dotted leader were removed with the column — the
       sparkline is what fills that space now, and it needs no leader to pull
       the eye across to the numbers.) */
    .src-gas-row .gas-t {
      text-align: right; font-variant-numeric: tabular-nums; color: var(--accent);
    }
    /* Same six columns on a phone, just tighter — there is no longer a base-fee
       cell to drop, so the layout no longer changes shape at this breakpoint. */
    @media (max-width: 560px) {
      .src-gas-head, .src-gas-row {
        grid-template-columns: 12px 8.5ch minmax(28px, 1fr) 5.5ch 5.5ch 5.5ch;
        gap: 0 9px;
      }
      .src-gas-row .gas-spark, .src-gas-row .gas-spark .fx-svg { height: 18px; }
    }
    .src-sales-foot {
      margin-top: 5px; padding-top: 5px;
      border-top: 1px dotted var(--border);
      color: #4d4d4d;
    }
    /* The buyer/edition column is the first thing to go when there is no room
       for it — the piece, the price and the date are the row's actual content. */
    @media (max-width: 560px) {
      .src-sales-row { grid-template-columns: 12px minmax(0, 1fr) auto auto; gap: 0 9px; }
      .src-sales-row .sl-who { display: none; }
    }

    /* Stats readout — a wrapping grid of value/label pairs. Replaced three
       white-space:nowrap lines that ran straight out of the box. */
    /* Wrapping flex, NOT a fixed-column grid: the pairs vary from 9 to 17
       characters, so any rigid column count either stretches the short ones
       across dead space or clips the long ones. Content-sized items that
       reflow read like real wrapped terminal output at every width. */
    .src-term-out {
      display: flex; flex-wrap: wrap;
      gap: 2px 18px; margin-bottom: 10px;
    }
    .src-term-out .tk { white-space: nowrap; }
    .src-term-out .tk i { font-style: normal; color: var(--text); font-variant-numeric: tabular-nums; }

    /* ── Live FX ticker (foot of the terminal) ──────────────────────
       Spot + 24h sparkline per chain, polled by wireFxTicker() in
       viewer.js. Same row grammar as .src-term-line above (dot, label,
       stretchy middle, hard-right numbers) so it reads as more terminal
       output rather than a widget bolted on. */
    /* ⚠️ The separator that used to sit here (border-top + padding/margin) is
       GONE. It marked the ticker as its own section back when every command
       shared one terminal window — now each command has its own framed block,
       so the frame already does that job and the hairline just drew a stray
       rule a few pixels inside the top of the ticker's own box. Don't put it
       back without also un-splitting the blocks. */
    .src-fx-head { }
    .src-fx { margin: 10px 0 10px; }
    /* ⚠️ The two rows must share ONE set of column tracks, or each row sizes
       its `auto` columns to its OWN content — "1,868.46" is wider than
       "0.2049", so the 1fr sparkline column came out a different width on
       each row and the two graphs visibly disagreed. Subgrid is what makes
       the tracks common while keeping .src-fx-row a real box (needed for the
       flash animation, which `display: contents` would have thrown away). */
    .src-fx { display: grid; grid-template-columns: 12px minmax(64px, auto) minmax(0, 1fr) auto auto; }
    .src-fx-row, .src-fx-foot { grid-column: 1 / -1; }
    .src-fx-row {
      display: grid;
      /* Fallback for no-subgrid engines: same track list as the parent. Rows
         can still disagree there, but nothing breaks. The @supports block
         below upgrades it where subgrid exists. */
      grid-template-columns: 12px minmax(64px, auto) minmax(0, 1fr) auto auto;
      gap: 0 12px; align-items: center;
      padding: 3px 0; white-space: nowrap;
    }
    @supports (grid-template-columns: subgrid) {
      .src-fx-row { grid-template-columns: subgrid; }
    }
    .src-fx-row .fx-dot { color: var(--accent); }
    .src-fx-row .fx-sym { color: var(--text); text-transform: uppercase; }
    .src-fx-row .fx-px {
      color: var(--text); text-align: right;
      font-variant-numeric: tabular-nums; min-width: 9ch;
    }
    .src-fx-row .fx-chg {
      text-align: right; font-variant-numeric: tabular-nums; min-width: 8ch;
    }
    /* ⚠️ Up AND down are both lime — no magenta, by request. Direction is
       carried by the ▲/▼ glyph alone, which is why .is-up / .is-dn still
       exist as classes even though they now paint identically: fxPaint()
       toggles them, and keeping the hooks means restoring a two-colour
       scheme is a CSS-only change. Earlier versions used magenta for the
       down state; every trace of it in this block was removed. */
    .src-fx-row .fx-chg.is-up,
    .src-fx-row .fx-chg.is-dn { color: var(--accent); }
    /* The <span> is the sizing box; the SVG inside stretches to it. Height is
       fixed so a row can't jump when the sparkline swaps in. */
    .src-fx-row .fx-spark { display: block; height: 30px; min-width: 0; }
    .src-fx-row .fx-svg { display: block; width: 100%; height: 30px; }
    /* Flash the row on a price move — lime in BOTH directions, by request.
       Two separate keyframe names are still needed even though they are now
       identical: the flash restarts by removing and re-adding a class, and
       an up-move followed by a down-move has to swap to a DIFFERENT class
       name to retrigger reliably (see the reflow poke in fxPaint()). */
    @keyframes fx-flash-up { from { background: color-mix(in srgb, var(--accent) 20%, transparent); } to { background: transparent; } }
    @keyframes fx-flash-dn { from { background: color-mix(in srgb, var(--accent) 20%, transparent); } to { background: transparent; } }
    .src-fx-row.fx-up { animation: fx-flash-up .9s ease-out 1; }
    .src-fx-row.fx-dn { animation: fx-flash-dn .9s ease-out 1; }
    .src-fx-foot {
      display: flex; gap: 12px; align-items: baseline;
      margin-top: 6px; padding-top: 7px;
      border-top: 1px solid var(--border);
      color: var(--sub);
    }
    /* Yellow, not magenta — the block is all-lime by request, so the one
       genuinely abnormal state needs a colour that still reads as a warning
       without reintroducing pink. Palette's own --yellow, not a new value. */
    .src-fx-foot .is-err { color: var(--yellow); }
    .src-fx-foot .fx-next { margin-left: auto; font-variant-numeric: tabular-nums; }
    /* Last-known numbers stay legible but visibly demoted while the feed is
       down — the alternative (blanking them) reads as broken, not honest. */
    .src-fx.is-stale .src-fx-row { opacity: .55; }
    @media (prefers-reduced-motion: reduce) {
      .src-fx-row.fx-up, .src-fx-row.fx-dn { animation: none; }
    }
    @media (max-width: 680px) {
      /* ⚠️ The sparkline SHRINKS here, it does not disappear — an earlier
         version hid it outright on the theory that it compresses to noise.
         It stays because fxSpark() now downsamples to roughly one point per
         3px of measured width, so a narrow cell draws a coarser line rather
         than 96 candles of mush.
         Everything else gives up its slack to it: the gaps tighten and the
         price/change cells drop their ch-based min-widths (they were sized
         for the desktop row and are wider than the content needs). The
         sparkline track keeps a `minmax(0, …)` floor so that if a very
         narrow viewport still can't fit everything, the graph gives way
         instead of pushing the row into overflow. */
      /* Track list moves to the PARENT here too — see the subgrid note above;
         overriding it on the row alone would be ignored under subgrid. */
      .src-fx { grid-template-columns: 12px auto minmax(0, 1fr) auto auto; }
      .src-fx-row { gap: 0 8px; }
      @supports not (grid-template-columns: subgrid) {
        .src-fx-row { grid-template-columns: 12px auto minmax(0, 1fr) auto auto; }
      }
      .src-fx-row .fx-px,
      .src-fx-row .fx-chg { min-width: 0; }
      /* Shorter too, so a narrower graph doesn't read as a tall thin box. */
      .src-fx-row .fx-spark,
      .src-fx-row .fx-svg { height: 22px; }
    }

    .src-caret {
      display: inline-block; width: 7px; height: 11px;
      background: var(--accent); vertical-align: baseline;
      animation: tb-blink 1.1s steps(1) infinite;
    }
    /* Narrow: drop latency (the least load-bearing number) and tighten the
       gutters. ⚠️ FOUR columns, because `.tl-ms` below is `display: none` and a
       hidden grid item takes no track — this said five while the row had six
       cells, which was right then and became one track too many when the
       endpoint cell moved out. A spare track is harmless (it collapses to 0)
       but it puts the dotted leader on the wrong column: `.tl-chain` has to be
       the flexible one, or the leader has nothing to eat and the count drifts
       left instead of sitting hard-right. */
    @media (max-width: 680px) {
      .src-term-line {
        grid-template-columns: 12px minmax(0, 1fr) auto auto;
        gap: 0 9px;
      }
      .src-term-line .tl-ms { display: none; }
      .src-term-bar { gap: 7px; padding: 8px 11px; }
      /* Padding lives on the blocks now, not the (empty) body wrapper. */
      .src-term-block { padding: 11px; }
      .src-term-block--bar { padding: 0; }
      .src-term-block--caret { padding: 7px 11px; }
      .src-terminal { gap: 7px; }
    }

    /* Breakable layout: the report sections are grid items. One column on
       mobile (natural order), two columns from tablet width, and on wide
       screens the long collections table moves beside the stack as its
       own scrolling panel. minmax(0,…) so tables can shrink, not overflow. */
    #sources-body {
      display: grid; grid-template-columns: minmax(0, 1fr);
      gap: clamp(20px, 3vw, 30px) clamp(26px, 4vw, 52px);
    }
    @media (min-width: 760px) {
      #sources-body {
        grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
        grid-template-areas:
          "term    term"
          "totals  totals"
          "chain   chain"
          "wallets sources"
          "cols    cols"
          "note    note";
        align-items: start;
      }
    }
    /* The collection list flows into multiple CSS columns on wide screens
       rather than living in a side panel — 55 entries stay fully visible
       (no inner scroll) while the section height roughly halves/thirds. */
    .src-collist { column-gap: clamp(26px, 3vw, 48px); }
    @media (min-width: 1000px) { .src-collist { columns: 2; } }
    @media (min-width: 1560px) { .src-collist { columns: 3; } }
    /* Each collection row is a BUTTON that filters the gallery to it (see
       the delegated handler in viewer.js). Grid layout works on a <button>;
       these overrides just strip the native button chrome and add a hover. */
    .src-crow {
      display: grid; grid-template-columns: minmax(0, 1fr) auto auto;
      gap: 4px 12px; align-items: baseline;
      padding: 6px 6px; margin: 0 -6px;
      border: none; border-bottom: 1px solid var(--border);
      background: none; color: inherit; font: inherit;
      font-size: var(--fs-small); text-align: left; width: calc(100% + 12px);
      cursor: pointer; border-radius: 4px;
      transition: background .12s, padding-left .12s;
      break-inside: avoid; -webkit-column-break-inside: avoid;
    }
    /* Same volt wash as the dropdown rows. The row's .nm already turned
       accent on hover; the plate was still grey, which read as two different
       hover systems on one row. */
    .src-crow:hover {
      background: color-mix(in srgb, var(--accent) 12%, transparent);
      padding-left: 10px;
    }
    .src-crow:hover .nm { color: var(--accent); }
    .src-crow .nm {
      color: var(--text); min-width: 0;
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
      /* ⚠️ Explicit line-height is load-bearing here. These are raw collection
         names, so they carry combining-mark towers, and .src-crow is a grid
         with `align-items: baseline` — a single mark that falls back to a
         face with tall metrics sizes the line box from THAT face and pushes
         the whole row taller, which ripples down the source list. `overflow:
         hidden` above only clips horizontally (nowrap), and `contain: paint`
         clips paint, not layout. Pinning the height is the only thing that
         actually stops it. */
      line-height: 1.5;
    }
    /* ⚠️ `min-width: 0` + ellipsis, and NOT `white-space: nowrap` alone.
       This cell was nowrap with no min-width, which in a grid means it refuses
       to shrink below its content: "Transient + Alchemy · Base" then held the
       row open and squeezed `.nm` — the COLLECTION NAME, the thing the row is
       actually about — down to a few characters on a phone. The source label
       is the least important of the three, so it is the one that gives. */
    .src-crow .ch {
      color: var(--sub); font-size: var(--fs-micro);
      min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }
    .src-crow .num {
      color: var(--accent); font-variant-numeric: tabular-nums;
      min-width: 4ch; text-align: right;
    }
    /* ── Narrow: NAME and COUNT win, the source label takes what is left ──
       Desktop keeps all three on one comfortable line (`1fr auto auto`, where
       the name gets the slack). Below 620px there is not enough width for
       that, so the two flexible cells share it explicitly — the name gets
       roughly two thirds, the source label the rest, and BOTH ellipsise rather
       than one starving the other. The count is never allowed to shrink: it is
       four characters and it is half the reason the row exists. */
    @media (max-width: 620px) {
      .src-crow { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto; }
    }
    @media (min-width: 760px) {
      /* Only named here — below 760px #sources-body has no grid-template-areas,
         so a bare `grid-area: term` would place the block into an implicit
         track instead of the flow position it should keep. */
      .src-terminal             { grid-area: term; }
      .src-section--totals      { grid-area: totals; }
      .src-section--chain       { grid-area: chain; }
      .src-section--wallets     { grid-area: wallets; }
      .src-section--sources     { grid-area: sources; }
      .src-section--collections { grid-area: cols; }
      .src-note                 { grid-area: note; }
    }
    .src-section { margin-top: 0; min-width: 0; }
    /* Section headings (By chain / Wallets / By source / By collection) in
       BD Alien at 20px — they were 13px --fs-micro in the UI mono and read as
       labels rather than headings.
       ⚠️ This is a deliberate exception to the "--font-num is for elements
       whose entire content is a NUMBER" rule stated just below. The rule
       exists to stop a display face sitting inside a phrase next to Geist
       Mono; here the WHOLE heading is BD Alien with no mixed inline run, and
       it's already uppercase, so BD Alien's caps-only character set is not a
       constraint. All four headings are styled together — "By source" was not
       named in the request but is left matching its siblings on purpose. */
    .src-section h3 {
      margin: 0 0 14px;
      font-family: var(--font-num);
      font-size: 34px; letter-spacing: normal; text-transform: uppercase;
      /* Full white, not var(--sub) — at this size a dimmed heading read as
         disabled rather than secondary. These are section titles now, so they
         take the same pure white as the other content-level type. */
      color: #fff; font-weight: 400;
    }
    .src-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; }
    .src-card {
      background: var(--white); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px;
    }
    .src-card .n { font-size: var(--fs-head); font-weight: 400; color: var(--text); font-variant-numeric: tabular-nums; }
    .src-card .l { font-size: var(--fs-small); color: var(--sub); margin-top: 2px; }

    /* ── BD Alien numerals ──────────────────────────────────────────
       Applied ONLY to elements whose entire content is a number, so the
       display face never sits inside a phrase alongside Geist Mono. Kept
       out of the sync terminal on purpose: its rows align in `ch` units
       with tabular numerals, and BD Alien is proportional. */
    .src-total .n,
    .src-card .n,
    .src-crow .num,
    .src-legend-n,
    .src-table .num,
    .tb-opt .opt-n {
      font-family: var(--font-num);
    }

    /* ── Totals — editorial hero row ────────────────────────────────
       The numbers ARE the panel: huge, volt, borderless. Asymmetric —
       Artworks (the headline figure) dominates; Listed/Collections sit
       smaller beside it. No card chrome; a top hairline separates each. */
    .src-section--totals { margin-top: 4px; }
    /* minmax(0,…) not plain fr: grid items default to min-width:auto, so the
       tracks were being forced wider than the container by their own content
       and the page picked up ~51px of horizontal overflow on a phone. */
    .src-totals {
      display: grid;
      grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 1fr);
      gap: clamp(12px, 3vw, 34px); align-items: end;
    }
    .src-total { min-width: 0; }
    .src-total { padding-top: 12px; }
    .src-total .n {
      /* Sized for --font-num (BD Alien), whose digits run ~0.88em against
         Geist Mono's 0.6em — the previous 7vw/40px floor overflowed the
         track on a phone once the numerals changed face. */
      font-size: clamp(24px, 6vw, 76px); line-height: .82; letter-spacing: normal;
      color: var(--accent); font-variant-numeric: tabular-nums;
    }
    .src-total--hero .n { font-size: clamp(34px, 10vw, 132px); }
    .src-total .l {
      font-size: var(--fs-micro); color: var(--sub); margin-top: 10px;
      text-transform: uppercase; letter-spacing: normal;
    }

    /* ── By-chain proportion bar + legend ───────────────────────────
       One stacked bar, each segment a chain colour (--c-{chain}); a
       colour-dot legend with count + share underneath.
       Inside #stats the chain palette is REDEFINED to shades of the one
       accent green (Jul 23 2026) — the RGB trio stays sitewide (badges,
       ticker), but the stats read as a single-hue system. The local vars
       cascade to bar segments, legend dots AND wallet left-edges. */
    #stats {
      --c-eth:  var(--accent);
      --c-base: color-mix(in srgb, var(--accent) 55%, #0a1f0a);
      --c-tez:  color-mix(in srgb, var(--accent) 28%, #0a1f0a);
      --c-sol:  color-mix(in srgb, var(--accent) 14%, #0a1f0a);
    }
    .src-bar {
      display: flex; width: 100%; height: 12px; border-radius: 4px;
      overflow: hidden; gap: 2px; background: var(--bg);
    }
    .src-bar-seg { height: 100%; min-width: 3px; transition: opacity .15s; }
    .src-bar-seg:hover { opacity: .82; }
    .src-bar--tez  { background: var(--c-tez); }
    .src-bar--eth  { background: var(--c-eth); }
    .src-bar--base { background: var(--c-base); }
    .src-bar--sol  { background: var(--c-sol); }
    .src-legend { display: flex; flex-wrap: wrap; gap: 8px 22px; margin-top: 14px; }
    /* The COUNT is the whole point of the row — set large; name and share stay
       at the normal UI size beside it. Centred, not baseline-aligned: at 34px
       against 13px a shared baseline parks the small text at the very bottom
       of the number, which reads as misaligned. */
    .src-legend-item { display: flex; align-items: center; gap: 10px; font-size: var(--fs-ui); }
    .src-dot { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; align-self: center; }
    .src-legend-name { color: var(--text); font-size: var(--fs-ui); text-transform: uppercase; letter-spacing: normal; }
    .src-legend-n {
      color: var(--text); font-size: 34px; line-height: 1;
      font-variant-numeric: tabular-nums;
    }
    .src-legend-pct { color: var(--sub); font-size: var(--fs-ui); font-variant-numeric: tabular-nums; }
    /* BD Alien sits low in its em box, so box-centring still leaves the UI-size
       text ~1.8px high against the count's ink. Measured, not guessed —
       re-measure if the 34px/13px pairing changes. */
    .src-legend-name, .src-legend-pct { transform: translateY(1.8px); }
    .src-wallet { display: flex; flex-direction: column; gap: 8px; }
    /* Centred, not baseline-aligned: the chain name is 28px BD Alien against a
       13px address, so a shared baseline dropped the address to the very
       bottom of the name (measured 4.26px out). */
    .src-wallet a {
      display: flex; justify-content: space-between; gap: 12px; align-items: center;
      background: var(--white); border: 1px solid var(--border);
      /* 5px, matching .tb-control / .tb-opt — these are interactive rows with
         a hover stroke, so they belong to the same corner language as the
         other hover surfaces. Was 10px, left over from before the site's
         corners were tightened across the board (cards 2px, terminal 6px).
         ⚠️ .src-wallet a::after takes `border-radius: inherit`, so the volt
         hover stroke follows this automatically — don't set it twice. */
      border-left: 1px solid var(--border); border-radius: 5px;
      padding: 10px 14px; color: var(--text); text-decoration: none; font-size: var(--fs-small);
    }
    /* Chain-coloured left edge, matching the proportion bar. */
    .src-wallet--tez  { border-left-color: var(--c-tez); }
    .src-wallet--eth  { border-left-color: var(--c-eth); }
    .src-wallet--base { border-left-color: var(--c-base); }
    /* Same animated volt stroke as the gallery cards — reuses their keyframes.
       The pseudo is position:absolute on purpose: an in-flow ::after on a flex
       container becomes a FLEX ITEM and would land as a third column in the
       row; absolutely-positioned children are exempt from that. */
    .src-wallet a { position: relative; }
    .src-wallet a::after {
      content: ''; position: absolute; inset: 0; z-index: 2;
      pointer-events: none; border-radius: inherit;
      box-shadow: inset 0 0 0 0 var(--accent);
      transition: box-shadow .22s ease;
    }
    @media (any-hover: hover) and (any-pointer: fine) {
      .src-wallet a:hover::after { animation: card-stroke-in .34s ease-out forwards; }
    }
    @media (prefers-reduced-motion: reduce) {
      .src-wallet a:hover::after { animation: none; box-shadow: inset 0 0 0 4px var(--accent); }
    }
    /* BD Alien's low ink leaves box-centring 0.85px out — measured, as with
       the chain legend. Re-measure if the 28px/13px pairing changes. */
    .src-wallet .addr {
      color: var(--sub); font-size: var(--fs-small);
      text-align: right; transform: translateY(0.85px);
      /* Truncate rather than wrap. word-break:break-all used to let a 42-char
         hex address split across lines and shove the row's height around at
         narrow widths; it now ellipsises to whatever space is left. min-width:0
         is required — a flex item won't shrink below its content otherwise, so
         text-overflow would never trigger. Full value stays in the title. */
      min-width: 0; max-width: 100%;
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }
    /* Chain NAME in BD Alien, set larger — it's the label you scan for. The
       address deliberately stays in the mono UI face: it's a hex string whose
       EIP-55 casing carries a checksum, so it needs to stay unambiguous. The
       name is the first span (the address is .addr). */
    .src-wallet a > span:not(.addr) {
      font-family: var(--font-num);
      font-size: 28px;
      line-height: 1.1;
      /* Volt, not --text. The chain name is the scan target in this block and
         the accent marks it as such; the address stays grey so the pair keeps
         its hierarchy. Note the row already carries a chain-COLOURED left edge
         (--c-tez / --c-eth / --c-base) — the name is deliberately NOT tinted
         to match it, or every row would read as a different colour and the
         single-accent system would break down. */
      color: var(--accent);
    }
    .src-table { width: 100%; border-collapse: collapse; font-size: var(--fs-small); }
    .src-table th, .src-table td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--border); }
    .src-table th { color: var(--sub); font-weight: 400; font-size: var(--fs-micro); text-transform: uppercase; letter-spacing: normal; }
    .src-table td.num { text-align: right; font-variant-numeric: tabular-nums; color: var(--accent); }
    .src-table td.nm { color: var(--text); max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .src-table td.ch { color: var(--sub); font-size: var(--fs-micro); }
    .src-note { margin-top: 18px; font-size: var(--fs-small); color: var(--sub); line-height: 1.6; }
    .src-note strong { color: var(--accent); font-weight: 400; }
    .src-tag {
      display: inline-block; font-size: var(--fs-micro); font-weight: 400; letter-spacing: normal;
      color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 33%, transparent);
      border-radius: 5px; padding: 1px 5px; vertical-align: middle; margin-right: 4px;
    }
    /* Stats section — mobile: tighter tables, stacked wallet rows so long
       addresses don't overflow, shorter collections panel. */
    @media (max-width: 680px) {
      #stats { padding: 26px 14px calc(40px + env(safe-area-inset-bottom,0)); }
      .src-grid { grid-template-columns: repeat(auto-fit, minmax(94px, 1fr)); gap: 8px; }
      .src-card { padding: 10px 11px; }
      .src-card .n { font-size: var(--fs-mid); }
      .src-table { font-size: var(--fs-small); }
      .src-table th, .src-table td { padding: 6px 5px; }
      .src-table td.nm { max-width: 44vw; }
      .src-wallet a { flex-direction: column; gap: 3px; align-items: flex-start; }
      .src-wallet .addr { text-align: left; }
    }

    /* ── Share panel ─────────────────────────────────────────────────
       The Info panel's smaller sibling. Every shared value below is
       deliberately IDENTICAL to the #fs-activity block that follows —
       #141414 plate, var(--border) hairline, 18/16px radii, the bottom-sheet
       →centred-card switch at 600px, the 44px BD Alien heading, the 34px
       close button. The two dialogs are one component in two sizes; if you
       retune one of those numbers, retune both or the pair reads as two
       different design systems opening from buttons that sit side by side.

       What is NOT shared is the sizing model: this panel has no scrolling
       body, so it takes its HEIGHT FROM ITS CONTENT (no `height` at all)
       while the info panel is pinned to a fixed 660px. That is the whole of
       "same popup but smaller". */
    #fs-share {
      display: none; position: fixed; inset: 0; z-index: 2200;
      background: rgba(0,0,0,.72);
      -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
    }
    /* flex-end = bottom sheet on a phone, exactly like #fs-activity. */
    #fs-share.open { display: flex; align-items: flex-end; justify-content: center; }
    .fsh-panel {
      position: relative; width: 100%; max-width: 620px;
      background: #141414; border: 1px solid var(--border);
      border-radius: 18px 18px 0 0;
      padding: 22px 20px calc(20px + env(safe-area-inset-bottom, 0px));
      display: flex; flex-direction: column; gap: 14px;
      /* ⚠️ 82vh AND A FIXED DESKTOP HEIGHT — the same numbers as .fa-panel, not
         "content-sized" any more (Aug 2 2026, by request: "make it full screen
         like the info popup"). The two dialogs open from buttons that sit side
         by side and now differ in NOTHING but their contents; if you retune one
         of these, retune both or the pair reads as two design systems.
         The whole panel still scrolls rather than an inner body: there is no
         header/footer split here worth keeping pinned. */
      max-height: 82vh; max-height: 82dvh;
      overflow-y: auto; -webkit-overflow-scrolling: touch;
    }
    /* ── PHONE: FULL SCREEN, not a bottom sheet ───────────────────────────
       Byte for byte what #fs-activity does at this width (`align-items:
       stretch` + a 100%/100% panel with no radius). The two dialogs open from
       buttons that sit side by side and must not behave differently; the
       sheet form is gone from both.
       ⚠️ The safe-area inset stays in the padding. At full height the panel's
       bottom edge IS the screen's bottom edge, which is exactly where the home
       indicator and the browser's own chrome live — without it the last row is
       unreachable rather than merely cramped. env() is ADDED to a real 18px,
       never relied on alone: it is 0 on plenty of Android phones that still
       have a gesture bar. */
    /* ⚠️⚠️ EVERY SELECTOR IN THIS BLOCK CARRIES `#fs-share`, AND IT IS NOT
       TIDINESS — it is the third time this trap has been hit in two days. This
       @media sits ABOVE most of the .fsh-* base rules in the file, so at equal
       specificity the LATER base rule wins and the override is silently
       ignored: the panel resized correctly (its base rule happens to sit
       above) while the buttons, the heading and the link row did not, which is
       a very confusing half-applied result to debug. The id costs nothing and
       buys back what source order takes away. Add a bare `.fsh-…` selector
       here and it will probably do nothing. */
    @media (max-width: 599px) {
      #fs-share.open { align-items: stretch; }
      #fs-share .fsh-panel {
        margin: 0; width: 100%; max-width: none;
        height: 100%; max-height: 100%;
        border-radius: 0;
        padding: 18px 16px calc(18px + env(safe-area-inset-bottom, 0px));
      }
      /* Same heading step-down as .fa-panel h3 — 44px would wrap next to the
         close button on a 360px screen. The padding-right is what keeps it
         clear of that button. */
      #fs-share .fsh-panel h3 { font-size: 34px; padding-right: 44px; }
      #fs-share .fsh-close { top: 14px; right: 14px; }
      /* The card centres in the free height (`margin: auto`) exactly as it
         does on desktop, so a full-height panel does not leave it stranded at
         the top with the copy row floating under it. */
      #fs-share .fsh-card-stage { margin: auto; }
      /* ⚠️ NO PHONE-ONLY RADIUS. It was 15px here against 22px on desktop and
         that is exactly what made the glow behind the card look different on
         the two screens. The radius is one value for both now — see the note
         on .fsh-card. */
      /* Room to grow now that the panel is the whole screen — a 260px image on
         a 844px-tall phone is a stamp. vh-based so it still behaves in
         landscape, where the height is the scarce axis. */
      #fs-share .fsh-card-media { max-height: min(340px, 38vh); }

      /* ── THE FOOT OF THE PANEL, STACKED ────────────────────────────────
         Desktop puts the URL and Copy on one line, which is right when there
         is width to spare. On a phone it was not: the URL ellipsised down to
         almost nothing to make room for the button beside it, and then "More
         options…" appeared BELOW as a full-width control — so the two buttons
         had different widths, different alignments (one left, one centred) and
         different fills (`--flat` is transparent), stacked one above the
         other. Three inconsistencies in 100px of screen.
         Fixed by making them one column: two IDENTICAL full-width buttons.
         `.fsh-link`'s gap matches the panel's own 14px so Copy and More are
         evenly spaced whether or not the More button is present — which is the
         case that looked broken, since it is hidden on desktop and on any phone
         without Web Share.
         (The URL line the paragraph above refers to is gone — see the 🗑 note in
         index.php. The rule for it stays so restoring the span is markup-only.) */
      #fs-share .fsh-link { flex-direction: column; align-items: stretch; gap: 14px; }
      #fs-share .fsh-url { flex: 0 0 auto; width: 100%; }
      /* --flat drops its transparent background here and becomes the same
         control as .fsh-native. Two buttons doing the same kind of job, one
         above the other in the same column, must not be styled as different
         ranks.
         ⚠️ AND IT TAKES ITS LABEL BACK. Desktop shows the copy icon alone (see
         .fsh-opt--flat below); here it sits directly above a labelled "More
         options…", and a bare icon beside a worded button in the same stack
         reads as a control that failed to render rather than as a deliberate
         one. Same reasoning as the equal widths and fills above. */
      #fs-share .fsh-opt--flat {
        flex: 0 0 auto; justify-content: center; background: var(--white);
      }
    }
    @media (min-width: 600px) {
      #fs-share.open { align-items: center; }
      /* ⚠️ IT NO LONGER MATCHES .fa-panel's WIDTH, and that is deliberate.
         This used to be byte-for-byte the Info panel's box (860px) on the
         argument that "the two panels are now one size" — but they do not hold
         the same thing. The Info panel carries ~16 metadata rows plus an
         event feed and needs the width so contracts and URIs show in full;
         this one holds a card, a heading and one or two buttons, and at 860px
         it read as a mostly-empty room. Narrowed by request (Aug 2 2026).
         The HEIGHT still matches, and is still a fixed height rather than a
         max: without it the dialog resizes per artwork, because the artwork's
         own ratio drives the card inside it. */
      .fsh-panel {
        border-radius: 16px; margin: 0 16px;
        width: min(620px, calc(100vw - 64px)); max-width: none;
        height: min(660px, 86vh); max-height: 86vh;
      }
      /* The card owns the free space and sits in the middle of it. `margin:
         auto` on a flex item absorbs the leftover height, which parks the
         copy-link row on the floor of the panel instead of leaving it floating
         under the card with 200px of nothing beneath.
         ⚠️ `#fs-share` PREFIX IS LOAD-BEARING, not tidiness. This @media block
         sits ABOVE the base .fsh-card rules in the file, so at equal
         specificity the later base rule wins and these are silently ignored —
         which is exactly what happened first try (card stayed 440px wide and
         top-aligned). The id buys the specificity the source order costs. */
      /* ⚠️ 88%, NOT 92%, AND THE PERCENTAGE IS THE TILT'S HEADROOM. It only
         governs between 600px and ~640px of viewport, where the panel is
         nearly full width and the 520px cap has not bitten yet — the tightest
         case on the whole site. Measured at vw 660: at 92% the card is 534px
         with 4.6px of clearance at 11°, and 13° actually overflows the panel
         (`scrollWidth > clientWidth`, a horizontal scrollbar on a dialog).
         88% buys ~22px per side there and costs nothing on a wide screen,
         where `min()` picks 520px regardless. Raise it back and you must lower
         FSH_TILT_MAX with it. */
      /* 520 → 460. The panel is 860 wide and the card was filling most of it;
         a share dialog's subject is the destinations, and the card is the
         confirmation. Smaller also means more panel showing behind the tilt,
         which is what gives the 3D somewhere to happen. */
      #fs-share .fsh-card-stage { margin: auto; width: min(84%, 460px); }
      /* ── The buttons take the CARD'S width, not the panel's ──────────────
         By request (Aug 2 2026), for the iPad case where Copy link and
         "More options…" are both on screen: same width, centred, and wider
         than the 126px pill Copy used to be.
         ⚠️ `min(84%, 460px)` is REPEATED FROM THE CARD ABOVE ON PURPOSE — the
         point is that the two edges line up. Rendered both ways before
         choosing: at the panel's full 578px content width the buttons overhang
         the 460px card and the stack looks unaligned; matched to the card it
         reads as one column. If the card's width changes, change it here too.
         ⚠️ ≥600px ONLY. Below that the ≤600px block makes both buttons full
         width in a single column, which is what a phone should keep. */
      #fs-share.fsh-native-mode .fsh-link,
      #fs-share.fsh-native-mode .fsh-opt--wide {
        width: min(84%, 460px); margin-left: auto; margin-right: auto;
      }
      /* Taller too — a 260px image inside a 660px panel is a stamp. Still a
         cap and still `cover`, so the card never letterboxes. */
      #fs-share .fsh-card-media { max-height: min(340px, 40vh); }
    }
    /* Heading + close: same treatment as .fa-panel h3 / .fa-close. The
       `margin: 0` is explicit because the shared gap on .fsh-panel owns the
       spacing here, unlike the info panel where h3 carries its own 16px. */
    .fsh-panel h3 {
      margin: 0; font-family: var(--font-num); font-size: 44px; line-height: 1.15;
      font-weight: 400; color: #fff;
    }
    .fsh-close {
      position: absolute; top: 16px; right: 16px; width: 34px; height: 34px;
      border-radius: 9px; background: var(--white); border: 1px solid var(--border);
      color: var(--text); cursor: pointer;
      display: flex; align-items: center; justify-content: center;
    }
    .fsh-close svg { width: 15px; height: 15px; display: block; }
    /* Gated for the same reason as .fsh-opt:hover below — the close button sits
       in the top-right of a sheet that opens under the finger. */
    @media (any-hover: hover) and (any-pointer: fine) {
      .fsh-close:hover { border-color: var(--accent); color: var(--accent); }
    }
    /* "ARTWORK  <name>" — the .fa-meta-row label/value pairing, one row. */
    /* ── THE ARTWORK CARD ─────────────────────────────────────────────────
       The share panel's one graphical moment. It replaces a 52px thumbnail
       beside a line of text, which read as a form field on a panel whose whole
       subject is a picture — the piece should look like it matters.

       Built from parts the site already owns rather than a new visual language:
       the .chain-badge pill from the gallery cards, --font-art-code for the
       artist's strings, --font-num for the date, and the volt for the one
       accent. The frame is what makes it read as an object — a hairline, a
       darker plate than the panel behind it, and a volt edge along the top.

       ⚠️ THE IMAGE KEEPS THE ARTWORK'S OWN RATIO. --art-ar is written by
       openShare() from the decoded thumbnail; the fallback only applies before
       it loads. Do not replace this with a fixed box — the shape of the piece
       is information, and a square crop of a panorama is a different artwork. */
    /* ══ THE STAGE ════════════════════════════════════════════════════════
       Owns the SIZE, the 3D TILT, and the two glows that sit BEHIND the card
       (the rim and the bloom). The card itself owns the frame and the foil.
       ⚠️ THE SPLIT IS NOT COSMETIC. `.fsh-card` is `isolation: isolate` so its
       mix-blend foil composites against the card instead of the panel — and an
       isolated element cannot paint a child behind its own background. The
       reference puts both glows on the card because its card is not isolated;
       ours has to hoist them one level up. Tilting the STAGE is what keeps all
       three rotating as one object rather than the glows staying flat.
       ⚠️ NO `isolation` HERE, and no z-index: the glows are z-index:-1 children
       and need this element to stay part of the panel's stacking context. */
    .fsh-card-stage {
      position: relative;
      width: min(88%, 440px); margin: 6px auto 10px; min-width: 0;
      /* 480px, down from 600. Shorter perspective = harder foreshortening for
         the same angle, so this is the knob that adds DEPTH without costing
         layout clearance (the angle is what costs it — see FSH_TILT_MAX).
         Below ~450 it turns into a fisheye and straight edges start to bend. */
      transform: perspective(480px)
                 rotateX(var(--tilt-rx, 0deg)) rotateY(var(--tilt-ry, 0deg));
      transition: transform .14s ease-out;
      --tilt-rx: 0deg; --tilt-ry: 0deg; --glare: 0;
      /* Proximity ramp, 0 at rest. In the reference this is how far the cursor
         is from the card; here it is how far the card is LEANING, which is the
         same idea reaching the same place — the foil and both glows swell as
         the card turns and fall away when it settles. Static opacities were
         what made the first port look like a decal instead of foil. */
      --grad-op: 0;
      /* ⚠️ ONE HUE, BOTH VARIANTS: the accent. The reference's rim is gold→blue
         and the holo variant used those verbatim at first — it looked like a
         Pokémon card rather than like this one. The two-colour STRUCTURE is
         what makes the edge read as lit (a hot core falling to a saturated
         edge, positioned at the highlight); the two colours do not have to be
         different HUES to do that. So the gradient runs near-white-green →
         volt instead, and the card stays volt-edged while it moves.
         The foil underneath keeps its full spectrum — that is the artwork's
         surface, not the frame. */
      --rim-in:  color-mix(in srgb, var(--accent) 45%, #ffffff);
      --rim-out: var(--accent);
      --bloom-color: var(--accent);
    }
    .fsh-card-stage.is-idle { transition: none; }
    .fsh-card-stage.is-gyro { transition: transform .5s cubic-bezier(.22, .61, .36, 1); }
    /* ── The CATCH, when the pointer comes within reach of a drifting card ──
       The third of the three transitions, and the mirror of `.is-settling`:
       that one eases the card back to square when the pointer LEAVES, this one
       eases it from wherever the drift had it to wherever the cursor IS. The
       drift runs under `transition: none` (it is already a per-frame sine), so
       without this the first pointer write is a teleport — the bigger the lean
       the drift was holding, the more it reads as a glitch rather than a
       response.
       ⚠️ .45s, SHORTER THAN THE RELEASE ON PURPOSE. Arriving is an answer to
       something the user just did and must feel prompt; letting go is the card
       relaxing on its own and can take its time.
       ⚠️ Self-clearing after 460ms in shareIdleTakeOver(), and it has to be —
       left on, this IS the tracking transition and the card swims a third of a
       second behind the cursor.
       Order in this file is load-bearing between all three, since they share a
       specificity and only source order separates them: .is-idle first (the
       drift's `none`), this next, .is-settling last — the release must beat the
       catch if a pointer leaves within the 460ms window. shareIdleTakeOver()
       and shareTiltReset() are also written so the classes do not overlap; this
       ordering is the second guard, not the only one. */
    .fsh-card-stage.is-waking {
      transition: transform .45s cubic-bezier(.22, .61, .36, 1);
    }
    /* ── The RELEASE, when the pointer leaves ─────────────────────────────
       ⚠️ A separate, much longer curve from the .14s the stage wears while
       TRACKING, and the two are tuned for opposite things. Tracking wants to
       be short: the card must sit under the cursor, and anything slower reads
       as lag. Letting go wants to be long: at .14s a 14° card dropped to flat
       in under a sixth of a second, which is not a release at all — it is the
       tilt being switched off ("it suddenly goes to flat, it feels broken").
       Set by shareTiltReset(true), and self-clearing (see the note there) —
       if this class is ever left on, the card tracks the pointer at .55s.
       ⚠️ It also eases the GLOW properties the reset clears alongside the
       transform (--grad-op / --glare / --edge-lift drive the rim, bloom and
       frame). Easing the angle while the light snapped off was the same
       broken-feeling half-fix in a different property. */
    /* ⚠️ .9s, AND THE NUMBER IS COPIED IN viewer.js — see FSH_SETTLE_MS there.
       Two JS timers hang off this duration (when the class self-clears, and
       when the idle drift is allowed to take the card back); change it here and
       they are both short, which puts the drift under this curve instead of
       under `transition: none`. Was .55s until Aug 4 2026 — "add more easing
       when mouse not hover". The CURVE is unchanged and deliberately so: it is
       a hard ease-out (fast off the mark, long tail), so the extra 350ms all
       lands in the tail where the card is barely moving, which is the part that
       reads as easing. Swapping in a symmetric curve at this length would feel
       slow instead of soft. */
    .fsh-card-stage.is-settling {
      transition: transform .9s cubic-bezier(.16, .84, .34, 1);
    }
    /* The light has to travel with the angle — easing the transform while the
       glow snapped off was the original half-fix. Same duration, same curve. */
    .fsh-card-stage.is-settling .fsh-card-glare,
    .fsh-card-stage.is-settling::before,
    .fsh-card-stage.is-settling::after {
      transition: opacity .9s cubic-bezier(.16, .84, .34, 1);
    }
    @media (prefers-reduced-motion: reduce) {
      .fsh-card-stage { transform: none; transition: none; }
    }
    /* The BLOOM — a soft coloured light behind the card, following the
       highlight. `filter: blur()` on a radial is exactly what the reference
       does; the blur is what stops it reading as a hard halo. */
    .fsh-card-stage::after {
      content: ''; position: absolute; inset: 2%; z-index: -1;
      border-radius: 14px;
      background: radial-gradient(circle at var(--gx, 50%) var(--gy, 50%),
        var(--bloom-color, hsl(48, 100%, 69%)), transparent 62%);
      filter: blur(16px);
      opacity: calc(var(--grad-op, 0) * 1.6);
      transition: opacity .3s ease;
      pointer-events: none;
    }
    /* The RIM — a two-colour edge light that tracks the highlight, sitting a
       few px proud of the card so it reads as the frame catching the light.
       ⚠️ This is the reference's "border colour" effect and it is POSITIONAL,
       not a colour cycle: the gold end sits where the light is and the blue
       end opposite it, so turning the card walks the colour around the edge.
       That is why it is a radial behind the card and not an animated
       `border-color` — a keyframed border changes everywhere at once, which
       reads as a blinking outline rather than a lit edge. */
    .fsh-card-stage::before {
      content: ''; position: absolute; inset: -5px; z-index: -1;
      border-radius: 17px;
      background: radial-gradient(circle at var(--gx, 50%) var(--gy, 50%),
        var(--rim-in, hsl(48, 100%, 69%)),
        var(--rim-out, hsl(240, 100%, 69%)),
        transparent 78%);
      opacity: calc(var(--grad-op, 0) * 1.9);
      transition: opacity .3s ease;
      pointer-events: none;
    }
    .fsh-card {
      /* ⚠️ NARROWER THAN THE PANEL ON PURPOSE, and centred. Edge to edge it
         read as a panel SECTION with a border round it; pulled in on both
         sides with real air either side, it reads as an object placed on the
         sheet — which is the whole idea, and what the tilt then acts on.
         `min()` so it insets proportionally on a phone (92%) and stops growing
         on a desktop, where a full-width 514px card was the loudest thing in a
         dialog whose subject is a list of destinations.
         ⚠️ THE SIZE MOVED TO .fsh-card-stage. The card fills it. Put a width
         back here and the stage's glows, which are positioned against the
         stage, stop lining up with the card's edges. */
      width: 100%; min-width: 0;
      /* ⚠️ THE ACCENT, NOT #111, AND THIS IS A BUG FIX — do not "correct" it
         back to a dark plate. While the tilt transition runs, the card is
         composited and its clipped children rasterise a sub-pixel short of the
         rounded clip, so a hairline of THIS COLOUR shows between the artwork
         and the stroke. At #111 that was a black line crawling around the
         inside of the border whenever the card moved (reported Aug 1 2026,
         caught by slowing the transition to 4s and screenshotting mid-move —
         a settled screenshot never shows it). Painting the gap the same colour
         as the border makes it invisible instead of chasing the seam, which is
         a compositing detail no CSS can reliably close.
         ⚠️ The caption therefore carries its own #111 — see .fsh-card-body. */
      background: var(--accent);
      /* ⚠️ VOLT STROKE, and it is the card's whole identity — do not soften it
         back to the grey hairline every other surface uses. This is the one
         element in the panel that is the artwork, and the accent is what says
         so.
         ⚠️ PLAIN, FLAT `var(--accent)` — no gradient, no animated hue, no
         bloom. Both were tried (Aug 1 2026): a stroke that drifted through the
         foil's aqua/violet turned the card's one fixed reference into part of
         the effect, and the low-alpha glow rings under it made a hard-edged
         object look lit from behind. The foil moves; the frame does not. That
         contrast is what makes the sheen read as motion ON a solid card.
         3px, up from the original 2 and back down from 5: at 2 the stroke was
         thinner than the panel's own hairline plus its shadow and the card's
         edge read as soft; at 5 it was a mount rather than an edge and started
         eating into the artwork. 3 is the printed border of a physical trading
         card, which is the object this is imitating. */
      border: 3px solid var(--accent);
      /* 12px, and it is ONE value at every width now — the phone used to
         override it to 15 and the desktop sat at 22. ⚠️ THE RADIUS IS NOT ONLY
         THE CARD'S OWN CORNER: the rim and the bloom on .fsh-card-stage are
         rounded rectangles sitting just outside it, so their curve has to match
         or the glow reads as a differently-shaped object behind the card. That
         coupling is why this is a single number and why the two stage pseudos
         are listed right under it.
         (Previous note, still true) --card-radius (2px) is the gallery thumbnail's corner, and a
         stroke this heavy turning a corner that tight reads as a mitre rather
         than a curve. This is the one place on the site allowed to be rounder
         than near-square — it is a CARD, an object on a sheet, not another
         panel surface, and at 22 it reads as a physical trading card rather
         than a cropped rectangle. ⚠️ Retune it WITH the border-width; the two
         are a pair, and a heavy stroke on a tight radius is the failure mode.
         The artwork and the caption need no radius of their own: `overflow:
         hidden` below clips both to this curve, which is also why the foil
         layers stay inside it. */
      border-radius: 12px;
      overflow: hidden;
      /* 🚨 SAFARI DOES NOT ALWAYS CLIP A COMPOSITED CHILD TO A ROUNDED
         `overflow: hidden`. The stage above carries a 3D transform, which
         promotes this subtree to its own layer, and on iOS the artwork's SQUARE
         corner then paints straight through the card's 22px curve (reported
         Aug 2 2026: "I see the image corner… hasn't been masked well").
         This mask is the long-standing fix — it forces the clip onto the
         composited layer. Harmless everywhere it is not needed.
         ⚠️ It is BELT AND BRACES with the inner radii on .fsh-card-media and
         .fsh-card-body below: the mask handles the general case, the inner
         radii mean the corner is correct even if a browser ignores both the
         mask and the overflow. Keep all three. */
      -webkit-mask-image: -webkit-radial-gradient(#fff, #000);
      mask-image: radial-gradient(#fff, #000);
      /* Depth only. The two accent-tinted rings that used to sit here are
         gone — see the stroke note above. */
      box-shadow: 0 10px 28px rgba(0,0,0,.55),
                  inset 0 1px 0 rgba(255,255,255,.05);
      /* The foil below blends against the CARD, not the panel behind it. */
      position: relative; isolation: isolate;
      /* 🚨 THE CARD IS ITS OWN CONTAINER, and every size in the caption below is
         a share of its WIDTH (cqw). This is what makes the card the same shape
         on a phone and on a desktop.
         Before this the media was a fixed 16/9 but the caption was fixed PIXELS,
         so the caption ate a much bigger share of a 320px card than of a 460px
         one and the card came out visibly taller on mobile ("it doesn't look
         the same on both screens"). Percentages of the viewport cannot fix that
         — the card is not the viewport. Container units are the only thing that
         measures what actually varies.
         ⚠️ Anything added to the caption must be sized in cqw too, or it
         reintroduces exactly this bug for that one element. */
      container-type: inline-size;
      /* One knob for the whole finish: both foil layers multiply their opacity
         by it, so the sheen and the colour field can be dialled up or down
         together instead of one pulling ahead when the other is retuned. */
      --holo: 1;
      /* ── TILT (pointer on desktop, gyroscope on a phone) ───────────────
         Written by shareCardTilt() in viewer.js; all five default to a
         resting card, so the CSS is complete on its own and the JS only ever
         nudges it. Nothing here needs the JS to have run.

         ⚠️ THE ANGLE IS CAPPED BY LAYOUT, NOT BY TASTE — the number lives in
         viewer.js (FSH_TILT_MAX) and the constraint lives here. .fsh-panel
         scrolls (`overflow-y: auto`, which computes overflow-x to auto with
         it), so a card that projects past the panel raises a horizontal
         scrollbar on a dialog. The binding case is NOT a phone and not a wide
         desktop: it is a viewport of ~600-680px, where the panel is nearly
         full width and the card's percentage still governs. Measured there at
         perspective 600px — 9deg clears by 11.5px, 11deg by 4.6px, and 13deg
         OVERFLOWS. Widening the card's percentage eats this directly; see the
         88% note in the @media block above. Re-measure both together. */
      --tilt-rx: 0deg;   /* about X — pointer's vertical position */
      --tilt-ry: 0deg;   /* about Y — pointer's horizontal position */
      --glare: 0;              /* the highlight's strength */
      /* ⚠️ --posx/--posy have NO default here on purpose. They are the foil's
         only motion (see the pseudos below), and every place that reads them
         supplies its own `50%` fallback — so a card whose JS never ran renders
         a centred, correct finish. Declaring them here would work equally well
         and is the trap: it makes the fallbacks look redundant, and someone
         removes them. */
      /* ⚠️ 600px, DOWN FROM 1000px — this is the depth knob, and it is the one
         to reach for, not the angle. Perspective distance is how far the
         "viewer" is from the card: halve it and the same rotation foreshortens
         far harder, so the near edge really comes at you instead of the card
         just leaning. It buys 3D for free in layout terms — the angle is what
         costs clearance (see above), and this does not touch it. Below ~450px
         the foreshortening turns into a fisheye and straight edges start to
         look bent. */
      /* ⚠️ NO TRANSFORM HERE — the tilt is on .fsh-card-stage so the glows
         behind the card rotate with it. See the stage block above. */
      /* Short, not zero. The pointer stream is already rAF-throttled; this is
         what keeps a gyroscope's jitter from reading as a shiver, and what
         gives the card its weight when the pointer leaves and it swings back.
         ⚠️ Do not add `transform` to a longer transition — at ~.4s+ the card
         lags the pointer visibly and feels broken rather than heavy. */
    }
    /* ⚠️ THE GYRO GETS ITS OWN, LONGER EASE — and the exception is the point.
       The rule above is about a POINTER, which is deliberate and stays where
       you put it. A phone is never still (breathing, walking, a train), so at
       .14s the card chases every wobble. `.is-gyro` is added by
       shareTiltListenGyro() when real sensor readings start arriving, and this
       is the SECOND of the two damping stages: the JS low-pass filters the
       signal, this smooths what is left between frames. Neither alone is
       enough — see the FSH_GYRO_SMOOTH note in viewer.js. */
    /* (moved to .fsh-card-stage.is-gyro — the transform it eases is up there now) */
    /* The idle drift writes a continuous sine at frame rate, so it must NOT be
       eased again — the transition is there to smooth a pointer's jumps, and
       layering it over an animation only makes the card trail its own motion.
       Set by shareIdleStart(); the two classes are mutually exclusive, and a
       real gyro reading removes this one. */
    /* (moved to .fsh-card-stage.is-idle) */
    /* ── HOLOGRAPHIC FOIL ─────────────────────────────────────────────────
       The "rare holo v" finish, ported from the Pokémon-card technique
       (codepen msriki12/yyBNMEr). It is NOT a gradient — it is four layers
       blended against each other, drawn TWICE at different offsets, and the
       parts that make it read as FOIL rather than as a rainbow print are the
       least obvious ones:

         1. `--grain`  an inline SVG fractal-noise tile. This is the sparkle.
                       ⚠️ Inline on purpose: the pen's other variants pull a
                       .jpg off a CDN, which this site cannot do (self-hosted,
                       and the artifact CSP would block it anyway).
         2. the RAINBOW repeating gradient — the hue wheel.
         3. the 133° DARK/SILVER repeating gradient (#0e152e → pale cyan →
                       #0e152e). This is the one that does the work: it cuts
                       the rainbow into metallic bands. Delete it and the whole
                       thing collapses into a pride flag.
         4. a radial darkener at the pointer, so the sheen has a hot spot.

       ⚠️ NOTHING HERE ANIMATES ON A TIMER. Both layers used to drift on their
       own (a 20s sweep, a 52s rotation) and that is gone: the card only moves
       when the POINTER moves or the PHONE tilts, driven by --posx/--posy/--gx/
       --gy, which shareTiltApply() writes. A card that shimmers at a fixed rate
       is a screensaver; a card that answers you is an object. Sitting still is
       the correct resting state.

       ⚠️ IT SITS OVER EVERYTHING, INCLUDING THE CAPTION, and that is the
       point: a finish that stops at the image edge reads as a highlight ON the
       photo, not as a laminate over the card. Which is also why the alpha is
       this low — the effect has to survive a title being read through it.

       ⚠️ `background-blend-mode` AND `filter` ARE BOTH LOAD-BEARING.
       `screen, hue, hard-light` is what fuses the four layers; the
       `contrast(2.95) saturate(0.5)` crushes the mid-tones so the colour only
       survives where the bands peak. Drop the filter and it looks like cheap
       cellophane. These numbers come from the original and were tuned there —
       change them one at a time.

       ⚠️ TONED DOWN FROM THE SOURCE. The pen lays this over flat illustration;
       here it sits over Kero's artwork, which is already saturated and full of
       scanlines, so `color-dodge` at full strength blew the art out entirely.
       The opacity multiplier is the knob that keeps the artwork readable.

       ── 🗑 THE SECOND VARIANT IS GONE (Aug 2 2026, by request) ─────────────
       Until now these two pseudos held a QUIETER recipe — one iridescent 115°
       sweep plus a conic oil-slick field — and the holo above was layered on
       top of it as `.fsh-card.holo`, selected by an `FSH_HOLO` constant in
       viewer.js that was never going to be flipped back. Keeping both meant
       every structural property was split across two rules that had to agree,
       and four custom properties (--sheen-x/--sheen-y for the sweep's travel,
       --foil-shift/--foil-boost for the slick) were still being written on
       every pointer frame to drive layers nobody could see.
       The holo recipe is now simply what the card IS: one rule per layer, no
       class to toggle, no dead vars. The pre-holo tree is in
       backups/share-card-20260802-142059/ if the quiet version is ever wanted
       back — restore it from there rather than reconstructing it here.

       ⚠️ THE PATTERN SCALE IS THE WHOLE SETTING, and it was too fine once.
       At --holo-space 5% with the banding layer at 300%, the metallic lines
       landed only a few pixels apart and crossed each other into a mesh — and
       because Kero's artwork is ITSELF full of dither and scanlines, the two
       grids beat against each other into moiré, which reads as the picture
       being chopped up rather than as foil ("too many lines crossing… this
       creates also some cropping"). The reference does not hit this: its cards
       are flat illustration with nothing fine underneath.
       Everything below is scaled together — the band spacing, the two
       repeating gradients' stops, the grain tile and every background-size on
       BOTH passes. ⚠️ Scale them as a set: widening one layer alone just moves
       which pair of grids interferes.

       ⚠️ THE LAYER STACK LIVES IN A CUSTOM PROPERTY so both passes can share
       one declaration. A custom property can hold an entire comma-separated
       background list, which is what makes this possible; the alternative —
       repeating twenty lines in each pseudo — is how the two passes silently
       drift apart the first time someone edits one of them.
       `background-image: inherit` was tried and is WRONG here: it would make
       the pseudos inherit the CARD's background-image, and the card's own
       background is the accent fill that hides the tilt seam. */
    .fsh-card {
      --holo-space: 9%;
      --holo-angle: 133deg;
      --holo-img: 780px;
      --grain: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iNTAwIiBoZWlnaHQ9IjUwMCI+CjxmaWx0ZXIgaWQ9Im4iPgo8ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjciIG51bU9jdGF2ZXM9IjEwIiBzdGl0Y2hUaWxlcz0ic3RpdGNoIj48L2ZlVHVyYnVsZW5jZT4KPC9maWx0ZXI+CjxyZWN0IHdpZHRoPSI1MDAiIGhlaWdodD0iNTAwIiBmaWxsPSIjMDAwIj48L3JlY3Q+CjxyZWN0IHdpZHRoPSI1MDAiIGhlaWdodD0iNTAwIiBmaWx0ZXI9InVybCgjbikiIG9wYWNpdHk9IjAuMyI+PC9yZWN0Pgo8L3N2Zz4=");
      --holo-stack:
        var(--grain),
        repeating-linear-gradient(0deg,
          rgb(255,119,115) calc(var(--holo-space) * 1),
          rgba(255,237,95,1) calc(var(--holo-space) * 2),
          rgba(168,255,95,1) calc(var(--holo-space) * 3),
          rgba(131,255,247,1) calc(var(--holo-space) * 4),
          rgba(120,148,255,1) calc(var(--holo-space) * 5),
          rgb(216,117,255) calc(var(--holo-space) * 6),
          rgb(255,119,115) calc(var(--holo-space) * 7)),
        repeating-linear-gradient(var(--holo-angle),
          #0e152e 0%,
          hsl(180, 10%, 60%) 6.5%,
          hsl(180, 29%, 66%) 7.6%,
          hsl(180, 10%, 60%) 8.8%,
          #0e152e 17%,
          #0e152e 20.4%),
        radial-gradient(farthest-corner circle at var(--gx, 50%) var(--gy, 50%),
          rgba(0,0,0,.1) 12%, rgba(0,0,0,.15) 20%, rgba(0,0,0,.25) 120%);
    }
    /* Pass 1 — the foil itself. It moves by BACKGROUND-POSITION (--posx/--posy,
       written by shareTiltApply), never by transform: the layer is inset:0 and
       nothing about it travels outside the card, so there is no clearance to
       protect and no seam to hide. */
    .fsh-card::after {
      content: ''; position: absolute; inset: 0; z-index: 5;
      pointer-events: none;                 /* never eats the card's pointer */
      background-image: var(--holo-stack);
      background-blend-mode: screen, hue, hard-light;
      background-size: var(--holo-img), 200% 700%, 520%, 240%;
      background-position: center,
        0% var(--posy, 50%),
        var(--posx, 50%) var(--posy, 50%),
        var(--posx, 50%) var(--posy, 50%);
      mix-blend-mode: color-dodge;
      filter: brightness(.8) contrast(2.95) saturate(.5);
      opacity: calc(.34 * var(--holo, 1));
      transition: opacity .3s ease;
    }
    /* Pass 2 — the same stack again at different offsets and a much higher
       saturation, softly lit. ⚠️ It is not a duplicate: the OFFSETS are what
       make the two passes drift against each other, which is what gives the
       surface depth instead of a single flat sheet. */
    .fsh-card::before {
      content: ''; position: absolute; z-index: 4; pointer-events: none;
      left: 0; top: 0; width: 100%; height: 100%;
      background-image: var(--holo-stack);
      background-blend-mode: screen, hue, hard-light;
      background-size: var(--holo-img), 200% 400%, 340%, 240%;
      background-position: center,
        0% var(--posx, 50%),
        calc(var(--posy, 50%) * -0.25) calc(var(--posx, 50%) * -0.25),
        var(--posy, 50%) var(--posx, 50%);
      mix-blend-mode: soft-light;
      filter: brightness(1) contrast(2.5) saturate(2.75);
      opacity: calc(.5 * var(--holo, 1));
      transition: opacity .3s ease;
    }
    /* ── The shiny stroke ─────────────────────────────────────────────────
       Asked for alongside the holo: the frame should catch the light too.
       ⚠️ This is the ONE place the "the frame does not move" rule is relaxed,
       and only under `.holo`. The plain variant keeps its flat accent stroke —
       the reasoning in the border note above still stands there.
       Implemented as a hue rotation rather than a colour keyframe list: the
       stroke stays the SAME green and only its specular tint travels, so the
       card never stops reading as volt-edged. 6s, offset from every other
       period on the card so the two never lock into step. */
    /* 🚨 THE STROKE IS DRIVEN BY ORIENTATION, NOT BY A CLOCK.
       This was a 6s keyframe shimmer, and it was wrong for the same reason the
       ambient foil animations were: it ran regardless of the card, so the green
       looked constant while you turned it and then changed while you held it
       still. `--edge-lift` is written by shareTiltApply() from the tilt
       MAGNITUDE, so the frame brightens as the card turns away from square and
       settles back to pure volt when it faces you — the stroke and the foil now
       answer the same input.
       ⚠️ A brightness lift, not a hue shift: every value is the accent mixed
       toward white, so the frame is never a different colour, only a more lit
       one. The directional half of the effect — brighter on the side the light
       is on — is the stage's rim glow, which a single `border-color` cannot do
       (it paints all four sides the same, and `border-image`, which could, does
       not follow border-radius and would square the 22px corners). */
    .fsh-card {
      border-color: color-mix(in srgb, #ffffff var(--edge-lift, 0%), var(--accent));
    }
    @media (prefers-reduced-motion: reduce) {
      .fsh-card { border-color: var(--accent); }
    }

    /* ── THE GLARE ────────────────────────────────────────────────────────
       The bright spot that follows the pointer (or the phone's tilt) across
       the card — the single most convincing part of the whole effect, because
       it is the one that says there is a LIGHT and the card has an angle to
       it. Everything else on this card is a texture; this is a reflection.

       `soft-light`, not `screen`: a screened white blows the artwork's own
       highlights out to flat paper. Soft-light lifts what is already light and
       leaves the darks, which is what a gloss coat does.

       ⚠️ --glare IS 0 AT REST and there is no ambient fallback, unlike the two
       foil layers. A highlight that sits there when nothing is pointing at the
       card is not a reflection, it is a smudge. */
    .fsh-card-glare {
      position: absolute; inset: 0; z-index: 6; pointer-events: none;
      background: radial-gradient(circle at var(--gx, 50%) var(--gy, 50%),
        rgba(255,255,255,.45), rgba(255,255,255,.10) 32%, transparent 62%);
      mix-blend-mode: soft-light;
      opacity: var(--glare);
      transition: opacity .25s ease;
    }
    /* ⚠️ NO `@keyframes` FOR THIS CARD, AND NO `:hover` RULE EITHER. Three
       timer-driven versions were built and removed over Aug 1 2026 — a hover
       that brightened and sped up the foil, a 20s travelling sweep, a 52s
       rotating slick — and the last of those took the other two's reasoning
       with it. The card is driven by the POINTER and the GYROSCOPE, through
       the custom properties at the top of .fsh-card, and by nothing else. If
       you find yourself adding an animation here, you are re-adding the
       screensaver. */
    /* Reduced motion: the foil STAYS. It is a surface finish, not an
       animation — the layers are static now, so there is nothing to stop, and
       stripping them would leave a plain card where everyone else sees the
       panel's one graphical moment. What goes is the RESPONSE. */
    @media (prefers-reduced-motion: reduce) {
      /* No tilt. shareCardTilt() bails on _reducedMotion so the vars never
         move, but the perspective/transform is pinned off here too — a
         transformed element is its own containing block and rasterises
         differently, and there is no reason to pay that for a card that
         cannot tilt.
         The foil pseudos no longer carry a transform at all — both are inset to
         the card and move by background-position — so there is nothing here to
         pin off, only their opacity easing to stop. */
      .fsh-card::before,
      .fsh-card::after { transition: none; }
      .fsh-card-glare { display: none; }
    }
    .fsh-card-media {
      position: relative; width: 100%;
      /* Inner radius = the card's 22px minus its 3px border. Top corners only:
         this is the top half of the card. See the mask note on .fsh-card. */
      border-radius: 9px 9px 0 0;
      overflow: hidden;
      /* ⚠️ FIXED 16/9 FOR EVERY ARTWORK — it used to be `var(--art-ar)`, the
         piece's own decoded ratio, and that is deliberately gone (Aug 2 2026).
         The card is a SHARE card: it is the same object every time, and a
         dialog that changes shape per artwork made the panel resize under the
         pointer and gave the tilt a different silhouette on every piece. A
         trading card is one size; the artwork is what changes.
         `object-fit: cover` on the image means a portrait is centre-cropped
         rather than letterboxed, so the frame is never bared. */
      aspect-ratio: 16 / 9;
      /* Even at a sane ratio a wide panel can make this tall; the cap keeps the
         sheet from filling the screen. When it bites, `cover` crops rather than
         letterboxing, so the card never shows a bar. */
      max-height: 260px;
      background: #0a0a0a; overflow: hidden;
    }
    .fsh-card-img {
      width: 100%; height: 100%; display: block; object-fit: cover;
      opacity: 0; transition: opacity .25s ease;
    }
    .fsh-card-img.is-ready { opacity: 1; }
    /* The maker's seal — Kero's animated avatar as a foil stamp — lived here
       and was removed (Aug 2 2026). The signature is the DJKERO wordmark alone
       now; the avatar made the caption's right side two objects competing for
       the same corner. The markup went with it. */
    @media (max-width: 599px) {
      /* Proportional to the smaller card — 44px on a 320px card is a badge,
         not a seal. The body's right padding follows it (see .fsh-card-body). */
      /* ⚠️ NO per-phone seal/caption sizes any more — they are cqw, so they
         already scale with the card. Adding pixel overrides back here is what
         made the two screens disagree in the first place. */
    }
    /* Chain pill, bottom-left over the art — the gallery cards put theirs in
       the same corner, so a piece reads the same in both places. */
    .fsh-card-chain {
      position: absolute; left: 10px; bottom: 10px; z-index: 2;
    }
    .fsh-card-chain:empty { display: none; }
    /* Price, opposite corner, filled volt — the only thing here loud enough to
       compete with the artwork, and only when there IS one. */
    .fsh-card-price {
      position: absolute; right: 10px; bottom: 10px; z-index: 2;
      display: inline-flex; align-items: center; height: 26px; padding: 0 11px;
      font-size: var(--fs-micro); line-height: 1; white-space: nowrap;
      color: var(--accent-ink); background: var(--accent);
      border-radius: 4px; font-weight: 400;
    }
    .fsh-card-price:empty { display: none; }
    /* ⚠️ The divider is GREY, not volt. It was a volt hairline back when that
       was the card's only accent; now that the whole card is outlined in the
       accent, a second green line inside it reads as a stray rule rather than
       as structure. One volt element per card. */
    .fsh-card-body {
      position: relative;
      display: flex; flex-direction: column; gap: 5px;
      /* All four in cqw so the caption keeps its share of the card at any
         width. ⚠️ The right one clears the maker's seal (9.6cqw wide at 3cqw
         from the edge) — retune the two together, in the same unit. */
      padding: 3cqw 3cqw 2.9cqw 3cqw; min-width: 0;
      border-top: 1px solid rgba(255,255,255,.10);
      /* Bottom corners, same reasoning as .fsh-card-media's top pair. */
      border-radius: 0 0 9px 9px;
      /* ⚠️ THE CAPTION'S PLATE LIVES HERE, not on .fsh-card. It used to be the
         card's own background; that is now the accent to hide the tilt seam
         (see the note there), so this row has to paint its own or the whole
         caption comes out green.
         #1e1e1e, not #111: against the artwork above — which is frequently
         near-black at its edges — a #111 plate had no edge of its own and the
         caption looked like the picture running out rather than like the
         card's own surface. Grey enough to read as material, dark enough that
         the volt title still carries. */
      background: #1e1e1e;
    }
    /* 15 -> 19px. The title is the card's headline and was sitting only 2px
       above the collection line under it, so the two read as one block; at 19
       there is a real step and the piece is named rather than labelled.
       line-height stays explicit — these are artist strings full of combining
       marks, and `normal` would size the box from whichever fallback face
       shapes them, which moves the card height per artwork. */
    /* The artwork name, in the accent and in the site's TITLE face.
       'Geist', var(--font-art) with a .has-marks fallback — byte for byte what
       #fs-name and .card-name use, so a piece is set the same way in the
       viewer, on its gallery card and here.

       ⚠️ THE .has-marks FALLBACK IS LOAD-BEARING, NOT POLISH. Geist covers the
       base letters but carries NO combining marks, and a face that wins the
       bases while losing the marks SPLITS the shaping run — nothing attaches
       and the title smears sideways, measured at up to 10x its width. Roughly
       27% of this catalogue takes that branch. If you change the face here,
       change it in all three places and keep the fallback; see the long note
       on #fs-name for the width-ratio measurements behind it.

       line-height stays explicit for the same reason it does there: `normal`
       sizes the box from whichever fallback face shapes the marks, which would
       change the card's height per artwork. */
    .fsh-card-name {
      display: flex; align-items: baseline; min-width: 0;
      font-family: 'Geist', var(--font-art); letter-spacing: -0.02em;
      /* 5.2cqw = 24px on a 460px card. The floor keeps a phone legible; it
         only engages below ~290px of card, where proportion matters less than
         being readable at all. */
      font-size: clamp(15px, 5.2cqw, 24px); line-height: 1.25;
      color: var(--accent); text-transform: none;
    }
    .fsh-card-title {
      min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    }
    /* Marks present -> the webfont head is removed ENTIRELY, not demoted: it
       only has to win the base letters to split the run. */
    .fsh-card-title.has-marks { font-family: var(--font-art); }
    .fsh-card-coll {
      /* 13 -> 15px: a step under the 19px title rather than dropping straight
         to interface size, so the collection reads as part of the credit. */
      min-width: 0; font-family: var(--font-art-code);
      font-size: clamp(11px, 3.3cqw, 15px);
      color: var(--sub); text-transform: none;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    }
    .fsh-card-coll:empty { display: none; }
    .fsh-card-foot {
      display: flex; align-items: baseline; gap: 10px; min-width: 0;
      /* Taller than the 3px it was: the date is now 20px against a 13px type
         label, so the row needs air above it or the date crowds the collection
         line it sits under. */
      margin-top: 7px;
    }
    /* Type and date are one cluster in the bottom-left, in one style — same
       size, same colour, same face. They are both metadata about the piece;
       splitting them across the row made the caption read as four unrelated
       corners. The signature opposite is the only thing with its own voice. */
    .fsh-card-type,
    .fsh-card-when { color: var(--sub); font-size: clamp(9px, 2.4cqw, 11px); white-space: nowrap; }
    .fsh-card-type:empty,
    .fsh-card-when:empty { display: none; }
    /* ⚠️ THIS SLOT IS THE ARTIST, NOT THE DATE (see openShare) — the class
       keeps its `-date` name so the CSS, the shuffle-leaf list and that line
       stay in step; the actual date is `.fsh-card-when`, left of the type.

       The size was originally derived from the avatar seal that used to sit
       beside it (cap height matched to the circle). THE SEAL IS GONE, so that
       derivation no longer holds and the size is simply tuned against the card:
       7.4cqw ≈ 34px on a 460px card, 2px down from the 7.83cqw it inherited
       from the seal era.

       Two measured constants still govern it, taken off the font with canvas
       TextMetrics at 100px rather than guessed:
           cap height = 0.60 x font-size      (0.72 was the wrong assumption)
           "DJKERO"   = 5.114 x font-size wide
       ⚠️ THE SECOND ONE IS THE REAL CONSTRAINT. At 5.114x, every 1cqw of
       font-size costs 5.1cqw of row width. Past roughly 8cqw the wordmark
       crowds the type/date cluster opposite and pushes the caption taller,
       which changes the card's whole proportion — so this is not a free knob. */
    .fsh-card-date {
      /* ⚠️ BD ALIEN (var(--font-num)) — the wordmark is the site's display face
         and must never fall back to the UI mono. It briefly did, because an
         edit to the comment above lost its closing `*​/` and this whole
         declaration block was swallowed by it: DJKERO rendered in the inherited
         face at the inherited size and looked like plain text. If the signature
         ever looks "wrong but not obviously broken", check that first. */
      /* ⚠️ WHAT PUSHES IT RIGHT. `margin-left: auto` eats the row's free space,
         so the signature sits against the caption's right padding and lines up
         with the type label on the left. Both of these were lost in the
         swallowed-comment incident above and the wordmark floated ~60px short
         of the edge — right-ish, but aligned to nothing. `flex-shrink: 0` keeps
         it from being squeezed by a long date beside it. */
      margin-left: auto; flex-shrink: 0;
      font-family: var(--font-num); font-size: clamp(12px, 7.4cqw, 38px); line-height: 1;
      color: #d0d0d0; white-space: nowrap;
      /* Nudged 2px off the shared baseline. BD Alien's caps sit high in the
         em box, so on a true baseline the wordmark reads as floating slightly
         above the small label opposite it; 2px down is what makes the two look
         like one line. Optical, not geometric — do not "correct" it back to 0.
         ⚠️ `position: relative` only exists to carry this offset. */
      position: relative; top: 2px;
      /* ⚠️ BASELINE, NOT CENTRED. It briefly used `align-self: center` to sit
         on the avatar's midline; with the avatar gone it pairs with the TYPE
         label across the row instead, and the two only line up if both sit on
         the row's shared baseline (.fsh-card-foot is `align-items: baseline`).
         Centring a 25px wordmark against an 11px label drops it visibly below
         the text it is meant to align with. */
    }
    .fsh-card-date:empty { display: none; }

    /* ⚠️ `.fsh-native-mode` no longer hides anything. It existed to drop the
       per-network grid on devices that have the OS sheet — and there is no grid
       any more, so the rule it carried is gone. The CLASS is still set by
       shareResetNative() and is left in place deliberately: it is the hook to
       style by if the panel ever needs to differ between touch and desktop
       again. A rule targeting `.fsh-opts` here would be dead. */
    /* Auto-fit rather than a fixed column count: nine tiles reflow from 2 up
       to 4 across without a breakpoint each. 132px is the width at which the
       longest label ("Pinterest") still sits on one line beside its mark. */
    .fsh-opts {
      display: grid; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
      gap: 8px;
    }
    .fsh-opt {
      display: flex; align-items: center; gap: 10px; min-width: 0;
      font: inherit; font-size: var(--fs-small); text-align: left; color: var(--text);
      background: var(--white); border: 1px solid var(--border); border-radius: 10px;
      padding: 11px 13px; cursor: pointer;
      /* Caps, like every other control on the site. These were the odd ones
         out in sentence case ("Copy link", "More options…") because their
         labels are written as prose in index.html — the toolbar, the viewer's
         link buttons and the page nav all sit in caps around them.
         ⚠️ On the CONTROL, not on .fsh-lb: the label span is the shuffle leaf,
         and shuffleInto measures each cell's width off the LIVE computed font.
         Casing the leaf itself is fine today but the transform belongs to the
         button, which is the thing being styled. Nothing artist-authored is in
         here — these are interface strings only. */
      text-transform: uppercase;
      transition: border-color .15s, background .15s, color .15s;
    }
    /* --brand shows on HOVER ONLY. At rest every mark is the same white-70 as
       the rest of the viewer chrome, so the grid reads as one control instead
       of nine competing logos; the colour arrives as feedback on the one you
       are pointing at. Default is the accent, so a network added without a
       --brand line still lights up rather than looking dead. */
    .fsh-opt { --brand: var(--accent); }
    .fsh-opt .fsh-ico { flex-shrink: 0; display: block; width: 18px; height: 18px; color: #ffffffb0; transition: color .15s; }
    .fsh-opt .fsh-ico svg { width: 100%; height: 100%; display: block; }
    .fsh-lb { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    /* ⚠️ GATED — a bare `:hover` here made a tile look SELECTED on a phone.
       Touch leaves :hover latched on whatever sits under the finger, and the
       panel opens as a bottom sheet directly beneath the Share button that
       opened it — so the tile that happened to land under that coordinate
       (Reddit, in the 2-column phone grid) came up bordered, brand-coloured
       and scrambling, exactly as if it had been chosen.
       Same gate and same reason as the ticker and the cards: `any-hover` /
       `any-pointer`, NEVER `hover` / `pointer` — see the note on `.card`. */
    @media (any-hover: hover) and (any-pointer: fine) {
      .fsh-opt:hover { border-color: var(--brand); background: var(--hover); }
      .fsh-opt:hover .fsh-ico { color: var(--brand); }
    }
    /* Official brand colours, hover state only (see above). X has no colour —
       its mark is black, which is invisible here, so it takes white.
       One line per LIVE tile: a colour for a data-net with no button in
       index.html is dead weight that reads as a network still being offered.
       Bluesky, LinkedIn, Facebook, Telegram, WhatsApp and Email were all
       removed by request; their entries went with them. */
    /* Copy link + the deep-link it copies, on one line. The URL is the
       flexible track and the ONLY thing allowed to ellipsise — same rule as
       .fa-c-who; `min-width: 0` is what lets it, or the button would be
       pushed out of the panel by a long hash. */
    .fsh-link { display: flex; align-items: center; gap: 10px; min-width: 0; }
    .fsh-url {
      flex: 1 1 auto; min-width: 0;
      font-family: var(--font-code); font-size: var(--fs-micro); color: var(--sub);
      text-transform: none;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      /* Selectable, like every other identifier on the site — the clipboard
         path can be refused (insecure origin, permissions) and this is then
         the only way out. */
      -webkit-user-select: text; user-select: text; -webkit-touch-callout: default;
    }
    /* ── Copy link: centred, and it KEEPS ITS LABEL ──────────────────────
       The row used to be `[ long ellipsised url ][ Copy link ]`. With the URL
       gone (see index.php) a left-aligned button alone in a full-width row
       read as an orphan, so it centres.

       🗑 It briefly dropped to an icon ALONE on desktop, and that is reverted
       (Aug 2 2026, by request: "keep COPY LINK in button"). The label carried
       two jobs beyond naming the action — it is where "Copied ✓" and the
       clipboard-failure line are written — so hiding it meant a successful
       copy printed its confirmation into a `display: none` span and the click
       appeared to do nothing. That needed a whole `.is-msg` class to force the
       label back for two seconds; keeping the label simply deletes the
       problem. ⚠️ If it is ever hidden again, that machinery has to come back
       with it — see shareSetLabel in viewer.js. */
    .fsh-link { justify-content: center; }
    .fsh-opt--flat { flex: 0 0 auto; background: transparent; }
    .fsh-opt--wide { justify-content: center; }

    /* ── …UNLESS "More options…" IS THERE TOO ────────────────────────────
       🚨 iPad Safari, reported Aug 2 2026: "copy link and more option should
       have same size". Measured at 768px before the fix — Copy link 126x42,
       centred; More options… 578x42, full width. Two buttons doing the same
       job, stacked, one a third the width of the other.

       ⚠️ WHY IT SURVIVED ON DESKTOP AND ON PHONES BUT NOT HERE. The ≤600px
       block above already fixes exactly this, and its comment describes this
       very mismatch — but it is scoped to phones. Above 600px nobody saw it,
       because `.fsh-native` is hidden wherever `navigator.share` is absent,
       which is every desktop browser. An iPad is the one common device that is
       BOTH over the breakpoint AND has Web Share, so it is the only place the
       two buttons appear side by side at this width.

       Keyed on `.fsh-native-mode`, which shareResetNative() already toggles on
       #fs-share for precisely the condition that matters: the More button is
       being shown. When Copy is alone it keeps its centred pill.

       ⚠️ `width`, NOT `flex: 1 1 auto`. This rule is deliberately unscoped so
       the two layouts cannot drift apart again — and under the ≤600px column
       (`flex-direction: column`) a flex-grow would stretch the button
       VERTICALLY. A width is inert there (align-items: stretch already sized
       it) and does the work here.

       ⚠️ `justify-content` IS PART OF THE SAME FIX, not a separate tweak. Once
       the button is full width its contents no longer fill it, so the base
       flex-start left-aligns the icon+label against the edge while
       `.fsh-opt--wide` centres More options… — matching widths but mismatched
       contents, which reads worse than the original mismatch did. Both are
       centred, the pair reads as one control repeated.

       🚨 THIS COMMENT ONCE CLOSED EARLY AND LEFT ITS PROSE AS BARE CSS. A
       comment terminator sat above the justify-content paragraph, so the text
       after it parsed as a garbage selector and swallowed the rule below. The
       whole share fix was dead in the browser for two deploys while being
       present in the file and correct over HTTP.

       ⚠️ TWO RULES CAME OUT OF THAT, both learned the hard way:
       1. A rule being SERVED is not a rule being PARSED. Check
          `document.styleSheets` for the selector — not the file, not curl.
       2. NEVER WRITE A COMMENT TERMINATOR INSIDE A COMMENT, not even quoted in
          backticks to talk about one. CSS comments do not nest and backticks
          mean nothing to the parser. Writing this very warning out with the
          literal characters in it broke the file a second time. */
    #fs-share.fsh-native-mode .fsh-opt--flat {
      width: 100%; background: var(--white); justify-content: center;
    }
    /* Copy confirmed — the accent, matching .fs-link-btn.copied. */
    .fsh-opt.is-done, .fsh-opt.is-done:hover { color: var(--accent); border-color: var(--accent); }
    .fsh-opt.is-done .fsh-ico { color: var(--accent); }

    /* ── Artwork activity panel (Verse) ──────────────────────────── */
    #fs-activity {
      display: none; position: fixed; inset: 0; z-index: 2100;
      background: rgba(0,0,0,.8);
      -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
    }
    #fs-activity.open { display: flex; align-items: flex-end; justify-content: center; }
    .fa-panel {
      position: relative; width: 100%; max-width: 620px; max-height: 82vh;
      background: #141414; border: 1px solid var(--border);
      border-radius: 18px 18px 0 0; padding: 22px 20px calc(20px + env(safe-area-inset-bottom, 0px));
      display: flex; flex-direction: column;
    }
    @media (min-width: 600px) {
      #fs-activity.open { align-items: center; }
      /* Fixed height so the panel is the same size no matter how many rows. */
      /* Roomier on desktop — this panel now carries ~16 metadata rows plus the
         feed, and the extra width is what lets contracts and URIs show in full
         instead of ellipsising. */
      .fa-panel { border-radius: 16px; margin: 0 16px; width: min(860px, calc(100vw - 64px)); max-width: none; height: min(660px, 86vh); max-height: 86vh; }
    }
    /* BD Alien, sized as a real heading — matches the source-panel section
       titles (CHAIN / WALLETS / COLLECTIONS), which are the same kind of
       label. Uppercase comes from body; BD Alien is caps-only anyway. */
    .fa-panel h3 {
      margin: 0 0 16px;
      /* Same size as the .fa-*-section h4s (DESCRIPTION / ACTIVITY) — by
         request Jul 26 2026: the three panel headings read as one rank, so
         they take one size rather than a h3/h4 step. */
      font-family: var(--font-num); font-size: 44px; line-height: 1.15;
      font-weight: 400; color: #fff;
    }
    .fa-close {
      position: absolute; top: 16px; right: 16px; width: 34px; height: 34px;
      border-radius: 9px; background: var(--white); border: 1px solid var(--border);
      color: var(--text); font-size: var(--fs-body); cursor: pointer;
      /* Flex-centred since the ✕ text glyph became a lucide `x` SVG (Jul 30
         2026). An <svg> with only a viewBox has no intrinsic size and stretched
         to 32px inside this 34px button — it needs both an explicit size and a
         centring context, which the text version got for free from line-height. */
      display: flex; align-items: center; justify-content: center;
    }
    .fa-close svg { width: 15px; height: 15px; display: block; }
    .fa-close:hover { border-color: var(--accent); color: var(--accent); }
    .fa-tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
    .fa-tab {
      font: inherit; font-size: var(--fs-small); color: var(--sub); background: transparent;
      border: 1px solid var(--border); border-radius: 20px; padding: 7px 16px; cursor: pointer;
      transition: color .15s, border-color .15s, background .15s;
    }
    .fa-tab.active { color: var(--text); border-color: #666; background: var(--white); }
    .fa-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; margin-top: 8px; }
    /* Four aligned columns. Wallet takes the flexible track and is the ONLY
       one allowed to ellipsise; the rest are sized to their content so nothing
       is cropped while there is room. `minmax(0, 1fr)` is required — a grid
       item won't shrink below its content otherwise, so the ellipsis would
       never fire and a long address would push price/date out of the panel. */
    /* ⚠️ ONE grid on the LIST, with each row as `display: contents`. Putting
       the grid on .fa-row instead gives every row its own independent track
       sizing, so `max-content` resolves per row and "Transfer" / "Sale" /
       "Listing" each produce a different Event column width — the columns
       visibly fail to line up. Rows must be contents so all cells belong to
       the same grid.
       Consequence: .fa-row can no longer carry padding or borders (a contents
       box generates no box), so those move onto the CELLS below. */
    .fa-list {
      display: grid;
      grid-template-columns: max-content minmax(0, 1fr) max-content max-content;
      column-gap: 14px;
      font-size: var(--fs-small);
    }
    .fa-row { display: contents; }
    .fa-row > * {
      padding: 10px 2px;
      border-bottom: 1px solid var(--border);
      align-self: baseline;
    }
    .fa-empty { grid-column: 1 / -1; }
    /* Header cells stick individually — a `display: contents` row can't be
       sticky itself, but its cells can. */
    .fa-head > * {
      padding-top: 0;
      border-bottom-color: #3a3a3a;
      font-size: var(--fs-micro); color: var(--sub);
      text-transform: uppercase; letter-spacing: normal;
      position: sticky; top: 0; z-index: 1;
      background: #141414;   /* matches .fa-panel, so rows scroll under it */
    }
    .fa-c-type { color: var(--text); white-space: nowrap; }
    .fa-c-who {
      min-width: 0; color: var(--sub);
      font-family: var(--font-code, monospace);
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }
    .fa-c-who i { font-style: normal; opacity: .6; }
    .fa-c-who i::before { content: ' - '; }
    .fa-c-amt {
      color: var(--accent); text-align: right; white-space: nowrap;
      font-variant-numeric: tabular-nums;
    }
    .fa-head .fa-c-amt { color: var(--sub); }
    .fa-c-date {
      color: var(--sub); text-align: right; white-space: nowrap;
      font-variant-numeric: tabular-nums;
    }
    .fa-l { min-width: 0; }
    .fa-l { max-width: 100%; }
    .fa-l .who { font-size: var(--fs-body); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .fa-l .sub { font-size: var(--fs-small); color: var(--sub); margin-top: 4px; }
    .fa-r { text-align: right; flex-shrink: 0; }
    .fa-r .type { font-size: var(--fs-body); color: var(--text); }
    .fa-r .amt { font-size: var(--fs-small); color: var(--text); margin-top: 3px; font-variant-numeric: tabular-nums; opacity: .85; }
    .fa-r .date { font-size: var(--fs-small); color: var(--sub); margin-top: 4px; font-variant-numeric: tabular-nums; }
    .fa-empty { color: var(--sub); font-size: var(--fs-small); padding: 28px 2px; text-align: center; }
    @media (max-width: 600px) {
      #fs-activity.open { align-items: stretch; }
      .fa-panel {
        margin: 0; width: 100%; max-width: none; max-height: 100%; height: 100%;
        border-radius: 0; padding: 18px 16px calc(18px + env(safe-area-inset-bottom, 0px));
      }
      .fa-panel h3 { font-size: 34px; margin-bottom: 12px; padding-right: 44px; }
      .fa-close { top: 14px; right: 14px; }
      .fa-tabs { gap: 7px; }
      .fa-tab { font-size: var(--fs-small); padding: 6px 13px; }
      .fa-list { column-gap: 10px; }
      .fa-row > * { padding: 9px 2px; }
      .fa-l .who { font-size: var(--fs-body); }
      .fa-r .type { font-size: var(--fs-small); }
    }

    /* ── Page navigation (paged loading mode) ────────────────────── */
    #page-nav {
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 10px;
      flex-wrap: wrap;
      padding: 2px 16px 28px;
      position: relative;
      z-index: 1;
    }
    #page-nav .pg-btn {
      font: inherit;
      font-size: var(--fs-small);
      text-transform: uppercase; letter-spacing: normal;
      color: var(--text);
      background: transparent;
      border: 1px solid var(--border);
      /* 2px → 5px to match the rest of the button chrome (.tb-opt 5px,
         .fs-link-btn / badges 4px). At 2px these read as a different family
         from every other control on the page. */
      border-radius: 5px;
      padding: 11px 18px;
      min-width: 44px;
      cursor: pointer;
      transition: background .15s ease, border-color .15s ease, color .15s ease, transform .1s ease;
      -webkit-tap-highlight-color: transparent;
    }
    @media (any-hover: hover) and (any-pointer: fine) {
      #page-nav .pg-btn:not(:disabled):hover {
        border-color: var(--accent);
        color: var(--accent);
        background: color-mix(in srgb, var(--accent) 7%, transparent);
      }
    }
    #page-nav .pg-btn:not(:disabled):active { transform: translateY(1px); }
    #page-nav .pg-btn:disabled { opacity: .32; cursor: default; }
    #page-nav .pg-info {
      font-size: var(--fs-small);
      color: var(--sub);
      padding: 0 6px;
      min-width: 96px;
      text-align: center;
      font-variant-numeric: tabular-nums;
    }
    @media (max-width: 680px) {
      #page-nav { padding-bottom: 90px; gap: 7px; }
      #page-nav .pg-btn { padding: 8px 11px; font-size: var(--fs-small); }
    }


    /* ── Global accent touches ───────────────────────────── */
    ::selection { background: var(--accent); color: var(--accent-ink); }

    /* ── AMBIENT MODE — the player is the fixed background of home ────
       (Jul 20 2026.) #fs-overlay stays open forever; .fs-ambient demotes
       it behind the page: z-index 0, no pointer events, chrome hidden,
       art dimmed so the home UI reads on top. Home content (all z>0)
       scrolls OVER the fixed player — the solid-bg gallery wall covers
       the artwork as you scroll. closeViewer() = demotion, openViewer()
       = promotion (see viewer.js). */
    #fs-overlay.fs-ambient,
    #fs-overlay.fs-open.fs-ambient {
      z-index: 0;
      pointer-events: none;
    }
    /* Going from the focused viewer to the menu (ambient) desaturates the
       player to BLACK & WHITE and dims it — the .6s filter transition makes
       the colour drain out as the viewer demotes to background. */
    #fs-media-wrap { transition: filter .6s ease; }
    #fs-overlay.fs-ambient #fs-media-wrap {
      filter: grayscale(1) brightness(.62) contrast(1.02);
    }
    /* Ambient veil — a black overlay OVER the B&W artwork, and the fixed
       gradient the whole home floats on. It lives INSIDE the overlay
       (z-index 5) so it sits ABOVE the artwork but BELOW the persistent brand
       block (#fs-home, z-index 11), and — being absolute inside the FIXED
       overlay — it's viewport-fixed and full-bleed (inset:0), so it never
       tiles or leaves a hole at the bottom of the scroll. Ambient only.

       Shape (Jul 24 2026): unchanged at the top — .72 for masthead/#fs-home
       legibility, easing to .5 at 190px so the artwork stays alive behind the
       first rows of the gallery — then a long, progressive fall to near-black
       at the bottom of the SCREEN. Because the layer is viewport-fixed the
       ramp is screen-referenced, not document-referenced: content darkens as
       it scrolls toward the bottom edge and lifts again as it rises, giving
       the home a cinematic vignette and making captions/labels in the lower
       half read cleanly over even the busiest artwork. The extra mid stops
       exist to keep the falloff smooth — with only two stops this wide a
       ramp visibly bands on 8-bit displays. */
    #fs-overlay.fs-ambient::after {
      content: ''; position: absolute; inset: 0;
      z-index: 5; pointer-events: none;
      background: linear-gradient(to bottom,
        rgba(0,0,0,.72) 0,
        rgba(0,0,0,.5)  190px,
        rgba(0,0,0,.55) 30%,
        rgba(0,0,0,.66) 55%,
        rgba(0,0,0,.78) 72%,
        rgba(0,0,0,.88) 86%,
        rgba(0,0,0,.96) 100%);
    }
    /* Most viewer chrome hides while ambient (the brand now lives in the
       toolbar, not the overlay). */
    #fs-overlay.fs-ambient #fs-info,
    #fs-overlay.fs-ambient #fs-close,
    #fs-overlay.fs-ambient #fs-artwork-controls,
    #fs-overlay.fs-ambient #fs-dice,
    #fs-overlay.fs-ambient #fs-prev,
    #fs-overlay.fs-ambient #fs-next,
    #fs-overlay.fs-ambient #fs-spinner,
    #fs-overlay.fs-ambient #fs-loadsize,
    #fs-overlay.fs-ambient #fs-loadbar { display: none !important; }

    /* Home-over-player surfaces: the masthead floats directly on the artwork
       (its legibility scrim lives inside the overlay as .fs-ambient::after
       above, so the brand block sits on TOP of it). The header stays
       transparent. */
    body.ambient-bg #top-gradient { display: none; }

    /* Content floats OVER the living player (Jul 2026): the gallery/nav/footer
       are fully transparent, so the ONE fixed veil (#fs-overlay.fs-ambient::after,
       above) is what every surface floats on. Using a single fixed layer —
       instead of a per-element translucent background — means the B&W artwork
       reads consistently the whole way down and there's no un-veiled "hole"
       at the end of the scroll where a per-element gradient used to run out. */
    body.ambient-bg #gallery,
    body.ambient-bg #page-nav,
    body.ambient-bg #stats,
    body.ambient-bg footer { background: transparent; }
    /* Non-ambient (deep-link focused viewer, or no art) keeps the solid wall. */
    #gallery, #page-nav, #stats, footer { background: var(--bg); }

    /* ── The page behind the OPEN viewer is pure black ────────────────────
       Fixes a hairline of lighter grey along the bottom edge of the viewer,
       reported Aug 2026 on an iPad Pro M4 in Safari — full-screen window ONLY;
       never in a window, never on desktop Safari or Chrome.

       Cause: #fs-overlay is `top: 0; height: 100dvh`, and iOS Safari resolves
       viewport units against the visual viewport ROUNDED DOWN TO A WHOLE CSS
       PIXEL. Safari lays a window out at integer heights, so windowed and
       desktop never see it; full-screen sizes the viewport from the display
       instead, which on this panel lands on a fractional CSS height. 100dvh
       then truncates and the overlay stops a fraction of a pixel short of the
       screen. The overlay also carries `overflow: hidden`, so #fs-info's
       gradient — which bleeds --bleed BELOW the overlay precisely to cover
       this edge — is clipped at that same short bottom and cannot help.

       Through the gap sits the page: <body> and these surfaces at --bg
       (#060606) against the viewer's #000. Six levels of black is nothing on
       an LCD and clearly visible on this iPad's OLED, which is why it reads as
       a transparent line rather than as a gap.

       So: while a FOCUSED viewer is open, everything behind it is #000 — the
       same black as the overlay and as the bottom stop of its gradient — and
       any sub-pixel seam, on any device, has nothing to reveal.

       ⚠️ Why not fix the geometry instead. Every version costs more than it
       saves: `height: calc(100dvh + 1px)` moves every bottom-anchored control
       down a pixel to hide a fraction of one; `min-height: 100%` resolves
       against the ICB, which on an iPhone is the LARGE viewport, and would
       push the info shelf behind the collapsed URL bar on the phones this must
       never break. There is no reason for the wall behind a full-screen black
       viewer to be a different black than the viewer.

       ⚠️ `.viewer-open` — NOT `.ambient-bg`. Ambient mode demotes the overlay
       behind the page and those surfaces go transparent so the artwork shows
       through; blacking them out there would paint over the player. setAmbient()
       in viewer.js removes `viewer-open` exactly when that happens. */
    body.viewer-open,
    body.viewer-open #gallery,
    body.viewer-open #page-nav,
    body.viewer-open #stats,
    body.viewer-open footer { background: #000; }

    /* ── Footer credits ──────────────────────────────────────────────
       One quiet line under the stats: creator + designer. Deliberately
       plain — the CLI voice (mono, --fs-ui, --sub, uppercase inherited
       from body) rather than a designed block, so it closes the page
       without competing with the giant archive numbers above it.
       Background is NOT set here: the `footer` selectors at the end of
       this file already switch it between transparent (ambient player)
       and solid --bg. */
    #site-footer {
      /* ⚠️ --foot-name-lg / --foot-name-sm are NOT declared here any more —
         they moved to .foot-col (below) because they now resolve from `cqw`
         container units, and a container never queries ITSELF. The container
         is .foot-inner; the tokens have to live on a DESCENDANT of it, and
         .foot-col is the nearest one that still inherits to every consumer
         (.foot-name, .foot-logo, .foot-label, both <a>s). Declaring them
         here instead silently resolves cqw against the wrong ancestor. */
      /* ⚠️ position + z-index are LOAD-BEARING, not decoration. In ambient
         mode #fs-overlay is a full-screen `position: fixed` layer that the
         page content floats on top of, and every other section opts out of it
         the same way: #gallery, #stats and #page-nav are all
         `position: relative; z-index: 1`. Without this the footer is a static,
         auto-z-index box and paints UNDERNEATH that overlay — present in the
         DOM, correct size, completely invisible.
         Note `elementsFromPoint` does NOT catch this: the ambient overlay is
         pointer-events:none, so hit-testing reports the footer as topmost
         while painting puts it behind. Check stacking, not hit-testing. */
      position: relative; z-index: 1;
      display: block;
      padding: 28px var(--gutter) calc(36px + env(safe-area-inset-bottom, 0px));
      /* ⚠️ `border-top: none` is an OVERRIDE, not a default. A generic
         `footer { border-top: 1px solid var(--border) }` rule further down is
         still in the sheet and this block matches it by virtue of being a
         <footer> — simply omitting the property here leaves the line drawn.
         The credits read as a quiet sign-off, not a bounded section.
         (That generic rule was written for the second "Powered by…" <footer>,
         which was removed Aug 2 2026. It is kept because #site-footer now
         depends on it — see the note where the element used to be in
         index.php — so this override is still doing real work.) */
      border-top: none;
      font-size: var(--fs-ui); color: var(--sub);
      letter-spacing: normal; text-align: left;
    }
    /* Same measure as #stats-inner (max-width 1240 + auto margins) so the
       credits line up with the source report above rather than with the page
       gutter — change both together if that measure ever moves.
       Two equal columns, matching the WALLETS | SOURCE split up the page. */
    .foot-inner {
      width: 100%; max-width: 1240px; margin: 0 auto;
      /* Columns sized to their CONTENT and sat next to each other, not two
         half-width tracks — `1fr 1fr` pushed "Design by" out to the middle of
         the 1240px measure with a void between the pair. They read as one
         credit block now. */
      /* align-items: flex-START so both columns hang from the same top edge —
         the CREATOR and DESIGN BY labels line up on one baseline. With
         flex-end they were bottom-aligned, and because DJKERO is 52px against
         JCNAOUR's 34px the two labels sat at different heights. */
      /* ⚠️ A 2x2 GRID, not two flex columns. The names are wildly different
         sizes (96px vs 34px) and both had to sit on ONE line. Flex could not
         do it: with each label+name nested in its own column, `align-items`
         on the row aligns the COLUMNS (their first baseline = the label), and
         `align-self` inside a column-flex acts on the cross axis, i.e.
         horizontally — the small name stayed pinned to the top.
         As a grid the labels share row 1 and the names share row 2, and
         `align-items: baseline` then aligns the names to each other. */
      display: grid;
      /* ⚠️ Two EQUAL 1fr tracks mirroring #sources-body's own two-column grid
         (`minmax(0,1fr) minmax(0,1fr)` at `min-width:760px`), NOT the old
         `max-content max-content`. By request the credits now line up with
         the source report's columns: CREATOR sits on the left column's edge,
         DESIGN BY on the right one's, and DJKERO is sized to FILL its track
         (see --foot-name-lg on .foot-col). The old content-sized tracks
         couldn't do that — a track that hugs its content can't be a width
         target for the content inside it. */
      grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
      /* `start`, not `baseline`: the two names sit on the same TOP edge. With
         baseline the 96px and 34px names shared a baseline, which hangs the
         small one low against the big one. */
      align-items: start;
      /* ⚠️ EXACTLY #sources-body's column-gap expression, not a footer-local
         number (was a flat 80px). Both grids must compute the same gap or
         their columns land on different x positions — the whole point of this
         alignment. `vw` (not cqw) is deliberate: it's what #sources-body uses,
         so matching the unit guarantees an identical result. */
      column-gap: clamp(26px, 4vw, 52px); row-gap: 0;
      /* Makes this the query container for the `cqw` sizing on .foot-col.
         cqw (not vw) because vw INCLUDES the scrollbar width — with a ~15px
         scrollbar a vw-derived wordmark overflows its track on desktop.
         Safe here: the box is externally sized (width:100% + max-width), so
         inline-size containment can't feed back into its own width. */
      container-type: inline-size;
    }
    /* display: contents so the label and the name become direct grid items of
       .foot-inner and can be placed in their own rows. Explicit placement is
       required — auto-flow would fill row 1 with (label, name) of the first
       column and row 2 with the second column's pair. */
    /* ⚠️ The size tokens are declared on BOTH children of .foot-inner, not on
       .foot-col alone. .foot-credits is a SIBLING of .foot-col, not a
       descendant, so it inherited nothing — `var(--foot-name-sm)` resolved to
       an invalid value and the credits silently fell back to the footer's
       13px --fs-ui while DJKERO rendered at 56px. They cannot move up to
       .foot-inner either: that element IS the query container, and an element
       never queries itself, so `cqw` there resolves against the wrong box.
       Both children it is. */
    .foot-col { display: contents; }
    .foot-col, .foot-credits {
      /* ⚠️ The name tokens live HERE, not on #site-footer, because `cqw`
         resolves against the nearest ANCESTOR container and an element never
         queries itself — declaring them on #site-footer (the container's
         parent) or on .foot-inner (the container) both resolve to the wrong
         box. `display: contents` doesn't affect inheritance, so every
         consumer below still picks them up.
         ⚠️ EVERY name in this footer shares ONE size — DJKERO+mark, EBOY,
         BÜRO DESTRUCT and JCN — and the reference is the DJKERO LOCKUP filling
         its track exactly. That is what 7.0695 is: the lockup's width as a
         multiple of its own font-size (wordmark 5.1141 + gap 0.16 + mark
         1.796, the mark being 0.611 height-ratio x 291/99 aspect). Measured
         live at 7.0700, so the constant is right.
         Measured widths of the other names, same units:
           BÜRO DESTRUCT  10.6041em   — wider than the track, WRAPS (see below)
           eBOY            3.5250em
           JCN             2.6441em
         ⚠️ A credit longer than the track is EXPECTED to wrap onto two lines
         rather than shrink everything to fit it. That was tried — dividing by
         BÜRO DESTRUCT's 10.6041 instead — and it dragged DJKERO down to ~56px
         with a third of its column empty. Sizing to the lockup and letting the
         long name break is the requested behaviour: BÜRO DESTRUCT sets as
         "BÜRO / DESTRUCT". Nothing forces that break — the name simply exceeds
         the track and wraps at its own space. */
      --foot-name-lg: calc((100cqw - clamp(26px, 4vw, 52px)) / 2 / 7.0695);
      --foot-name-sm: var(--foot-name-lg);
      /* Breathing room under EVERY role label — CREATOR, PFP, BD ALIEN FONT,
         DESIGN & DEV. One knob so the four gaps can never drift apart; raise
         it to open them all up together.
         ⚠️ A FRACTION of the name size, not px — the same rule the rest of this
         footer's rhythm follows. It was a flat 18px, which is tuned for the
         84px desktop lockup and reads as a chasm under the ~33px phone one:
         the gap stayed put while the type it separates shrank by two thirds.
         0.214 reproduces that 18px on desktop and scales to ~7px on a phone. */
      --foot-label-gap: calc(var(--foot-name-lg) * 0.214);
    }
    #site-footer .foot-col:nth-child(1) .foot-label  { grid-column: 1; grid-row: 1; }
    #site-footer .foot-col:nth-child(1) .foot-name   { grid-column: 1; grid-row: 2; }
    #site-footer .foot-col:nth-child(1) .foot-avatar { grid-column: 1; grid-row: 3; }
    /* The credits stack occupies the whole right-hand column, spanning the
       creator's three rows. Its own children flow normally inside it. */
    .foot-credits { grid-column: 2; grid-row: 1 / span 3; }

    /* ── Credits stack ───────────────────────────────────────────────────
       Three label/name pairs. They share ONE size, exactly like DJKERO and JCN
       did before — the size is set by the LONGEST name so nothing overflows,
       and the others simply follow it. Sizing each to fill its own row would
       make "JCN" enormous next to "BÜRO DESTRUCT" for no reason.
       ⚠️ These are deliberately much smaller than DJKERO. The creator wordmark
       is fitted to fill its column (7.0695 lockup ratio); "BÜRO DESTRUCT" is
       13 glyphs against DJKERO's 6, so at that size it would be roughly twice
       the column wide. Fluid in `cqw` so it keeps the footer's auto-width
       character, clamped at both ends so it can neither overflow the column on
       a wide screen nor collapse below readable on a phone. */
    .foot-credits {
      display: flex; flex-direction: column;
      /* Proportional to the shared name size, like every other gap in this
         footer, so retuning that token rescales the rhythm with it. */
      gap: calc(var(--foot-name-lg) * 0.34);
    }
    .foot-credit { display: flex; flex-direction: column; }
    /* ⚠️ No font-size rule here on purpose. The base `#site-footer a` already
       sets --foot-name-sm, which is an alias of --foot-name-lg — so these
       credits inherit the SAME size as DJKERO with no override at all, which
       is the point. Adding one here is what previously made them 32px against
       the wordmark's 84px. */
    /* Closes the label→name gap the same way the creator block does — most of
       it is the font's own ink offset above the caps, not margin, so it is
       pulled up rather than having the label pushed down. */
    #site-footer .foot-credits a {
      /* ⚠️ The creator's expression PLUS a line-height compensation, so all
         four label→name gaps measure the SAME on screen.
         Not a taste value: these names run at line-height 0.70 (tight, for the
         two-line BÜRO / DESTRUCT) while the creator's lockup runs at 1.1, and a
         shorter line box puts the first line's ink higher relative to the box
         the margin acts on. The offset is half that difference —
         (1.1 - 0.70) / 2 = 0.20 — which measured at 17px against the 84px
         desktop size, matching the 0.20 exactly.
         ⚠️ It is expressed as that FRACTION, not the measured 17px: the offset
         is half a line-height, so it scales with the font. Hardcoding it made
         the phone credits sit ~10px lower than CREATOR, since 17px is over half
         the whole gap at 33px type.
         ⚠️ If .foot-credits .cl's line-height changes, recompute the 0.20. */
      margin-top: calc(var(--foot-name-lg) * -0.167 + var(--foot-label-gap) + var(--foot-name-lg) * 0.20);
      overflow-wrap: break-word;
    }
    /* ── Credit line ─────────────────────────────────────────────────────
       One per visual line, so a two-word credit is two ELEMENTS rather than
       one string left to wrap. Nothing about the break then depends on the
       text being wider than its column, which is what made it collapse under
       the hover scramble (see the markup note).
       ⚠️ line-height 0.70, well under 1. BD Alien's em box carries a lot of
       air above and below the caps — at the 1.1 that reads correctly for UI
       text the two lines of BÜRO / DESTRUCT sat a whole cap-height apart and
       looked like two separate credits. Tightened 1.1 → 0.82 → 0.70, which
       sets the lines about as close as the caps allow: at the 84px desktop
       size that is a 59px step against a ~50px cap height, so the strokes come
       near without touching. Only safe because these are ALL-CAPS display
       words with no descenders; do not reuse this value on mixed-case text. */
    .foot-credits .cl { display: block; line-height: 0.70; }

    /* ── End-of-page sign-off ────────────────────────────────────────────
       A closing terminal beat: prompt, command, output, legal.
       Deliberately NORMAL text — small, mono, --sub — against the giant BD
       Alien credits above it, so the page ends on a quiet line rather than
       another display-type block. Left-aligned on the credits' own measure. */
    .foot-signoff {
      width: 100%; max-width: 1240px; margin: 0 auto;
      /* Sits clear of the credits/portrait above. Proportional to the shared
         name size like the rest of the footer's rhythm. */
      margin-top: calc(var(--foot-name-lg, 40px) * 0.55);
      text-align: left;
      font-size: var(--fs-micro); line-height: 1.75; color: var(--sub);
    }
    .foot-term { color: var(--sub); }
    /* Same volt prompt as the sync terminal's `$`, so the page's first and
       last words are visibly the same shell. */
    .foot-prompt { color: var(--accent); margin-right: 8px; }
    .foot-term--out { color: #4d4d4d; }

    .foot-legal {
      margin: calc(var(--fs-micro) * 1.1) 0 0;
      color: #4d4d4d; max-width: 68ch;
      /* The one place on the site that is NOT uppercased: this is fine print,
         and body's global text-transform would shout it. */
      text-transform: none;
    }
    /* The creator's name row: wordmark + mark, one line. It carries the name
       SIZE and line-height itself so its own strut matches the <a> inside it —
       left inheriting the 13px UI font the line box would be set by a font
       nobody can see. */
    .foot-name {
      font-size: var(--foot-name-lg); line-height: 1.1;
      /* Never let the pair break across two lines: at a narrow width the mark
         would drop under the wordmark and the whole point of the lockup goes.
         The phone block below shrinks the mark instead. */
      white-space: nowrap;
    }
    /* The volt cut of the mark, replacing the 💽💾☠ emoji that used to sit on
       a grid row of its own under the name (Jul 27 2026). The SVG is the same
       three glyphs drawn as paths in #00FF00, so it matches --accent — the
       colour the two names already use — instead of the platform's emoji
       palette, and it no longer depends on an emoji font being present. */
    .foot-logo {
      /* Height = BD Alien's CAP height, NOT its em box. Matching cap height is
         what makes the mark read at the same weight as the wordmark beside it;
         at a matched EM height it comes out ~40% too tall — the same trap the
         old .foot-emoji rule was compensating for with its 0.66 factor.
         0.611 = 0.605 / 0.9907, i.e. two measured numbers:
           • 0.605em — BD Alien's cap height, from rasterising DJKERO at 200px
             and scanning (ink top 0.605em above the baseline, descent 0).
           • 0.9907 — the SVG's ink fraction: getBBox() on the single path is
             275.48 x 97.09 inside a 276 x 98 viewBox, so 0.93% of the BOX is
             empty at the bottom and the ink is that much shorter than the
             box the browser scales.
         Re-measure both if the face or the SVG export is ever replaced. */
      height: calc(var(--foot-name-lg) * 0.611); width: auto;
      /* An inline <img>'s bottom edge sits ON the baseline, which is where
         BD Alien's caps sit too — `vertical-align: baseline` does the actual
         alignment work. The translateY below is a small PAINT-ONLY optical
         nudge on top of that, not a substitute for it. */
      vertical-align: baseline;
      /* Measured (see height comment above) at 0.65px too high at the 96px
         desktop size — the mark's ink top sits fractionally above the
         wordmark's cap line even though the boxes line up. Nudged down by
         a proportional amount, ~1px at 96px, so it also holds at the 44px
         phone size instead of drifting back out if pinned to a flat px. */
      transform: translateY(calc(var(--foot-name-lg) * 0.0104));
      /* One word space at this display size, as a fraction of the name size
         like every other measure in this footer — a fixed px gap would read
         airy at 96px and cramped at the 44px phone size. */
      margin-left: calc(var(--foot-name-lg) * 0.16);
    }
    /* ⚠️ HIDDEN at two-column widths, by request — the avatar is a
       single-column-layout element only. Above the breakpoint the footer
       mirrors #sources-body's two tracks (CREATOR | DESIGN BY) and there is
       no third row for a portrait to live in without unbalancing that pair;
       the phone block below is what turns it back on, full-bleed.
       Kept as `display: none` rather than removed from the markup so the
       single-column rule stays a one-liner and the <img> keeps its place in
       the grid's row 3 assignment.
       ⚠️ Uses `kero_thumbnail.gif` (1860x1860 — see the markup comment), NOT
       the `_small` 400x400 file the viewer's #fs-sig-avatar uses: at ~30px in
       the viewer 400px is overkill, but this box goes full column width on
       phones, which is 750+ PHYSICAL px on a 2x-DPR screen — the small file
       would be upscaled and read visibly soft. `object-fit: cover` on the
       correct source is enough on its own; no `image-rendering` override
       wanted here, since this is a photo being DOWNSCALED and the browser's
       default smoothing is the sharp choice — `crisp-edges`/`pixelated` are
       for the opposite case (small pixel art scaled UP) and would make a
       downscaled photo look aliased, not sharper. */
    .foot-avatar {
      /* ⚠️ Shown at EVERY width now (Jul 31 2026). It used to be
         `display: none` above 759px, because the two-column grid mirrored
         #sources-body's CREATOR | DESIGN BY pair and there was no third row for
         a portrait without unbalancing them. The right-hand column is a credits
         STACK now, which is tall enough to sit beside a portrait, so the
         desktop layout has somewhere to put it.
         Width is the column, not a fixed size: it is a grid item in a
         `minmax(0, 1fr)` track, so `width: 100%` IS "as wide as the first
         column" — which is the cap that was asked for, and it stays fluid at
         every width with no breakpoint of its own. aspect-ratio keeps it square
         off that width; a plain `height: auto` on a GIF falls back to the
         file's intrinsic size instead of tracking the column. */
      display: block; width: 100%; height: auto; aspect-ratio: 1 / 1;
      /* Derived from the gallery's token rather than a literal. This radius has
         always been meant to relate to the artwork tiles — it was 18px back
         when Feed cards were 18px, and stayed at 18 when they dropped to 6 and
         then 2, ending up the roundest thing on the page. Tying it to
         --card-radius means it cannot drift again.
         ⚠️ x3, not x1. This is the size-relative roundness argument that the
         --card-radius note itself makes, in the one place it still bites: the
         portrait renders at the FULL column width — 594px desktop, ~347px on a
         phone — where a card's 2px reads as a hard square, while the same 2px
         on a ~200px tile reads as softened. Three times the card value lands
         it back where the two look equally soft. It still tracks the cards, so
         retuning them moves this with them. */
      border-radius: calc(var(--card-radius) * 3);
      object-fit: cover; border: 1px solid rgba(255,255,255,.22);
      /* Own vertical rhythm, separate from the label→name pull above (which
         is a NEGATIVE margin closing the font's internal ink offset) — this
         is a plain positive gap between two ordinary boxes. */
      margin-top: calc(var(--foot-name-lg) * 0.22);
    }
    /* Creator is the first column — bigger than the designer credit beside it.
       ⚠️ Must be prefixed with #site-footer. `.foot-col:first-child a` alone is
       (0,2,1) and LOSES to `#site-footer a` at (1,0,1), so the size silently
       stayed at the base value. */
    #site-footer .foot-col:first-child a { font-size: var(--foot-name-lg); }
    /* ⚠️ Optical top alignment. Both names share a grid row with
       `align-items: start`, so their BOXES already align — but the ink sits
       lower in a bigger box: half-leading and the font's ascent-above-cap both
       scale with font-size, measured at a constant 0.394em for BD Alien. The
       smaller name therefore starts ~25px too high and has to be pushed down
       by that fraction of the SIZE DIFFERENCE. calc() so it stays correct if
       either size is retuned. */
    /* No optical nudge here any more: it existed only to compensate for the two
       names being different sizes. At equal sizes the ink offset is identical,
       so `align-items: start` on the grid row aligns them exactly. If the sizes
       ever diverge again, the compensation is
       `padding-top: calc((lg - sm) * 0.394)` on the second name. */
    /* ⚠️ Vertical rhythm is a FRACTION of --foot-name-lg, never px, and lives
       here rather than in the phone block — that is what keeps the footer
       looking identical at both breakpoints instead of tighter on one. The gap
       under CREATOR / DESIGN BY is mostly the name's own ink offset (~0.394em
       of empty box above the caps), not margin, so it closes by pulling the
       names UP. Applied equally to both, so their optical alignment holds. */
    /* Eased in steps (-0.30 → -0.20 → -0.167), each time to put a few more
       pixels between the labels and the names — ~19px → ~22px at the 96px
       desktop size. Kept as a FRACTION rather than px so mobile scales with it
       and both breakpoints keep the same proportions; a hardcoded nudge would
       read correct on desktop and loose at 44px. */
    /* ⚠️ Applied to whatever IS the name-row grid item: .foot-name in the
       creator column, the bare <a> in the designer column. It cannot go on
       `.foot-col a` any more — the creator's <a> is now inline inside
       .foot-name, and margin-top on an inline box is inert, so the pull would
       silently vanish from the left column only and the two names would sit
       ~16px apart vertically. */
    #site-footer .foot-name,
    #site-footer .foot-col > a {
      margin-top: calc(var(--foot-name-lg) * -0.167 + var(--foot-label-gap));
    }
    /* The label's own line box adds to that gap on top of the name's ink
       offset, so it is tightened too — line-height 1 leaves it exactly as tall
       as its caps. */
    .foot-label { line-height: 1; }

    .foot-label { font-size: var(--fs-micro); color: var(--sub); letter-spacing: normal; }
    /* ⚠️ Phones STACK — one column, four rows. They went two-up for a while
       (creator left, designer right) and that stopped fitting the moment the
       mark joined DJKERO on the name line: measured at 375px the creator side
       needs 308px (225 wordmark + 7 gap + 76 mark) against 341px of usable
       measure once the 14px column gap and jcn's 116px are subtracted, so the
       mark ran straight over the J of JCN.
       ⚠️ It did NOT show up as page overflow — the tracks are `minmax(0, …)`
       and shrank to 225px while their CONTENT stayed 308px wide, so
       scrollWidth still read a clean 375. Check the two columns' rects against
       each other, not the document width.
       Shrinking --foot-name-lg instead would have worked arithmetically
       (≤35px, down from 44) but that is a 20% cut to the wordmark to save a
       layout nobody asked for. */
    /* ⚠️ 759px, NOT 680 — by request, this now matches #sources-body's own
       column breakpoint (`@media (min-width: 760px)`, the "By source" report
       above the footer switching wallets/sources from stacked to side by
       side) instead of the generic 680px used elsewhere on the page. Same
       idea as the 14px padding match a few edits back: two sections that
       should visually behave as one system were on different thresholds.
       Every other 680px media query in this file is UNRELATED (gallery
       columns, toolbar, mosaic view) and was deliberately left alone — this
       swap is footer-only. */
    @media (max-width: 759px) {
      /* Bigger than before (36/18) — with the single column there is now room
         to spare: the creator row measures 308px inside 355px. */
      /* ⚠️ Horizontal padding pinned to 14px, NOT `var(--gutter)` (10px at
         this width) — measured Jul 28 2026: on desktop this footer's inset
         matches #stats-inner exactly (both land at x:100 on a 1440px
         viewport, via the shared max-width:1240+auto-margin pattern the
         comment above documents), but #stats has its OWN mobile override
         (`padding: 26px 14px …`, not tied to --gutter either) while this
         block was still falling back to the global --gutter — so the two
         sections' left edges, flush on desktop, drifted 4px apart on phones.
         14px reinstates the match. If #stats's mobile padding is ever
         retuned, update this to follow it, not --gutter. */
      #site-footer { padding-left: 14px; padding-right: 14px; }
      .foot-inner {
        /* ONE track, so the lockup below fills the whole measure. */
        grid-template-columns: minmax(0, 1fr);
        justify-content: start;
        column-gap: 0;
      }
      /* Single column, so the wordmark fills the CONTAINER, not a half-track:
         the base rule's `(100cqw - gap) / 2 / 7.0695` loses both the gap and
         the /2 here. Still cqw, still the same 7.0695 lockup ratio — see the
         base .foot-col rule for where that number comes from.
         --foot-name-sm is aliased to this var (base rule), so JCN tracks it
         automatically: same font-size as DJKERO, NOT independently fitted to
         its own row, which would make its letters bigger since JCN is the
         shorter word.
         ⚠️ Expect a deliberate step at the 759→760px boundary: below it the
         lockup spans the full measure, above it spans one of two columns, so
         the wordmark roughly halves. That is the requested design (full width
         vs. column width), not a tuning bug — don't "smooth" it by capping
         this value, which would just stop the fill being exact. */
      .foot-col, .foot-credits { --foot-name-lg: calc(100cqw / 7.0695); }
      /* Rows 4-5, under the creator, instead of column 2 — pushed one row
         further down than before (was 3-4) to leave row 3 for .foot-avatar,
         which keeps its desktop grid-row:3 unchanged on phones since it was
         never overridden here. The base rules place this column explicitly
         (auto-flow would interleave the pairs), so the override has to be
         explicit too. */
      /* Stacked under the portrait: CREATOR → DJKERO → image → credits.
          One item to place now, not a label/name pair, because the right-hand
          column became a single .foot-credits block. */
      .foot-credits { grid-column: 1; grid-row: 4; }
      /* Separates the two credits now that they are stacked rather than
         side by side. Proportional to the name size like the rest of the
         footer's rhythm, so it holds if that token is retuned. */
      /* Separates the credits from the portrait above them now that they are
          stacked rather than side by side. Proportional to the name size like
          the rest of the footer's rhythm, so it holds if that token is retuned. */
      .foot-credits { margin-top: calc(var(--foot-name-lg) * 0.34); }
      /* ⚠️ NOT `overflow: hidden`. That was silently CLIPPING an oversized
         name to nothing visible instead of showing the overflow — by request
         ("JCN...just stays and disappears, this is wrong"), a name that ever
         doesn't fit should visibly wrap, not vanish behind a hard clip.
         `overflow-wrap/word-break: break-word` let a single unbreakable token
         (DJKERO, JCN — neither has a space to wrap at) break mid-word as a
         last resort, rather than overflowing invisibly past the clipped edge
         the old rule created. In practice the fluid --foot-name-lg above
         should keep both names inside their column at every width this site
         supports, so this is a safety net, not the primary fix — DON'T treat
         "text still wraps" as license to skip re-deriving the clamp() if the
         lockup or padding changes again. */
      #site-footer a { max-width: 100%; overflow-wrap: break-word; word-break: break-word; }
      /* No spacing overrides here on purpose — the vertical rhythm is
         proportional to --foot-name-lg in the base rules, so shrinking the
         token above rescales it automatically and both breakpoints match. */
      /* Turned back ON here — the base rule hides it, since it is a
         single-column-layout element only (see .foot-avatar above).
         Full column width, by request. aspect-ratio keeps it square off
         `width: 100%`; a plain `height: auto` on a GIF falls back to the
         file's own intrinsic size instead of tracking the column. */
      /* ⚠️ No .foot-avatar override needed here any more — the base rule now
          shows it at every width with `width: 100%`, which is the full measure
          in this single-column layout and the first column's width above 760px.
          One rule, both breakpoints. */
      /* ⚠️ .foot-logo gets NO mobile override any more — it stays the base
         rule's `display: inline; vertical-align: baseline`, same as desktop.
         An earlier version forced it to `display: block` here so it dropped
         under DJKERO on phones; by request ("DJKERO+SVG stay always on same
         line") that's reversed — the fluid --foot-name-lg above is what now
         keeps the whole lockup inside the available width instead. */
    }
    /* The two NAMES render in BD Alien (--font-num, the display face) while
       the connecting words stay in the mono UI voice — same treatment the
       wallet block gives chain names. Sized up a touch because BD Alien runs
       small against Geist Mono at the same px. */
    #site-footer a {
      /* Base size = the DESIGN BY credit. The creator's name is scaled up
         below: it's the artist whose work this is, so it outranks the
         designer's byline rather than matching it. */
      font-family: var(--font-num); font-size: var(--foot-name-sm); line-height: 1.1;
      /* Volt at rest, not just on hover — these two names are the credit, and
         the accent is what the rest of the page uses to mark a name worth
         reading (wallet chain names, page-nav, source rows). */
      color: var(--accent); text-decoration: none;
      /* No underline and no border — the scramble on hover (wired in
         gallery.js's _HOVER_ZONES) plus the accent colour are the feedback.
         A rule underneath the BD Alien caps read as a strikethrough at this
         size. */
      transition: color .15s ease;
    }
    /* any-hover/any-pointer, not hover/pointer — see the note on .card's
       hover block: the plain forms never match on iPad + trackpad. */
    @media (any-hover: hover) and (any-pointer: fine) {
      /* Rest is already accent, so hover brightens instead of switching hue —
         `color: var(--accent)` here would now be a no-op. */
      #site-footer a:hover { color: var(--accent-bright, #fff); }
    }
    #site-footer a:focus-visible {
      outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 2px;
    }
    /* ⚠️ This block MUST stay after the base `#site-footer a` rule above.
       Media queries add no specificity, so when it sat earlier in the file the
       base 34px simply won on source order and the phone sizes never applied —
       DJKERO came out 38px against jcnaour's 34px, i.e. no visible hierarchy.
       Same ratio as desktop (52/34), scaled for a 375px screen. */
    @media (max-width: 680px) {

    }

    /* ── Viewer controls: volt hover ─────────────────────────────────
       The round glass buttons and the action pills used to hover to a
       slightly lighter/darker glass — barely visible over bright artwork,
       and out of step with the rest of the site, where hover means VOLT
       (cards take a volt stroke, page-nav goes accent, wallet names are
       accent). This brings them into that language: accent icon + accent
       hairline + a faint accent wash over the existing plate.

       ⚠️ Every button is listed TWICE — once plain, once `.fs-immersive` —
       and that is not redundancy. `#fs-overlay #fs-dice:hover` is (2,1,0) and
       loses to the existing `#fs-overlay.fs-immersive #fs-dice:hover` at
       (2,2,0), so in immersive mode the plain form alone would silently keep
       the old grey hover. The `.fs-immersive` copies tie that rule exactly,
       and this block sits at the END of the file so the tie resolves here.
       Moving it earlier, or dropping the immersive copies, hands immersive
       mode its grey hover back.

       The icons are `stroke="currentColor"`, so `color` alone re-tints the
       glyph; no per-SVG rule needed.

       any-hover/any-pointer, never hover/pointer — see the note on .card. */
    @media (any-hover: hover) and (any-pointer: fine) {
      #fs-overlay #fs-close:hover,
      #fs-overlay #fs-prev:hover,
      #fs-overlay #fs-next:hover,
      #fs-overlay #fs-dice:hover,
      #fs-overlay #fs-fit:hover,
      #fs-overlay #fs-sound:hover,
      #fs-overlay #fs-mode:hover,
      #fs-overlay #fs-explore:hover,
      #fs-overlay #fs-full:hover,
      #fs-overlay #fs-full-exit:hover,
      #fs-overlay #fs-info-peek:hover,
      #fs-overlay.fs-immersive #fs-close:hover,
      #fs-overlay.fs-immersive #fs-prev:hover,
      #fs-overlay.fs-immersive #fs-next:hover,
      #fs-overlay.fs-immersive #fs-dice:hover,
      #fs-overlay.fs-immersive #fs-fit:hover,
      #fs-overlay.fs-immersive #fs-sound:hover,
      #fs-overlay.fs-immersive #fs-mode:hover,
      #fs-overlay.fs-immersive #fs-explore:hover,
      #fs-overlay.fs-immersive #fs-full:hover,
      #fs-overlay.fs-immersive #fs-full-exit:hover,
      #fs-overlay.fs-immersive #fs-info-peek:hover {
        color: var(--accent);
        border-color: var(--accent);
        background: color-mix(in srgb, var(--accent) 16%, rgba(0, 0, 0, 0.55));
      }
      /* Action pills (View on … / Activity). The filled --buy CTA is excluded:
         it already has its own brighter-fill hover and would lose its contrast
         if it took the outline treatment. */
      /* #fs-counter declares its own colour (#ffffff99), so it does NOT pick
         up the accent by inheriting from the hovered button — it needs to be
         named. Its scramble-on-hover is wired in gallery.js's _HOVER_ZONES. */
      #fs-overlay #fs-info-peek:hover #fs-counter,
      #fs-overlay.fs-immersive #fs-info-peek:hover #fs-counter { color: var(--accent); }
      #fs-overlay .fs-link-btn:not(.fs-link-btn--buy):hover,
      #fs-overlay.fs-immersive .fs-link-btn:not(.fs-link-btn--buy):hover {
        color: var(--accent);
        border-color: var(--accent);
        background: color-mix(in srgb, var(--accent) 10%, transparent);
      }
    }

    /* ══ Explore mode ═══════════════════════════════════════════════════
       A zoom/pan reader for the artwork. Toggled by #fs-explore, which adds
       .fs-explore-on to #fs-overlay. NOT the browser fullscreen API — that
       was the old behaviour of this button and it is gone.

       Everything here is driven by that one class so the mode can never end
       up half-applied: chrome out, explore chrome in, gestures enabled. */

    /* All viewer chrome steps aside. opacity+pointer-events rather than
       display:none so nothing reflows on the way in or out — the artwork must
       not shift under the pointer at the moment you start exploring. */
    #fs-overlay.fs-explore-on #fs-info,
    #fs-overlay.fs-explore-on #fs-artwork-controls,
    #fs-overlay.fs-explore-on #fs-close,
    #fs-overlay.fs-explore-on #fs-sound,
    #fs-overlay.fs-explore-on #fs-prev,
    #fs-overlay.fs-explore-on #fs-next,
    #fs-overlay.fs-explore-on #fs-dice,
    #fs-overlay.fs-explore-on #fs-scrim-top,
    #fs-overlay.fs-explore-on #fs-signature {
      opacity: 0 !important; pointer-events: none !important;
    }
    /* The bottom gradient exists to make the info panel readable; with the
       panel gone it is just a dark band over the art. */
    #fs-overlay.fs-explore-on #fs-info { visibility: hidden; }

    /* The media wrapper becomes the gesture surface. touch-action:none is
       load-bearing on touch devices — without it the browser claims the
       gesture for its own pan/zoom and pointermove events stop arriving. */
    #fs-overlay.fs-explore-on #fs-media-wrap {
      overflow: hidden;
      touch-action: none;
      cursor: grab;
    }
    #fs-overlay.fs-explore-on.is-panning #fs-media-wrap { cursor: grabbing; }
    /* The transform itself is set inline by JS. will-change promotes the layer
       once, at mode-entry, instead of on every frame of a pinch. */
    /* ⚠️ Kill the browser's native image drag. An <img> is draggable by
       default, so a pan gesture starts an HTML5 drag-and-drop — you get a
       translucent ghost of the artwork stuck to the cursor and can drop it
       into another tab or the desktop, while the pan itself dies. Applied to
       the viewer media at all times, not just in explore mode, since the same
       drag can be started on a plain fullscreen image.
       Belt and braces: the `dragstart` handler in viewer.js preventDefaults
       too, because -webkit-user-drag is non-standard and absent in Firefox. */
    #fs-img, #fs-poster, #fs-video, .card-thumb {
      -webkit-user-drag: none;
      user-drag: none;
    }
    /* ⚠️ Explore always shows the WHOLE artwork. Immersive browsing fills the
       screen with object-fit: cover — that is a CROP, and zooming it magnified
       the crop, with the cut-off edges unreachable no matter how far you panned
       (reported live: "zoom accesses the cropped version; with fit mode on, zoom
       is all good"). So the mode forces contain regardless of the fit toggle,
       and drops the fit-mode padding with it: the chrome is gone, there is
       nothing left to inset from, so the artwork gets the whole screen at 1x.
       !important because the padding is also set from a mobile media query.
       exMediaSize() in viewer.js derives the pan limits from this same
       letterboxed geometry — the two have to agree. */
    #fs-overlay.fs-explore-on #fs-img,
    #fs-overlay.fs-explore-on #fs-video {
      object-fit: contain !important;
      padding: 0 !important;
    }
    #fs-overlay.fs-explore-on #fs-img,
    #fs-overlay.fs-explore-on #fs-video {
      will-change: transform;
      transform-origin: center center;
      /* Eases the DISCRETE steps — wheel notches, double-click reset, the
         snap back to 1x — so zoom glides instead of jumping. */
      transition: transform .18s cubic-bezier(.22, .61, .36, 1);
    }
    /* ⚠️ …and is switched OFF while a pointer is down. A transition during a
       drag or pinch makes the artwork lag behind the finger by the full
       duration, which reads as broken rather than smooth: direct manipulation
       must track 1:1. .is-panning is set on pointerdown and cleared when the
       last pointer lifts, so the easing returns for the next wheel step. */
    #fs-overlay.fs-explore-on.is-panning #fs-img,
    #fs-overlay.fs-explore-on.is-panning #fs-video { transition: none; }

    /* Exit control — RIGHT corner, deliberately far from the bottom-left
       cluster the mode was launched from, so it can't be hit mid-pan. */
    /* ⚠️ IDENTICAL BOX to #fs-close and #fs-full-exit — 46px circle, 20px
       lucide `x`, same glass, same corner. It used to be a wider pill with an
       "Exit" label at a 15px icon, which made the top-right corner visibly
       change shape depending on which mode you were in. All three are the same
       "get me out of here" control; only the mode differs. Change one, change
       all three (their mobile sizes live together in the max-width:600px block
       beside #fs-close). */
    #fs-explore-exit {
      position: absolute;
      top: calc(24px + env(safe-area-inset-top, 0px));
      right: calc(24px + env(safe-area-inset-right, 0px));
      z-index: 30;
      display: none; align-items: center; justify-content: center;
      width: 46px; height: 46px; padding: 0;
      border-radius: 50%; cursor: pointer;
      color: #fff; background: rgba(0, 0, 0, 0.42);
      border: 1px solid rgba(255, 255, 255, 0.24);
      -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
      transition: background .15s, border-color .15s, color .15s;
    }
    #fs-explore-exit svg { width: 20px; height: 20px; }
    #fs-overlay.fs-explore-on #fs-explore-exit { display: flex; }
    @media (any-hover: hover) and (any-pointer: fine) {
      #fs-explore-exit:hover {
        color: var(--accent); border-color: var(--accent);
        background: color-mix(in srgb, var(--accent) 16%, rgba(0, 0, 0, 0.55));
      }
    }

    /* Hint — says what the gestures are, then gets out of the way. Fades on a
       JS-set .is-gone class rather than an animation, so re-entering the mode
       always shows it again from a known state. */
    /* Built as a #fs-meta-row pill (the DATE / FORMAT chip) rather than its own
       shape: same 26px height, 0/12px padding, 4px radius, black-55 plate,
       white-18 hairline, blur and --fs-micro type. That family is the viewer's
       "quiet metadata" voice, which is exactly the register a gesture hint
       wants — it should be legible and then ignorable, not announced.
       ⚠️ It does NOT auto-fade. An earlier version dismissed itself after
       3.6s; the instruction is that the text stays up for as long as the mode
       is on, so the affordance is always readable. Keep z-index above the
       artwork layer. */
    #fs-explore-hint {
      position: absolute; z-index: 30;
      left: 50%; transform: translateX(-50%);
      bottom: calc(28px + env(safe-area-inset-bottom, 0px));
      display: none; align-items: center;
      height: 26px; padding: 0 12px;
      border-radius: 4px;
      font-size: var(--fs-micro); color: #ffffff70;
      text-transform: uppercase; letter-spacing: normal;
      background: rgba(0, 0, 0, 0.55);
      border: 1px solid rgba(255, 255, 255, 0.18);
      -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
      white-space: nowrap; pointer-events: none;
    }
    #fs-overlay.fs-explore-on #fs-explore-hint { display: inline-flex; }
    /* One hint, two wordings. any-pointer:fine → a mouse/trackpad is attached,
       so "scroll / double-click"; otherwise the touch wording. */
    .hint-touch { display: inline; }
    .hint-mouse { display: none; }
    @media (any-pointer: fine) {
      .hint-mouse { display: inline; }
      .hint-touch { display: none; }
    }
    @media (max-width: 680px) {
      /* ⚠️ #fs-explore-exit's own shrink rule is NOT here any more — it moved up
         beside #fs-close's, into the max-width: 600px block. It used to switch
         at 680px while #fs-close switched at 600, so anywhere in the 601–680px
         band the two top-right exit controls sat on different corners (24/24 vs
         16/16) and the corner jumped when you entered or left explore mode.
         All three exit controls now share ONE breakpoint. */
      #fs-explore-hint { height: 24px; padding: 0 10px; }
    }

    /* ── Info panel (was the Activity panel) ─────────────────────────────
       Metadata table + optional description + the event feed, in that order.
       The key/value rows borrow the .fs-meta-label / .fs-meta-value voice so
       the panel reads as the same family as the DATE pill in the shelf. */
    /* The single scroll region. .fa-list keeps its own overflow rule for the
       legacy layout, so it is explicitly neutralised inside this body —
       nested scrollers trap the wheel and strand content. */
    .fa-body {
      flex: 1 1 auto; min-height: 0; overflow-y: auto;
      /* ⚠️ Vertical only. A single long unbroken value (contract, ipfs:// URI)
         would otherwise widen the content box and give the whole panel a
         horizontal scrollbar — the rows below clamp their own width, and
         anything that still escapes gets clipped rather than scrolled. */
      overflow-x: hidden;
      -webkit-overflow-scrolling: touch;
      overscroll-behavior: contain;
      /* Thin, dim scrollbar riding the PANEL WALL, not the text column.
         The negative margin cancels .fa-panel's own 20px padding-right so this
         scroll container reaches the panel's inner edge — that's where the
         track is drawn — and the matching padding-right puts the content back
         where it was. Result: the bar sits hard against the side of the popup
         with a full 20px of clearance from the values.
         (`scrollbar-gutter: stable` was tried first; it reserves space but the
         track still sat inside the text column.)
         The margin is 2px short of the panel's 20px padding (by request, Jul 26
         2026) so the bar sits 2px OFF the wall instead of hard against it.
         margin + padding still sum to 20px, so the content column does not
         move — only the track does. */
      margin-right: -18px;
      padding-right: 18px;
      scrollbar-width: thin;
      scrollbar-color: var(--border) transparent;
    }
    .fa-body::-webkit-scrollbar { width: 5px; }
    .fa-body::-webkit-scrollbar-track { background: transparent; }
    .fa-body::-webkit-scrollbar-thumb {
      background: var(--border); border-radius: 3px;
    }
    /* Only picks up the accent while the pointer is actually in the panel, so
       the bar stays invisible-ish at rest. */
    @media (any-hover: hover) and (any-pointer: fine) {
      .fa-body:hover::-webkit-scrollbar-thumb { background: #3a3a3a; }
      .fa-body::-webkit-scrollbar-thumb:hover { background: var(--accent); }
    }
    .fa-body .fa-list { flex: none; overflow: visible; min-height: 0; margin-top: 0; }
    #fs-activity .fa-meta { display: flex; flex-direction: column; margin-bottom: 4px; }
    .fa-meta-row {
      display: flex; align-items: baseline; gap: 14px; flex-wrap: nowrap;
      max-width: 100%; min-width: 0;
      padding: 7px 0; border-bottom: 1px solid var(--border);
      /* Pinned so a value carrying combining marks can't grow the row — the
         same trap documented on .src-crow .nm. */
      line-height: 1.5;
    }
    .fa-meta-row:last-child { border-bottom: none; }
    /* Sized to the widest label rather than a fixed 108px — a fixed basis
       either wastes width (short labels) or wraps (long ones), and every pixel
       it wastes is a pixel the value can't use before it starts cropping. */
    .fa-k {
      flex: 0 0 auto; min-width: 96px; max-width: 132px;
      font-size: var(--fs-micro); color: var(--sub);
      text-transform: uppercase; letter-spacing: normal;
    }
    .fa-v {
      flex: 1 1 auto; min-width: 0;
      font-size: var(--fs-small); color: var(--text);
      /* WRAP, don't ellipsise. These are full titles, collection names and
         descriptions — truncating them defeats the point of an info panel.
         overflow-wrap:anywhere gives long unbroken tokens a break opportunity.
         ⚠️ `overflow: hidden` is still required on top of the wrapping, and
         this is the non-obvious part: `anywhere` breaks between GRAPHEME
         CLUSTERS, never inside one. A zalgo collection name is a single base
         character carrying 40+ combining marks — one unbreakable cluster,
         wider than the panel, which pushed the content box out and gave the
         whole popup a horizontal scrollbar. Clipping is the only thing that
         contains it. max-width pins the flex item to its track. */
      white-space: normal; overflow-wrap: anywhere; word-break: normal;
      max-width: 100%; overflow: hidden;
      text-decoration: none;
    }
    /* Identifiers (wallet, contract, token id, URIs) keep the fixed-pitch face
       — --font-code is the one stack guaranteed not to drift proportional.
       ⚠️ They get ONE line with text-overflow:ellipsis, deliberately different
       from the wrapping prose values above: an address should show in full
       whenever there is room and merely trail off with "…" when there isn't.
       Wrapping a 42-char hex across three lines is worse than a tail. The full
       value is always in the title attribute. */
    .fa-v.is-mono {
      font-family: var(--font-code, monospace);
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    }
    /* Links are green with NO underline — the scramble on hover (registered in
       gallery.js's _HOVER_ZONES) is the feedback instead. */
    a.fa-v { color: var(--accent); text-decoration: none; }
    /* Same BD Alien category treatment as ACTIVITY. */
    #fs-activity .fa-desc-section { margin-top: 18px; }
    #fs-activity .fa-desc-section h4 {
      margin: 0 0 10px; font-weight: 400;
      font-family: var(--font-num); font-size: 44px; line-height: 1.15;
      color: #fff; text-transform: uppercase; letter-spacing: normal;
    }
    @media (max-width: 680px) {
      #fs-activity .fa-desc-section h4 { font-size: 34px; }
    }
    /* ⚠️ Artist-authored text uses --font-art-code (the DevTools/Menlo stack),
       NOT the UI face. Descriptions carry the same combining-mark towers and
       stylized unicode as the titles, and the UI stack's symbol/emoji tail
       renders those as tofu boxes — see the --font-art-code note in :root. */
    .fa-v.is-art { font-family: var(--font-art-code); }
    #fs-activity .fa-desc {
      font-family: var(--font-art-code);
      margin: 0;
      /* line-height 2.1, not the 1.6 that prose would want. These descriptions
         carry the same combining-mark towers as the titles — marks stack far
         above the cap height and below the baseline, so at 1.6 consecutive
         lines collided and the tower of one line overprinted the next.
         Explicit (never `normal`) for the usual reason: a mark falling back to
         a tall-metric face would otherwise size the line box itself. */
      font-size: var(--fs-small); color: var(--sub); line-height: 2.1;
      /* Same containment as .fa-v — artist descriptions carry the same
         unbreakable stylized clusters as the titles. */
      overflow-wrap: anywhere; max-width: 100%; overflow: hidden;
    }
    #fs-activity .fa-activity-section {
      margin-top: 18px;
      /* Breathing room under the last event row so the feed doesn't end flush
         against the panel's bottom edge — the scroll has somewhere to land. */
      padding-bottom: 40px;
    }
    /* "Activity" is a section CATEGORY, same rank as the panel's own "Info"
       title — so it takes the same BD Alien treatment rather than reading as
       a small grey label. Matches the source panel's CHAIN / WALLETS /
       COLLECTIONS headings too. */
    #fs-activity .fa-activity-section h4 {
      margin: 0 0 10px; font-weight: 400;
      font-family: var(--font-num); font-size: 44px; line-height: 1.15;
      color: #fff;
      text-transform: uppercase; letter-spacing: normal;
    }
    @media (max-width: 680px) {
      #fs-activity .fa-activity-section h4 { font-size: 34px; }
    }
    @media (max-width: 680px) {
      .fa-k { min-width: 78px; max-width: 104px; }
      /* Matches the phone panel's 16px padding, less the same 2px wall gap. */
      .fa-body { margin-right: -14px; padding-right: 14px; }
    }

    /* ── Animated hover stroke ───────────────────────────────────────────
       The volt stroke draws IN rather than appearing at full weight, the same
       gesture .card::after already uses (overshoot, then settle). Two
       keyframes because the surfaces differ:

       row-stroke-in — for rows with NO border of their own (.tb-opt,
       .src-crow). Overshoots to 3px and settles at 1px, so the stroke stays.

       ring-pulse — for controls that ALREADY get `border-color: var(--accent)`
       on hover (viewer buttons, link pills, exit). Settling at a non-zero
       width there would sit just inside the border and read as a doubled
       2px edge, so this one returns to 0 and leaves the border alone: the
       animation is the thickening, not the resting state.

       Both are inset box-shadows — a real border would change the box and
       shift the layout mid-hover. */
    @keyframes row-stroke-in {
      0%   { box-shadow: inset 0 0 0 0 var(--accent); }
      55%  { box-shadow: inset 0 0 0 3px var(--accent); }
      100% { box-shadow: inset 0 0 0 1px var(--accent); }
    }
    @keyframes ring-pulse {
      0%   { box-shadow: inset 0 0 0 0 var(--accent); }
      55%  { box-shadow: inset 0 0 0 2.5px var(--accent); }
      100% { box-shadow: inset 0 0 0 0 var(--accent); }
    }
    @media (any-hover: hover) and (any-pointer: fine) {
      .tb-opt:hover,
      .src-crow:hover {
        animation: row-stroke-in .34s ease-out forwards;
      }
      #fs-overlay #fs-close:hover,
      #fs-overlay #fs-prev:hover,
      #fs-overlay #fs-next:hover,
      #fs-overlay #fs-dice:hover,
      #fs-overlay #fs-fit:hover,
      #fs-overlay #fs-sound:hover,
      #fs-overlay #fs-explore:hover,
      #fs-overlay #fs-full:hover,
      #fs-overlay #fs-info-peek:hover,
      #fs-explore-exit:hover,
      #fs-full-exit:hover,
      #fs-overlay .fs-link-btn:not(.fs-link-btn--buy):hover,
      #page-nav .pg-btn:not(:disabled):hover {
        animation: ring-pulse .34s ease-out forwards;
      }
    }
    /* Motion off: hold the end state so the stroke is still communicated. */
    @media (prefers-reduced-motion: reduce) {
      .tb-opt:hover,
      .src-crow:hover { animation: none; box-shadow: inset 0 0 0 1px var(--accent); }
      #fs-overlay #fs-close:hover,
      #fs-overlay #fs-prev:hover,
      #fs-overlay #fs-next:hover,
      #fs-overlay #fs-dice:hover,
      #fs-overlay #fs-fit:hover,
      #fs-overlay #fs-sound:hover,
      #fs-overlay #fs-explore:hover,
      #fs-overlay #fs-full:hover,
      #fs-overlay #fs-info-peek:hover,
      #fs-explore-exit:hover,
      #fs-full-exit:hover,
      #fs-overlay .fs-link-btn:not(.fs-link-btn--buy):hover,
      #page-nav .pg-btn:not(:disabled):hover { animation: none; }
    }
