/* ==========================================================================
   PocketSkynet — app.css
   A near-black product shell layered on top of Topcoat.

   The look: machine cinema. Dark first, one accent, and separation carried
   by contrast and elevation rather than by hairlines. The accent — Skynet
   optic cyan — appears on the primary action, on the "this is you" tile, on
   the selected row's edge and almost nowhere else; everything that used to
   be tinted is a neutral surface with coloured *ink* instead. Three voices
   of type, each with a job: the system face for reading, mono for machine
   data (addresses, hashes, serials, the ribbon — the places the product is
   allowed to look like an instrument), and Chakra Petch for *names* — rooms,
   screens, dialogs — the one face that is ours. Imagery leads where imagery
   exists: the room list wears its own generated sigils (§6), the way a film
   rack wears posters.

   Load order (required):
     1. topcoat-desktop-light.css   (vendor, unmodified)
     2. app.css                     (this file)

   This sheet never @imports anything. Every vector asset is inline (SVG data
   URIs); the only external files are the illustration PNGs under static/img/
   and the display face under static/fonts/, which ship next to the .wasm
   bundle.

   Dark mode is driven by `light-dark()` + `color-scheme`, so a single token
   block serves both schemes. `<html data-theme="light|dark">` forces one;
   no attribute follows the OS. A `@supports` fallback at the end of §1
   covers engines without `light-dark()`.

   Two axes, two attributes. `data-theme` decides how bright the room is;
   `data-skin` decides what the product looks like. Everything from §2 down is
   written against `--fn-*` tokens and knows about neither — which is what
   makes a skin a block of token overrides (§1b) rather than a second sheet.

   Contents
     1  Tokens            (colour, type, space, elevation, motion)
     1b Skin: cuteskynet  (the same tokens, drawn as a friendly mecha)
     1c Skin: humanskynet  (the same tokens, drawn as a person)
     2  Reset & base
     3  Topcoat overrides (incl. the vendor-leak neutralisers)
     4  Primitives  (identity tile, mono address, lock, badges, pills, spinner)
     5  App shell & two-pane layout
     6  Room list
     7  Chat view  (header, messages, composer, typing indicator)
     8  Members & admins
     9  Modal / dialog
    10  Toast
    11  Empty / error / offline states + illustration art
    12  Login screen
    13  Not found
    14  Motion system     (keyframes + utilities)
    15  Utilities
    16  Responsive & orientation matrix
    17  Reduced motion, forced colours, print
    18  Addenda
   ========================================================================== */


/* ==========================================================================
   1. TOKENS
   ========================================================================== */

/* The display face (see --fn-font-display for where it is allowed to appear).
   Latin subset only, ~10KB a weight: the point of the face is its *shape*,
   and every non-Latin room name falls through to the system stack by design.
   `font-display: swap` because a name in the fallback face for one frame
   beats an unnamed room for three. */
@font-face {
  font-family: "Chakra Petch";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/static/fonts/chakra-petch-600-latin.woff2") format("woff2");
}
@font-face {
  font-family: "Chakra Petch";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/static/fonts/chakra-petch-700-latin.woff2") format("woff2");
}

:root {
  color-scheme: light dark;

  /* --- Brand ------------------------------------------------------------
     `--fn-primary` is the saturated fill — Skynet optic cyan, the colour of
     the guardian's eye in every illustration. Cyan this bright cannot carry
     white text at AA, so anything *filled* with it uses the deep abyss
     `--fn-primary-ink`, and anything that *is* cyan text uses
     `--fn-primary-text`, which is darkened in light mode. Keeping the fill
     vivid is the whole identity; the ink is what moves. */
  --fn-primary:        hsl(190 95% 50%);
  --fn-primary-hover:  hsl(190 95% 45%);
  --fn-primary-active: hsl(192 92% 39%);
  --fn-primary-ink:    hsl(200 90% 10%);
  --fn-primary-text:   light-dark(hsl(193 95% 27%), hsl(187 95% 60%));
  --fn-primary-soft:   light-dark(hsl(190 95% 45% / 0.10), hsl(188 95% 55% / 0.16));
  --fn-primary-wash:   light-dark(hsl(190 95% 45% / 0.16), hsl(188 95% 55% / 0.22));
  --fn-primary-line:   light-dark(hsl(191 80% 40% / 0.34), hsl(188 90% 58% / 0.38));
  /* The HUD glow behind interactive chrome — pure theatre, never a surface. */
  --fn-glow:           hsl(190 100% 55% / 0.35);

  /* --- Surfaces ----------------------------------------------------------
     Dark is the default theme (`session.rs` stores Dark when nothing is set)
     and the palette is built for it: a cold, deliberately *blue* near-black —
     the command-center dark the cinematic art was generated against. The
     steps are luminance, not hue — 4% → 8% → 11% → 16% → 22% — because in
     this design *contrast between surfaces* is what separates regions of the
     screen. Borders are the fallback, not the mechanism.

     Light mode is the same structure inverted, and stays fully supported. */
  --fn-bg:        light-dark(hsl(210 18% 96%), hsl(214 32% 4%));
  --fn-surface:   light-dark(hsl(0 0% 100%),   hsl(214 26% 8%));
  --fn-surface-2: light-dark(hsl(210 20% 98%), hsl(214 24% 11%));
  --fn-surface-3: light-dark(hsl(210 16% 93%), hsl(214 20% 16%));
  --fn-surface-4: light-dark(hsl(210 12% 87%), hsl(214 18% 22%));
  --fn-overlay:   light-dark(hsl(214 30% 10% / 0.52), hsl(214 40% 2% / 0.80));

  /* --- Ink --------------------------------------------------------------
     Every value here clears 4.5:1 on --fn-bg, --fn-surface and --fn-surface-3
     in its own scheme. `faint` used to be 3.1:1 in light mode, which is why
     timestamps and the ledger slug were unreadable on a bright display. */
  --fn-fg:        light-dark(hsl(214 20% 8%),  hsl(210 24% 97%));
  --fn-fg-muted:  light-dark(hsl(214 10% 38%), hsl(212 12% 72%));
  --fn-fg-faint:  light-dark(hsl(214 8% 38%),  hsl(212 10% 66%));

  /* --- Lines ------------------------------------------------------------
     Three weights, because one border does three different jobs: `hair`
     separates things that belong together (rows in a list) and should barely
     register; `border` draws a surface's own edge; `border-strong` is for a
     control you are meant to see the boundary of. */
  --fn-hair:          light-dark(hsl(210 16% 91%), hsl(0 0% 100% / 0.05));
  --fn-border:        light-dark(hsl(210 16% 88%), hsl(0 0% 100% / 0.08));
  --fn-border-strong: light-dark(hsl(210 12% 76%),  hsl(0 0% 100% / 0.18));
  /* Text fields carry their own line: a shade darker than a surface edge, so
     an empty input still reads as a place to type. */
  --fn-field-line:    light-dark(hsl(210 12% 72%),  hsl(0 0% 100% / 0.16));
  /* A 1px rim of light. On dark surfaces this — not a shadow — is what
     reads as elevation; a black shadow on a near-black page is invisible. */
  --fn-rim: light-dark(hsl(0 0% 100% / 0), hsl(0 0% 100% / 0.05));

  /* --- Materials ---------------------------------------------------------
     A raised surface is not a flat fill. Every panel that sits above the page
     is painted with a 1° vertical gradient — lighter at the top edge where the
     light is, a shade deeper at the bottom — and topped with `--fn-hi`, a 1px
     inner highlight along its upper edge. Together they are what separates a
     "card" from "a rectangle with a border". */
  --fn-raise-a: light-dark(hsl(0 0% 100%),   hsl(214 24% 14%));
  --fn-raise-b: light-dark(hsl(210 20% 97%), hsl(214 26% 10%));
  --fn-sunk-a:  light-dark(hsl(210 20% 96%), hsl(214 30% 5%));
  --fn-sunk-b:  light-dark(hsl(0 0% 100%),   hsl(214 26% 9%));
  --fn-hi: light-dark(inset 0 1px 0 hsl(0 0% 100% / 0.9), inset 0 1px 0 hsl(0 0% 100% / 0.05));

  /* --- Semantic ---------------------------------------------------------
     `X` marks (dots, icons, borders — 3:1) and `X-text` labels (4.5:1). */
  --fn-encrypt:      light-dark(hsl(160 84% 30%), hsl(160 70% 52%));
  --fn-encrypt-text: light-dark(hsl(163 90% 22%), hsl(160 70% 56%));
  --fn-encrypt-soft: light-dark(hsl(160 84% 34% / 0.12), hsl(160 70% 50% / 0.16));
  --fn-crown:        light-dark(hsl(41 96% 38%), hsl(45 93% 60%));
  --fn-crown-text:   light-dark(hsl(38 94% 26%), hsl(45 93% 64%));
  --fn-crown-soft:   light-dark(hsl(43 93% 45% / 0.18), hsl(45 93% 55% / 0.18));
  --fn-danger:       light-dark(hsl(0 74% 42%), hsl(0 78% 65%));
  --fn-danger-text:  light-dark(hsl(0 76% 40%), hsl(0 84% 74%));
  --fn-danger-fill:  light-dark(hsl(0 72% 42%), hsl(0 70% 58%));
  --fn-danger-ink:   light-dark(hsl(0 0% 100%), hsl(0 60% 8%));
  --fn-danger-soft:  light-dark(hsl(0 72% 48% / 0.10), hsl(0 78% 60% / 0.16));
  --fn-online:       light-dark(hsl(145 80% 30%), hsl(142 65% 55%));
  --fn-online-text:  light-dark(hsl(145 82% 23%), hsl(142 62% 62%));
  --fn-info:         light-dark(hsl(212 88% 42%), hsl(212 85% 68%));
  --fn-info-text:    light-dark(hsl(212 90% 38%), hsl(212 85% 72%));
  --fn-info-fill:    light-dark(hsl(212 88% 40%), hsl(212 82% 64%));
  --fn-info-ink:     light-dark(hsl(0 0% 100%), hsl(214 72% 9%));
  --fn-info-soft:    light-dark(hsl(212 88% 48% / 0.10), hsl(212 85% 60% / 0.16));

  /* --- Focus ------------------------------------------------------------
     Two rings: the brand colour, plus a contrasting halo so the ring stays
     visible whether it lands on white, on the orange CTA, or on a dark
     panel. `--fn-focus-halo` is the *opposite* end of the scale. */
  --fn-focus:      light-dark(hsl(192 95% 36%), hsl(188 95% 60%));
  --fn-focus-halo: light-dark(hsl(0 0% 100%), hsl(214 32% 4%));

  /* --- HUD ---------------------------------------------------------------
     The machine half of the identity. Four primitives, all of them one or
     two pixels of light — a targeting frame, a luminous hairline, a bloom
     and a grid. They are the entire Terminator budget: anything that needs a
     panel, a bevel or a texture to work is out of scope, because "sleek,
     fast, simple" outranks the costume every time.

       glow      the bloom under the one saturated control on a screen
                 (--fn-sh-cta / --fn-sh-glow, in the elevation block above)
       dot       a lit indicator: the connection pill, the testnet ribbon
       bar       a 3px accent edge on the selected row
       grid      2% lattice, for large empty surfaces only

     Everything else that was drafted here — corner brackets, luminous
     hairlines, a second glow token — was cut. Each one worked in isolation
     and none of them survived being on the same screen as the others: the
     brief is sleek first and machine second, and a HUD cue only reads as one
     while it is the only one in view. */
  --fn-grid:      light-dark(hsl(214 20% 20% / 0.026), hsl(0 0% 100% / 0.022));
  /* The stage's corner falloff (§5). Black with a blue cast, absent in light. */
  --fn-vignette: light-dark(hsl(214 30% 10% / 0), hsl(216 45% 2% / 0.55));
  /* Poster strength on the rack (§6): rest → hover → selected. Plain
     numbers, so `light-dark()` cannot carry them — it only takes colours,
     and a number inside it drops the whole declaration, which lands the
     poster at `opacity: 1`. Dark values here; the light branches below
     follow the same forcing pattern the schemes themselves use. */
  --fn-poster-rest: 0.14;
  --fn-poster-hover: 0.26;
  --fn-poster-lit: 0.38;

  /* The login scrim, three stops laid over the artwork. `a` is where the form
     sits and is effectively opaque; `c` is where the illustration is allowed
     to be itself. `base` is the plate behind all of it, for the frame the
     source does not cover.

     Deliberately **not** `light-dark()`, and this is the one token group in §1
     where that is true. The artwork is lit against black in every skin, and a
     light scrim over it washes it to a grey smear — which is what the light
     theme used to do before §12 hard-coded these dark. The form card above is
     still themed, so light mode remains light where anyone is reading.

     They were previously written as `light-dark()` here and then ignored:
     §12 pasted the dark halves in as literals, so these three declarations
     were dead in all three skins and a skin could not touch its own sign-in.
     §12 reads them now, and they are plain values so the two `@supports`
     fallback blocks no longer need to restate them. */
  --fn-scrim-a: hsl(214 30% 3% / 0.96);
  --fn-scrim-b: hsl(214 30% 3% / 0.84);
  --fn-scrim-c: hsl(214 30% 3% / 0.42);
  --fn-scrim-base: hsl(214 30% 3%);
  /* The hairline ring around the sign-in card. A token because it is the one
     edge on that screen a skin can use to say what it is. */
  --fn-login-edge: light-dark(hsl(240 10% 50% / 0.14), hsl(0 0% 100% / 0.09));

  /* --- Type -------------------------------------------------------------
     The UI face is the platform's own. Topcoat vendors Source Sans under the
     family name "Source Sans" (not "…Pro"), so the old first entry in this
     stack never matched anything and every glyph on screen was already
     system-ui — but only at the weights Source Sans ships, had it matched.
     Asking for the system face by name instead gets a variable font with a
     real 300–800 range, which is what the scale below is built on. */
  --fn-font-ui: system-ui, -apple-system, BlinkMacSystemFont,
                "Segoe UI Variable Text", "Segoe UI", Roboto,
                "Helvetica Neue", Arial, sans-serif;
  --fn-font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono",
                  Menlo, Consolas, monospace;
  --fn-font-emoji: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
  /* The display face — the one piece of type that is *ours*. Chakra Petch:
     squared terminals and engineering-drawing geometry, which is the HUD
     register this product speaks in, without the sci-fi-novelty face that
     usually comes with it. Used only where something is being *named* — a
     room, a screen, a dialog — never for running text, and the ui stack sits
     behind it so Korean, Japanese and Cantonese names fall back to the
     system face instead of tofu. Vendored (static/fonts, OFL) like every
     other asset: this app must work on an air-gapped LAN. */
  --fn-font-display: "Chakra Petch", var(--fn-font-ui);

  --fn-t-xs:  0.6875rem;  /* 11px — meta, serials, timestamps        */
  --fn-t-sm:  0.8125rem;  /* 13px — addresses, secondary labels      */
  --fn-t-md:  0.9375rem;  /* 15px — body, message text               */
  --fn-t-lg:  1.0625rem;  /* 17px — room name, dialog title          */
  --fn-t-xl:  1.375rem;   /* 22px — screen title                     */
  --fn-t-2xl: 2rem;       /* 32px — balance figure                   */
  /* Display: the one place type is allowed to be an image rather than a
     label. Fluid, because a 42px wordmark on a 390px phone is a wrap. */
  --fn-t-display: clamp(2rem, 1.3rem + 2.1vw, 2.625rem);
  --fn-t-label: 0.75rem;  /* 12px — field labels, section eyebrows   */

  --fn-lh-tight:   1.25;
  --fn-lh-body:    1.5;
  --fn-lh-ui:      1.35;  /* controls: tighter than prose, looser than a title */
  --fn-lh-display: 1.02;

  /* Tracking. Type gets looser as it gets smaller and tighter as it gets
     larger; these are the four settings the whole product uses. */
  --fn-tr-display: -0.035em;
  --fn-tr-tight:   -0.015em;
  --fn-tr-label:    0.012em;
  --fn-tr-caps:     0.1em;

  /* --- Space (4px base) -------------------------------------------------- */
  --fn-s1: 4px;
  --fn-s2: 8px;
  --fn-s3: 12px;
  --fn-s4: 16px;
  --fn-s5: 20px;
  --fn-s6: 24px;
  --fn-s7: 32px;
  --fn-s8: 48px;

  /* --- Radius ------------------------------------------------------------
     One family. Topcoat's 4px control against a 14px panel is exactly the
     mismatch that makes a modern layout look like it is wearing 2013's
     widgets, so the controls came up to meet the surfaces:
       xs     7px  — chips, menu items, the inner cell of a segmented control
       ctl   10px  — every button and icon button
       field 10px  — every text input and textarea
       card  14px  — bubbles, list cards, popovers, toasts
       panel 20px  — the dialog and login card, the largest surfaces */
  --fn-r-xs:    7px;
  --fn-r-ctl:   10px;
  --fn-r-field: 10px;
  --fn-r-card:  14px;
  --fn-r-panel: 20px;
  --fn-r-pill:  999px;

  /* --- Elevation ---------------------------------------------------------
     A real scale, 0–4, each step a distinct job:
       0  flush        — no lift at all, just the hairline
       1  raised       — buttons, bubbles, chips
       2  floating     — sticky bars, hovered cards
       3  overlay      — menus, popovers, toasts
       4  modal        — the dialog layer
     Every step is two shadows, not one: a tight contact shadow that anchors
     the element to whatever is under it, and a wide, offset ambient shadow
     that is the actual sense of height. A single blur cannot be both — set
     tight it looks stuck to the page, set wide it floats free of it.

     In dark mode the same steps keep the same geometry but a much denser ink
     and lean on the `--fn-rim` hairline, because on a #16191f page a diffuse
     black blur does nothing and only the rim reads as "in front of".

     Only the *ink* switches per scheme. `light-dark()` carries colours, not
     shadow lists: `box-shadow: light-dark(0 1px 2px …, 0 1px 2px …)` is
     invalid at computed-value time and resolves silently to `none`, which is
     how every elevation in this sheet came to be invisible in both themes. */
  --fn-ink-key: light-dark(hsl(224 32% 12% / 0.07), hsl(0 0% 0% / 0.55));
  --fn-ink-amb: light-dark(hsl(224 32% 12% / 0.10), hsl(0 0% 0% / 0.44));

  --fn-elev-0: 0 0 0 1px var(--fn-rim);
  --fn-elev-1: 0 1px 1px var(--fn-ink-key),
               0 1px 3px -1px var(--fn-ink-amb), 0 0 0 1px var(--fn-rim);
  --fn-elev-2: 0 1px 2px var(--fn-ink-key),
               0 5px 12px -3px var(--fn-ink-amb), 0 0 0 1px var(--fn-rim);
  --fn-elev-3: 0 2px 4px var(--fn-ink-key),
               0 14px 30px -10px var(--fn-ink-amb), 0 0 0 1px var(--fn-rim);
  --fn-elev-4: 0 4px 10px -2px var(--fn-ink-key),
               0 30px 64px -18px var(--fn-ink-amb), 0 0 0 1px var(--fn-rim);
  /* Legacy aliases — §3–§13 were written against these names. */
  --fn-sh-1: var(--fn-elev-1);
  --fn-sh-2: var(--fn-elev-2);
  --fn-sh-3: var(--fn-elev-4);
  /* The CTA's shadow is tinted with its own hue rather than with grey: a
     neutral shadow under a saturated fill reads as dirt under the button. */
  --fn-sh-cta:  0 1px 2px hsl(192 80% 24% / 0.24), 0 4px 10px -3px hsl(191 90% 38% / 0.34);
  --fn-sh-glow: 0 1px 2px hsl(192 80% 24% / 0.26), 0 8px 22px -6px hsl(190 95% 44% / 0.52);
  --fn-sh-inset: inset 0 1px 2px light-dark(hsl(214 20% 20% / 0.18), hsl(0 0% 0% / 0.5));

  /* --- The call to action, and the ignition ------------------------------
     These two were written as literal `hsl(190 …)` inside §3 and §18, which
     worked for exactly as long as there was one accent — and then read as a
     bug the day a second skin arrived, because the loudest control on every
     screen and the whole sign-in cold open stayed cyan while everything
     around them turned cobalt.

     They are tokens rather than derivations of `--fn-primary` because a CTA
     fill is not one colour: it is a two-stop ramp inside a hue, a darker
     line, and a sheen along the top edge, and the relationship between those
     four is a judgement about how light falls on a raised object. `color-mix`
     on the primary would produce four values that are arithmetically related
     and visually wrong.

     `cta-a` is the lit top of the ramp, `cta-b` its shaded bottom; the ink
     clears AA against `cta-b`, so the gradient can only improve contrast. */
  --fn-cta-a:      hsl(188 96% 52%);
  --fn-cta-b:      hsl(191 95% 43%);
  --fn-cta-a-hi:   hsl(188 96% 58%);
  --fn-cta-b-hi:   hsl(191 95% 45%);
  --fn-cta-press:  hsl(192 92% 40%);
  --fn-cta-line:   hsl(192 92% 36%);
  --fn-cta-sheen:    hsl(186 100% 82% / 0.5);
  --fn-cta-sheen-hi: hsl(186 100% 84% / 0.55);

  /* The cold open's light (§18.2). `spark` is the lit particle, `flare` the
     near-white core of the discharge, `beam` the bright end of the progress
     bar and the colour the boot title is set in. */
  --fn-spark: hsl(186 100% 82%);
  --fn-spark-halo: hsl(190 100% 60% / 0.9);
  --fn-flare: #eafcff;
  --fn-flare-mid: hsl(190 100% 62%);
  --fn-bloom: hsl(188 100% 70% / 0.85);
  --fn-beam: hsl(186 100% 72%);
  --fn-beam-deep: hsl(190 95% 45%);
  --fn-boot-title: hsl(188 95% 66%);
  /* The sweep that plays across a dialog header when it opens. */
  --fn-sweep: hsl(190 100% 70% / 0.10);

  /* --- Layout metrics ---------------------------------------------------- */
  --fn-list-w: 300px;
  --fn-topbar-h: 52px;
  --fn-navbar-h: 56px;
  --fn-bubble-max: 62ch;
  --fn-stream-max: 100%;
  --fn-safe-top:    env(safe-area-inset-top, 0px);
  --fn-safe-bottom: env(safe-area-inset-bottom, 0px);
  --fn-safe-left:   env(safe-area-inset-left, 0px);
  --fn-safe-right:  env(safe-area-inset-right, 0px);

  /* --- Per-identity hue (overwritten inline from the address hash) ------- */
  --fn-hue: 24;

  /* --- Motion -----------------------------------------------------------
     The vocabulary is a straight port of the React client's
     `src/lib/motion.ts` (Framer Motion). Curves that Framer expresses as
     physical springs are approximated here; docs/MOTION.md shows the
     derivation for each. Summary:

       spring(k, c) with m=1  →  ω₀=√k, ζ=c/(2√k)
       visible duration ≈ 5/(ζω₀)      overshoot ≈ exp(-πζ/√(1-ζ²))

       snappy  460/38  ζ=0.886  →  263ms,  0.3% overshoot  → plain ease-out
       soft    320/30  ζ=0.839  →  335ms,  0.8% overshoot  → plain ease-out
       bubble  380/26  ζ=0.667  →  385ms,  6.0% overshoot  → bezier w/ y>1
       bouncy  640/16  ζ=0.316  →  625ms, 35.1% overshoot  → keyframes
   */
  --fn-ease:         cubic-bezier(0.2, 0, 0.2, 1);
  --fn-ease-expo:    cubic-bezier(0.22, 1, 0.36, 1);      /* easeOutExpo   */
  --fn-ease-cosine:  cubic-bezier(0.37, 0, 0.63, 1);      /* easeCosine    */
  --fn-ease-exit:    cubic-bezier(0.4, 0, 1, 1);          /* viewVariants  */
  --fn-spring-snappy: cubic-bezier(0.19, 0.91, 0.26, 1);
  --fn-spring-soft:   cubic-bezier(0.21, 0.84, 0.29, 1);
  --fn-spring-bubble: cubic-bezier(0.16, 1.06, 0.32, 1);
  /* `bouncy` cannot be a bezier — 35% overshoot needs real keyframes, so it
     is the `fn-pop` animation. This curve is its per-segment easing. */
  --fn-spring-bouncy: cubic-bezier(0.33, 0, 0.67, 1);

  /* Durations, rescaled. The curves above are the React client's; the times
     are not. Every entrance now lands inside 150–250ms, because perceived
     speed is part of the design and a 380ms bubble that "feels nice" in
     isolation is 380ms of a message not being there. The only long animation
     left in the sheet is the ambient float, which nothing waits for. */
  --fn-dur-fast:   90ms;
  --fn-dur:        140ms;
  --fn-dur-snappy: 180ms;
  --fn-dur-expo:   220ms;
  --fn-dur-soft:   200ms;
  --fn-dur-bubble: 220ms;
  --fn-dur-cosine: 240ms;
  --fn-dur-bouncy: 260ms;
  --fn-dur-exit:   140ms;
  --fn-dur-float:  6s;

  /* listContainer: staggered entrances, capped hard. A stagger is a texture,
     not a queue — past the sixth row everything arrives together. */
  --fn-stagger:      22ms;
  --fn-stagger-max:  140ms;
  --fn-stagger-grid: 8ms;       /* gridContainer: staggerChildren 0.012 */
  --fn-stagger-grid-max: 110ms;

  /* tapScale. The hover step is the Netflix tile lift: small, immediate,
     and paired with a brightness change rather than a shadow. */
  --fn-tap-hover: 1.02;
  --fn-tap-press: 0.97;

  /* Watermark strengths for the cinematic artwork. These CANNOT be
     `light-dark()` — that function only exists in *color* contexts, so
     `opacity: light-dark(0.05, 0.28)` is invalid CSS, the declaration is
     dropped, and the artwork renders at opacity 1: a photoreal black disc
     stamped on a white dialog. (Found live; the numbers themselves were
     always right, the function they were wrapped in was not.) The photoreal
     set is lit against black, so it can carry far more presence on a dark
     surface than on a light one. */
  --fn-mark-grid:   0.05;
  --fn-mark-vault:  0.08;
  --fn-mark-avatar: 0.10;

  /* --- Art registry (§1.1) -----------------------------------------------
     Every illustration this sheet paints, named once. Nothing below this
     block writes a `/static/img/…` literal; §11 and §18 reach for `--img-*`
     instead, and `src/asset.rs` does the same job for the `<img>` half.

     The indirection exists for one reason: a skin cannot rewrite a `url()`
     that is spelled out inside a rule. CSS has no string concatenation — you
     cannot build `url("/static/img/" + var(--dir) + "/logo.png")` — so a
     second art direction either restates every rule in §11 (and then drifts
     the moment one is added) or the URLs become tokens. They became tokens.

     A skin overrides only the entries it actually redraws; the rest keep
     resolving to the base artwork, which is what lets a skin ship with a
     few dozen pictures instead of eighty-seven. See `asset.rs`, whose
     `CUTE_ART` is the same list on the Rust side. */
  --img-logo:                url("/static/img/logo.png");
  --img-skynet-hero:         url("/static/img/skynet-hero.png");
  /* These five were written as `var(--img-skynet-avatar)` and so on — each
     token defined as itself. A self-reference makes a custom property
     *guaranteed-invalid*, so every rule below that used one computed to
     nothing and the base skin simply stopped painting the shell backdrop, the
     assistant watermark, both cold-open frames and the vault hall. It was
     invisible in review because the names are right, and invisible in the
     cute skin because §1b redeclares all five with real URLs. */
  --img-skynet-avatar:       url("/static/img/skynet-avatar.png");
  --img-skynet-grid:         url("/static/img/skynet-grid.png");
  --img-boot-sphere:         url("/static/img/boot-sphere.png");
  --img-boot-endoskull:      url("/static/img/boot-endoskull.png");
  --img-bank-vault-hall:     url("/static/img/bank-vault-hall.png");
  --img-bank-emblem:         url("/static/img/bank-emblem.png");
  --img-dashboard-emblem:    url("/static/img/dashboard-emblem.png");
  --img-banker-core:         url("/static/img/banker-core.png");
  /* Themed pairs. The `-dark` half is a genuinely different file, not a
     filter: a flat PNG cannot adapt its own baked-in background plate. */
  --img-empty-rooms:         url("/static/img/empty-rooms.png");
  --img-empty-rooms-dark:    url("/static/img/empty-rooms-dark.png");
  --img-empty-messages:      url("/static/img/empty-messages.png");
  --img-empty-messages-dark: url("/static/img/empty-messages-dark.png");
  --img-empty-invitations:      url("/static/img/empty-invitations.png");
  --img-empty-invitations-dark: url("/static/img/empty-invitations-dark.png");
  --img-empty-search:        url("/static/img/empty-search.png");
  --img-empty-search-dark:   url("/static/img/empty-search-dark.png");
  --img-empty-files:         url("/static/img/empty-files.png");
  --img-empty-files-dark:    url("/static/img/empty-files-dark.png");
  --img-empty-knowledge:     url("/static/img/empty-knowledge.png");
  --img-empty-knowledge-dark: url("/static/img/empty-knowledge-dark.png");
  --img-empty-publish:       url("/static/img/empty-publish.png");
  --img-empty-publish-dark:  url("/static/img/empty-publish-dark.png");
  --img-pick-room:           url("/static/img/pick-room.png");
  --img-pick-room-dark:      url("/static/img/pick-room-dark.png");
  --img-encrypted-badge:     url("/static/img/encrypted-badge.png");
  --img-encrypted-badge-dark: url("/static/img/encrypted-badge-dark.png");
  --img-error-offline:       url("/static/img/error-offline.png");
  --img-error-offline-dark:  url("/static/img/error-offline-dark.png");
  --img-bank-hero:           url("/static/img/bank-hero.png");
  --img-bank-hero-dark:      url("/static/img/bank-hero-dark.png");
  --img-bank-banker:         url("/static/img/bank-banker.png");
  --img-bank-banker-dark:    url("/static/img/bank-banker-dark.png");
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --fn-mark-grid:   0.28;
    --fn-mark-vault:  0.16;
    --fn-mark-avatar: 0.18;
  }
}
:root[data-theme="dark"] {
  --fn-mark-grid:   0.28;
  --fn-mark-vault:  0.16;
  --fn-mark-avatar: 0.18;
}
:root[data-theme="light"] {
  --fn-mark-grid:   0.05;
  --fn-mark-vault:  0.08;
  --fn-mark-avatar: 0.10;
}

:root[data-theme="light"] { color-scheme: only light; }
:root[data-theme="dark"]  { color-scheme: only dark;  }


/* ==========================================================================
   1b. SKIN — cuteskynet
   ==========================================================================

   The second art direction. Everything above is §1's machine cinema; this
   block is the same product drawn as a small friendly mecha — the one in
   `cuteskynet.jpg`: cobalt shell, gold visor, one red signal light, thick
   confident outlines, nothing sharp.

   # Why a skin is a second attribute, not four more themes

   `data-theme` answers "how bright is the room". `data-skin` answers "what
   does the product look like". They are genuinely independent — the cute skin
   has a dark face and the skynet skin has a light one — so folding them into
   one four-valued picker would force every future skin to double the list and
   would make "follow the OS" meaningless for half of it.

   # Why this is a token override and not a second stylesheet

   Sections 2–18 below are written entirely against `--fn-*`. Not one of them
   names a colour, a radius or a shadow directly. So a skin is complete when
   it has restated the tokens, and a rule added to §7 next month is themed by
   both skins the day it lands — which a parallel sheet could never promise.
   The rule for anyone editing below: if you find yourself typing a hex value
   or a pixel radius outside §1/§1b, that is the bug.

   # What actually changes, and why each one earns its place

   * Hue. Cyan-on-black is the guardian; cobalt-and-gold is the toy. The
     accent moves from a *light source* to a *painted surface*, which is why
     the primary fill here is dark enough to carry white text at AA where §1's
     cyan needed a deep ink instead.
   * Radius. The single loudest signal. §1 runs 7→20px on the theory that a
     squared terminal reads as an instrument; this runs 10→28px, because the
     reference has no corner you could cut yourself on.
   * Type. Chakra Petch's engineering-drawing geometry *is* the skynet
     register, so the display face steps aside for a rounded stack. Deliberately
     a platform stack and not a vendored file: this app has to work on an
     air-gapped LAN and a skin is not worth another 20 KB on the wire.
   * Light. The HUD budget (§1's grid, vignette and bloom) gets turned down
     rather than off — a cute robot still has a glowing visor, but it does not
     live in a command center.

   Specificity note: this block sits *above* the `data-font` rules on purpose.
   Both are (0,2,0), so source order decides, and an explicit font choice in
   Settings must beat the skin's default face — not the other way round. */
:root[data-skin="cuteskynet"] {
  /* --- Brand -------------------------------------------------------------
     The shell's cobalt. Held at 46% lightness rather than the reference's
     brighter mid-tone for one reason: this fill carries button labels, and
     at 46% it clears 4.5:1 against white, so `--fn-primary-ink` can be white
     — the natural ink for a blue button — instead of §1's dark abyss. The
     *hover* step goes lighter here, not darker: on a painted surface the lit
     state reads as the surface catching more light. */
  --fn-primary:        hsl(214 84% 46%);
  --fn-primary-hover:  hsl(212 86% 52%);
  --fn-primary-active: hsl(216 84% 40%);
  --fn-primary-ink:    hsl(0 0% 100%);
  --fn-primary-text:   light-dark(hsl(216 84% 40%), hsl(209 92% 72%));
  --fn-primary-soft:   light-dark(hsl(214 84% 50% / 0.12), hsl(210 90% 62% / 0.18));
  --fn-primary-wash:   light-dark(hsl(214 84% 50% / 0.18), hsl(210 90% 62% / 0.24));
  --fn-primary-line:   light-dark(hsl(214 70% 46% / 0.36), hsl(210 85% 66% / 0.40));
  /* Softer and wider than §1's optic bloom: a painted highlight, not a lamp. */
  --fn-glow:           hsl(210 95% 62% / 0.30);

  /* --- Surfaces ----------------------------------------------------------
     Both faces are blue, and neither is neutral. Light is the reference
     image's own backdrop — a cool off-white with the faintest green cast,
     the colour a studio wall takes behind a blue subject. Dark deliberately
     stops well short of §1's near-black: at 4% lightness the cobalt has
     nothing to sit against and the whole skin reads as the machine one with
     the hue swapped. 11% keeps it a *night* rather than a void. */
  --fn-bg:        light-dark(hsl(200 26% 95%), hsl(216 42% 11%));
  --fn-surface:   light-dark(hsl(0 0% 100%),   hsl(216 36% 15%));
  --fn-surface-2: light-dark(hsl(200 40% 98%), hsl(215 32% 19%));
  --fn-surface-3: light-dark(hsl(200 24% 91%), hsl(215 28% 25%));
  --fn-surface-4: light-dark(hsl(202 20% 85%), hsl(214 24% 32%));
  --fn-overlay:   light-dark(hsl(215 40% 20% / 0.46), hsl(216 50% 6% / 0.72));

  /* --- Ink --------------------------------------------------------------
     Never pure black. The reference's line art is a very dark desaturated
     navy, and using it for body text is what keeps a light cute surface from
     going harsh — the same reason the dark face's white is tinted cool. */
  --fn-fg:        light-dark(hsl(215 40% 18%), hsl(200 32% 96%));
  --fn-fg-muted:  light-dark(hsl(215 16% 40%), hsl(210 18% 76%));
  --fn-fg-faint:  light-dark(hsl(215 13% 44%), hsl(210 14% 70%));

  /* --- Lines ------------------------------------------------------------
     Heavier than §1 across the board, and that is the art direction rather
     than a preference: §1 separates regions by *contrast between surfaces*
     and treats a border as the fallback. Cel-shaded artwork does the
     opposite — the outline is the drawing. So the hairline stays quiet but
     every real edge gains weight. */
  --fn-hair:          light-dark(hsl(200 22% 88%), hsl(0 0% 100% / 0.07));
  --fn-border:        light-dark(hsl(202 22% 82%), hsl(0 0% 100% / 0.12));
  --fn-border-strong: light-dark(hsl(206 22% 66%), hsl(0 0% 100% / 0.24));
  --fn-field-line:    light-dark(hsl(206 20% 64%), hsl(0 0% 100% / 0.22));
  --fn-rim: light-dark(hsl(0 0% 100% / 0), hsl(0 0% 100% / 0.07));

  /* --- Materials ---------------------------------------------------------
     The gradient stays — a moulded plastic shell has a lit top edge for the
     same reason a dark panel does — but the highlight is stronger, because
     glossy is the point here where in §1 it was restraint. */
  --fn-raise-a: light-dark(hsl(0 0% 100%),   hsl(215 34% 20%));
  --fn-raise-b: light-dark(hsl(200 34% 97%), hsl(216 38% 16%));
  --fn-sunk-a:  light-dark(hsl(200 26% 94%), hsl(217 44% 12%));
  --fn-sunk-b:  light-dark(hsl(0 0% 100%),   hsl(216 36% 16%));
  --fn-hi: light-dark(inset 0 1px 0 hsl(0 0% 100%), inset 0 1px 0 hsl(0 0% 100% / 0.09));

  /* --- Semantic ---------------------------------------------------------
     Same jobs, repainted from the reference's own three lights: the visor
     gold, the antenna's red bead, and a mint that belongs beside them. The
     contrast contract from §1 is unchanged — marks clear 3:1, `-text` labels
     clear 4.5:1 — because that is accessibility, not art direction. */
  --fn-encrypt:      light-dark(hsl(158 72% 30%), hsl(156 66% 56%));
  --fn-encrypt-text: light-dark(hsl(160 80% 24%), hsl(156 66% 60%));
  --fn-encrypt-soft: light-dark(hsl(158 72% 36% / 0.14), hsl(156 66% 54% / 0.18));
  --fn-crown:        light-dark(hsl(40 94% 38%), hsl(44 96% 62%));
  --fn-crown-text:   light-dark(hsl(37 92% 28%), hsl(44 96% 66%));
  --fn-crown-soft:   light-dark(hsl(42 95% 50% / 0.22), hsl(44 96% 58% / 0.20));
  --fn-danger:       light-dark(hsl(4 74% 45%), hsl(6 88% 68%));
  --fn-danger-text:  light-dark(hsl(4 76% 42%), hsl(6 92% 76%));
  --fn-danger-fill:  light-dark(hsl(4 72% 47%), hsl(5 74% 60%));
  --fn-danger-ink:   light-dark(hsl(0 0% 100%), hsl(6 60% 10%));
  --fn-danger-soft:  light-dark(hsl(4 74% 52% / 0.12), hsl(6 82% 62% / 0.18));
  --fn-online:       light-dark(hsl(145 72% 31%), hsl(142 62% 58%));
  --fn-online-text:  light-dark(hsl(147 78% 24%), hsl(142 60% 64%));
  --fn-info:         light-dark(hsl(205 86% 42%), hsl(203 88% 70%));
  --fn-info-text:    light-dark(hsl(206 88% 38%), hsl(203 88% 74%));
  --fn-info-fill:    light-dark(hsl(205 86% 44%), hsl(203 82% 66%));
  --fn-info-ink:     light-dark(hsl(0 0% 100%), hsl(212 70% 10%));
  --fn-info-soft:    light-dark(hsl(205 86% 50% / 0.12), hsl(203 88% 62% / 0.18));

  --fn-focus:      light-dark(hsl(214 88% 40%), hsl(206 94% 68%));
  --fn-focus-halo: light-dark(hsl(0 0% 100%), hsl(216 42% 11%));

  /* --- HUD, turned down --------------------------------------------------
     §1 spends its whole machine budget on four cues. This skin keeps two of
     them at a whisper: the lattice is halved, and the stage's corner falloff
     goes to nothing in light and stays faint in dark. A vignette is how a
     command center looks; a toy is lit evenly. */
  --fn-grid:     light-dark(hsl(214 30% 30% / 0.018), hsl(0 0% 100% / 0.014));
  --fn-vignette: light-dark(hsl(214 30% 10% / 0), hsl(218 50% 6% / 0.30));

  /* The login scrim. Lighter than §1's — the cute artwork is the point of
     that screen, and burying it under 96% page colour wastes it. Dark in both
     schemes for the reason given in §1: a light scrim over art lit against
     black is a grey smear. */
  --fn-scrim-a: hsl(216 42% 10% / 0.93);
  --fn-scrim-b: hsl(216 42% 10% / 0.78);
  --fn-scrim-c: hsl(216 42% 10% / 0.34);
  --fn-scrim-base: hsl(216 42% 10%);

  /* --- Type -------------------------------------------------------------
     A rounded stack, and a platform one. `ui-rounded` is the generic keyword
     that resolves to SF Pro Rounded on Apple platforms; the named entries
     behind it cover the rest, and the §1 stack is the final fallback so no
     script loses its face. Nothing is fetched — see §1's note on the
     air-gapped LAN; a skin does not get to add 20 KB to the wire.

     The display face changes too, which §1's does not: Chakra Petch's squared
     terminals *are* the machine register. Keeping it here would leave every
     room name and dialog title still speaking in the other skin's voice. */
  --fn-font-ui: ui-rounded, "SF Pro Rounded", "Hiragino Maru Gothic ProN",
                "Varela Round", "Quicksand", "Nunito", system-ui,
                -apple-system, "Segoe UI Variable Text", "Segoe UI", Roboto,
                "Helvetica Neue", Arial, sans-serif;
  --fn-font-display: var(--fn-font-ui);
  /* Rounded faces are wider; the display tracking that keeps Chakra Petch
     from looking loose makes this one look cramped. */
  --fn-tr-display: -0.012em;
  --fn-tr-tight:   -0.004em;

  /* --- Radius ------------------------------------------------------------
     The loudest change in the whole block. Every step up from §1, and the
     ratio between them widened: a 28px panel around a 16px control is the
     proportion the reference's own shapes use — a big soft shell with smaller
     soft parts set into it. */
  --fn-r-xs:    10px;
  --fn-r-ctl:   16px;
  --fn-r-field: 16px;
  --fn-r-card:  20px;
  --fn-r-panel: 28px;

  /* --- Elevation ---------------------------------------------------------
     Same 0–4 geometry, softer ink and a blue cast. §1 leans on `--fn-rim`
     because a black blur is invisible on a near-black page; this skin's dark
     face is light enough that the shadows do their own work again, so the
     rim can go back to being a hairline instead of the mechanism. */
  --fn-ink-key: light-dark(hsl(214 45% 30% / 0.10), hsl(217 60% 3% / 0.50));
  --fn-ink-amb: light-dark(hsl(214 45% 30% / 0.14), hsl(217 60% 3% / 0.40));
  --fn-sh-cta:  0 1px 2px hsl(216 70% 26% / 0.26), 0 6px 14px -4px hsl(214 84% 46% / 0.38);
  --fn-sh-glow: 0 1px 2px hsl(216 70% 26% / 0.26), 0 10px 26px -6px hsl(212 88% 54% / 0.50);
  --fn-sh-inset: inset 0 1px 2px light-dark(hsl(214 30% 26% / 0.16), hsl(217 60% 3% / 0.45));

  /* --- The call to action, and the ignition ------------------------------
     The cobalt read of §1's ramp. Two things move beyond the hue.

     The ramp is *wider* — 12% of lightness rather than 9% — because a matte
     cobalt shell needs more of a gradient than a self-luminous cyan does to
     read as a lit object rather than a flat rectangle. And the sheen is a
     near-white rather than a pale tint of the hue, which is what a glossy
     moulded surface actually does with a light source: it reflects it.

     The cold open's light warms to the visor's gold at its brightest point.
     A blue flash on a blue robot is a flash you cannot see; the gold is the
     one colour in this palette that can go to white without disappearing. */
  --fn-cta-a:      hsl(211 86% 54%);
  --fn-cta-b:      hsl(217 84% 42%);
  --fn-cta-a-hi:   hsl(210 88% 59%);
  --fn-cta-b-hi:   hsl(216 84% 46%);
  --fn-cta-press:  hsl(218 82% 38%);
  --fn-cta-line:   hsl(219 80% 34%);
  --fn-cta-sheen:    hsl(205 100% 92% / 0.55);
  --fn-cta-sheen-hi: hsl(205 100% 94% / 0.62);

  --fn-spark: hsl(45 100% 82%);
  --fn-spark-halo: hsl(41 100% 62% / 0.9);
  --fn-flare: #fffaec;
  --fn-flare-mid: hsl(44 100% 66%);
  --fn-bloom: hsl(43 100% 72% / 0.85);
  --fn-beam: hsl(45 100% 74%);
  --fn-beam-deep: hsl(211 90% 52%);
  --fn-boot-title: hsl(207 92% 74%);
  --fn-sweep: hsl(205 100% 76% / 0.12);

  /* --- Motion ------------------------------------------------------------
     One change: the press is deeper and the hover lifts further. Squash and
     stretch is what makes a rounded shape feel like an object you touched;
     §1's 0.97 is the restraint an instrument panel wants. */
  --fn-tap-hover: 1.035;
  --fn-tap-press: 0.95;

  /* --- Art ---------------------------------------------------------------
     The registry from §1.1, repointed at `static/img/cute/`. Only the stems
     this skin actually redraws appear here; everything omitted keeps
     resolving to §1's entry and renders the base artwork, which is the same
     contract `asset.rs::CUTE_ART` implements for `<img>` elements. */
  --img-logo:                url("/static/img/cute/logo.png");
  --img-skynet-hero:         url("/static/img/cute/skynet-hero.png");
  --img-skynet-avatar:       url("/static/img/cute/skynet-avatar.png");
  --img-skynet-grid:         url("/static/img/cute/skynet-grid.png");
  --img-boot-sphere:         url("/static/img/cute/boot-sphere.png");
  --img-boot-endoskull:      url("/static/img/cute/boot-endoskull.png");
  --img-bank-vault-hall:     url("/static/img/cute/bank-vault-hall.png");
  --img-bank-emblem:         url("/static/img/cute/bank-emblem.png");
  --img-dashboard-emblem:    url("/static/img/cute/dashboard-emblem.png");
  --img-banker-core:         url("/static/img/cute/banker-core.png");
  --img-empty-rooms:         url("/static/img/cute/empty-rooms.png");
  --img-empty-rooms-dark:    url("/static/img/cute/empty-rooms-dark.png");
  --img-empty-messages:      url("/static/img/cute/empty-messages.png");
  --img-empty-messages-dark: url("/static/img/cute/empty-messages-dark.png");
  --img-empty-invitations:      url("/static/img/cute/empty-invitations.png");
  --img-empty-invitations-dark: url("/static/img/cute/empty-invitations-dark.png");
  --img-empty-search:        url("/static/img/cute/empty-search.png");
  --img-empty-search-dark:   url("/static/img/cute/empty-search-dark.png");
  --img-empty-files:         url("/static/img/cute/empty-files.png");
  --img-empty-files-dark:    url("/static/img/cute/empty-files-dark.png");
  --img-empty-knowledge:     url("/static/img/cute/empty-knowledge.png");
  --img-empty-knowledge-dark: url("/static/img/cute/empty-knowledge-dark.png");
  --img-empty-publish:       url("/static/img/cute/empty-publish.png");
  --img-empty-publish-dark:  url("/static/img/cute/empty-publish-dark.png");
  --img-pick-room:           url("/static/img/cute/pick-room.png");
  --img-pick-room-dark:      url("/static/img/cute/pick-room-dark.png");
  --img-encrypted-badge:     url("/static/img/cute/encrypted-badge.png");
  --img-encrypted-badge-dark: url("/static/img/cute/encrypted-badge-dark.png");
  --img-error-offline:       url("/static/img/cute/error-offline.png");
  --img-error-offline-dark:  url("/static/img/cute/error-offline-dark.png");
  --img-bank-hero:           url("/static/img/cute/bank-hero.png");
  --img-bank-hero-dark:      url("/static/img/cute/bank-hero-dark.png");
  --img-bank-banker:         url("/static/img/cute/bank-banker.png");
  --img-bank-banker-dark:    url("/static/img/cute/bank-banker-dark.png");
}


/* ==========================================================================
   1c. SKIN — humanskynet
   ==========================================================================

   The third art direction, and the one that is an argument rather than a
   palette. §1 draws the machine: torn skin, chrome underneath, a lit optic
   where an eye should be. §1b draws a toy. This one draws a person — the woman
   in `humanskynet.jpg` — and the whole point is that nothing in the picture
   tells you she is a Skynet. Same product, same guardian, same job; the only
   tell is that the room behind her is always made of screens.

   That premise decides every value below, because it decides where the machine
   is allowed to live. It is not on her, so it moves into the *environment*:
   cyan is the colour of screen-light in this skin — holograms, room insignia,
   the glow along a rim — and it is deliberately not the brand. The brand is the
   indigo of the room itself.

   # Why the accent is indigo and not the cyan that is all over the artwork

   Because §1's accent is already cyan. A skin whose primary is optic cyan on
   near-black *is* the machine skin with different pictures, however good the
   pictures are — the first thing anyone reads off an interface is the colour of
   the button they are about to press. So the cyan stays where it earns its
   place, as `--fn-info` and as the glow, and the indigo the artwork sits in
   becomes the thing you press. It also settles the three-way: §1 is cyan on
   black, §1b is cobalt and gold on white, this is indigo and cyan on midnight.

   # What else moves, and why each one earns its place

   * Type. Chakra Petch is the machine register and steps aside here for the
     same reason it does in §1b — but not toward §1b's rounded stack, which
     would be borrowing the toy's voice. A humanist sans is the least mechanical
     face a platform ships, which in a skin whose argument is "you cannot tell"
     is the only correct answer. Still a platform stack, still nothing fetched.
   * Radius. Between the two: §1's 7→20 says instrument, §1b's 10→28 says toy,
     and 8→22 says neither. The reference's own interface panels are softened
     rectangles, not pills.
   * Light. §1b turned the HUD budget down because a toy is lit evenly. This
     skin turns the *vignette* back up and leaves the lattice down — the artwork
     is cinematic, and a film frame is darker at the corners.
   * Surfaces. The dark face is a deep indigo (10% lightness, not §1's 4%),
     pinned to the flat plate the generated PNGs are drawn on so an illustration
     meets the page rather than sitting in a box on it.

   Contrast is unchanged as a contract, not as a style: every `-fill` carries
   its `-ink` at 4.5:1, every `-text` clears 4.5:1 on its surface, every mark
   clears 3:1. Those were checked numerically, not by eye.

   Specificity note: this block sits above the `data-font` rules for the reason
   §1b does — both are (0,2,0), source order decides, and an explicit font
   choice in Settings must beat the skin's default face. */
:root[data-skin="humanskynet"] {
  /* --- Brand -------------------------------------------------------------
     Periwinkle indigo, flat across both schemes like §1b's cobalt. At 50%
     lightness it carries white at 7.6:1, which is what lets `--fn-primary-ink`
     be white rather than the deep ink §1's luminous cyan needs. Hover goes
     lighter, not darker: this is a lit surface, not a pigment. */
  --fn-primary:        hsl(234 62% 50%);
  --fn-primary-hover:  hsl(232 66% 56%);
  --fn-primary-active: hsl(236 62% 43%);
  --fn-primary-ink:    hsl(0 0% 100%);
  --fn-primary-text:   light-dark(hsl(238 64% 42%), hsl(230 88% 76%));
  --fn-primary-soft:   light-dark(hsl(234 62% 54% / 0.12), hsl(230 88% 70% / 0.18));
  --fn-primary-wash:   light-dark(hsl(234 62% 54% / 0.18), hsl(230 88% 70% / 0.24));
  --fn-primary-line:   light-dark(hsl(234 56% 50% / 0.36), hsl(230 84% 72% / 0.40));
  /* The one place the artwork's cyan is allowed to be the brand's: a glow is
     light, and in this skin light is what the screens make. */
  --fn-glow:           hsl(188 90% 62% / 0.34);

  /* --- Surfaces ----------------------------------------------------------
     The dark face is the colour the generated plates are drawn on — the
     illustrations were asked for a flat #10142A ground and came back within a
     few percent of it, so the page is set to meet them rather than the other
     way round. That is the difference between an illustration on a page and an
     illustration in a visible rectangle.

     Light is a cool grey-lilac: the same room with the lamps on, not a neutral
     grey. A neutral would leave the indigo looking like an accident. */
  --fn-bg:        light-dark(hsl(228 26% 94%), hsl(230 44% 10%));
  --fn-surface:   light-dark(hsl(0 0% 100%),   hsl(230 38% 14%));
  --fn-surface-2: light-dark(hsl(228 36% 98%), hsl(230 34% 18%));
  --fn-surface-3: light-dark(hsl(228 22% 90%), hsl(230 28% 24%));
  --fn-surface-4: light-dark(hsl(228 18% 84%), hsl(230 24% 31%));
  --fn-overlay:   light-dark(hsl(230 40% 18% / 0.50), hsl(232 60% 4% / 0.78));

  /* --- Ink --------------------------------------------------------------
     Never pure black, and here specifically a dark indigo: the artwork's own
     shadows are blue, and neutral-black type on top of it reads as a different
     layer pasted over the picture. */
  --fn-fg:        light-dark(hsl(230 34% 16%), hsl(228 28% 95%));
  --fn-fg-muted:  light-dark(hsl(230 14% 40%), hsl(226 16% 75%));
  --fn-fg-faint:  light-dark(hsl(230 11% 45%), hsl(226 12% 69%));

  /* --- Lines ------------------------------------------------------------
     Back toward §1's weights, and deliberately not §1b's. The cute skin makes
     the border heavy because cel art *is* outline; this artwork is tonal and
     has almost no line in it at all, so separation goes back to being carried
     by surface contrast with the border as the fallback. */
  --fn-hair:          light-dark(hsl(228 20% 89%), hsl(0 0% 100% / 0.06));
  --fn-border:        light-dark(hsl(228 18% 84%), hsl(0 0% 100% / 0.10));
  --fn-border-strong: light-dark(hsl(228 16% 68%), hsl(0 0% 100% / 0.20));
  --fn-field-line:    light-dark(hsl(228 15% 66%), hsl(0 0% 100% / 0.18));
  --fn-rim: light-dark(hsl(0 0% 100% / 0), hsl(0 0% 100% / 0.06));

  /* --- Materials --------------------------------------------------------- */
  --fn-raise-a: light-dark(hsl(0 0% 100%),   hsl(230 36% 19%));
  --fn-raise-b: light-dark(hsl(228 32% 97%), hsl(230 40% 15%));
  --fn-sunk-a:  light-dark(hsl(228 24% 93%), hsl(231 46% 11%));
  --fn-sunk-b:  light-dark(hsl(0 0% 100%),   hsl(230 38% 15%));
  --fn-hi: light-dark(inset 0 1px 0 hsl(0 0% 100%), inset 0 1px 0 hsl(0 0% 100% / 0.07));

  /* --- Semantic ---------------------------------------------------------
     Same jobs, repainted out of the artwork's own lights: the jade of a lit
     hologram, a warm amber that is the only warm thing in the set, a coral red
     that reads as alarm without going orange next to the indigo, and the screen
     cyan for information — which is what the screens in every one of these
     pictures are actually doing. */
  --fn-encrypt:      light-dark(hsl(168 70% 31%), hsl(166 64% 55%));
  --fn-encrypt-text: light-dark(hsl(170 78% 25%), hsl(166 64% 60%));
  --fn-encrypt-soft: light-dark(hsl(168 70% 36% / 0.14), hsl(166 64% 54% / 0.18));
  --fn-crown:        light-dark(hsl(36 92% 37%), hsl(40 94% 62%));
  --fn-crown-text:   light-dark(hsl(33 90% 28%), hsl(40 94% 66%));
  --fn-crown-soft:   light-dark(hsl(38 92% 48% / 0.20), hsl(40 94% 58% / 0.20));
  --fn-danger:       light-dark(hsl(352 72% 45%), hsl(354 86% 70%));
  --fn-danger-text:  light-dark(hsl(352 74% 42%), hsl(354 90% 77%));
  --fn-danger-fill:  light-dark(hsl(352 70% 46%), hsl(353 72% 60%));
  --fn-danger-ink:   light-dark(hsl(0 0% 100%), hsl(232 60% 9%));
  --fn-danger-soft:  light-dark(hsl(352 72% 52% / 0.12), hsl(354 82% 64% / 0.18));
  --fn-online:       light-dark(hsl(152 70% 30%), hsl(150 60% 57%));
  --fn-online-text:  light-dark(hsl(154 76% 24%), hsl(150 58% 63%));
  --fn-info:         light-dark(hsl(192 88% 35%), hsl(188 86% 66%));
  --fn-info-text:    light-dark(hsl(193 90% 31%), hsl(188 86% 72%));
  /* 30% rather than the 35% `--fn-info` sits at: a fill has white on it and
     has to clear 4.5:1, where a mark only has to clear 3:1. */
  --fn-info-fill:    light-dark(hsl(193 90% 30%), hsl(188 80% 62%));
  --fn-info-ink:     light-dark(hsl(0 0% 100%), hsl(232 60% 9%));
  --fn-info-soft:    light-dark(hsl(192 88% 44% / 0.12), hsl(188 86% 62% / 0.18));

  --fn-focus:      light-dark(hsl(234 80% 44%), hsl(228 92% 72%));
  --fn-focus-halo: light-dark(hsl(0 0% 100%), hsl(230 44% 10%));

  /* --- HUD ---------------------------------------------------------------
     The one place this skin spends *more* than §1b rather than less. The
     lattice stays down — a grid overlay is a machine cue and this skin keeps
     its machine off-screen — but the corner falloff comes back, because every
     picture in the set is lit like a frame of film and a page lit flat under
     them looks like the artwork is floating on something else. */
  --fn-grid:     light-dark(hsl(230 30% 30% / 0.022), hsl(0 0% 100% / 0.020));
  --fn-vignette: light-dark(hsl(230 30% 12% / 0.05), hsl(232 60% 4% / 0.52));

  /* --- The sign-in screen ------------------------------------------------
     The one screen this skin is really *for*, and the tokens below are tuned
     against it rather than derived from the others.

     The scrim clears fast. §1 runs 0.96 → 0.42 across the frame because it is
     hiding a photoreal endoskeleton behind a form; here the artwork is the
     product's face and burying it is the mistake. So the near stop keeps the
     card legible and the far stop drops to 0.14, which is barely a tint — the
     two of them stand in a lit room and the room stays lit.

     The angle goes with it. 100° is a soft diagonal wipe; 96° is closer to a
     straight vertical edge, which is what makes the split read as a deliberate
     division of the screen rather than as a gradient someone forgot to finish.

     Dark in both schemes, for §1's reason. */
  --fn-scrim-angle: 96deg;
  --fn-scrim-a: hsl(232 48% 7% / 0.95);
  --fn-scrim-b: hsl(232 46% 8% / 0.72);
  --fn-scrim-c: hsl(230 44% 9% / 0.14);
  --fn-scrim-base: hsl(232 48% 7%);
  /* The card's edge stops being a neutral hairline and becomes the one lit
     line on the screen — the interface catching the same cyan the artwork is
     lit by. It is the whole "futuristic" budget for this screen, spent on one
     edge rather than sprinkled over five ornaments. */
  --fn-login-edge: light-dark(hsl(234 60% 50% / 0.30), hsl(188 92% 62% / 0.34));
  /* §18.3's optic, relocated. The core goes to zero — a lit point on a face is
     a machine tell, and this skin's entire claim is that there isn't one — and
     what was a bloom on an eye becomes a wide cool wash high on the data wall
     the two of them are standing in front of. Same amount of light on the
     screen, moved from the character to the room, which is this skin's rule
     for where the machine is allowed to live. */
  --fn-login-optic-core: 0%;
  --fn-login-optic-pos: 84% 22%;
  --fn-login-wash-pos: 80% 30%;
  --fn-login-wash: 14%;

  /* --- Type -------------------------------------------------------------
     A humanist stack, and a platform one — nothing is fetched, for §1's reason
     about the air-gapped LAN.

     Both faces change, as in §1b, and for a sharper version of the same reason.
     Chakra Petch's squared terminals are drawn from engineering lettering; they
     are the machine speaking. In a skin whose entire claim is that you cannot
     tell the machine from a person, leaving every room name in that face would
     be the interface giving away what the pictures refuse to. */
  --fn-font-ui: "Avenir Next", Avenir, "Segoe UI Variable Text", "Segoe UI",
                Inter, system-ui, -apple-system, Roboto, "Helvetica Neue",
                Arial, sans-serif;
  --fn-font-display: var(--fn-font-ui);
  /* Humanist letterforms are already open; §1's display tracking was drawn to
     stop a condensed technical face from looking tight, and applying it here
     just loosens something that was correct. */
  --fn-tr-display: -0.008em;
  --fn-tr-tight:   -0.002em;

  /* --- Radius ------------------------------------------------------------
     Between the two, and closer to §1. A softened rectangle is what the
     reference's own floating panels are; a pill would make this the toy and a
     hard corner would make it the instrument. */
  --fn-r-xs:    8px;
  --fn-r-ctl:   12px;
  --fn-r-field: 12px;
  --fn-r-card:  16px;
  --fn-r-panel: 22px;

  /* --- Elevation ---------------------------------------------------------
     Indigo-cast ink, and a deeper key in dark than §1b needs: this dark face is
     at 10% rather than 15%, so a shadow has somewhere to go again. The CTA's
     spread glow is indigo; the *glow* shadow is cyan, which is the one shape in
     the interface that borrows the screens' light directly. */
  --fn-ink-key: light-dark(hsl(230 45% 26% / 0.12), hsl(232 70% 2% / 0.60));
  --fn-ink-amb: light-dark(hsl(230 45% 26% / 0.16), hsl(232 70% 2% / 0.48));
  --fn-sh-cta:  0 1px 2px hsl(234 60% 22% / 0.30), 0 8px 18px -6px hsl(234 62% 50% / 0.44);
  --fn-sh-glow: 0 1px 2px hsl(234 60% 22% / 0.30), 0 12px 30px -8px hsl(188 88% 56% / 0.42);
  --fn-sh-inset: inset 0 1px 2px light-dark(hsl(230 30% 24% / 0.16), hsl(232 70% 2% / 0.55));

  /* --- The call to action, and the ignition ------------------------------
     A 14% ramp, wider than either other skin's: indigo is dark enough that a
     narrow gradient reads as a flat rectangle, and the sheen has to be a cool
     near-white rather than a tint of the hue or it disappears into it.

     The cold open goes cyan-white at its brightest, which is the one place this
     skin and §1 agree — but they agree about *light*, not about accent. What
     assembles in `boot-sphere` and opens its eyes in `boot-endoskull` is made
     of screen-light, and screen-light is cyan here as it is everywhere else in
     the set. */
  --fn-cta-a:      hsl(232 70% 58%);
  --fn-cta-b:      hsl(238 62% 44%);
  --fn-cta-a-hi:   hsl(231 74% 63%);
  --fn-cta-b-hi:   hsl(237 64% 49%);
  --fn-cta-press:  hsl(240 60% 39%);
  --fn-cta-line:   hsl(241 58% 34%);
  --fn-cta-sheen:    hsl(200 100% 92% / 0.50);
  --fn-cta-sheen-hi: hsl(200 100% 94% / 0.58);

  --fn-spark: hsl(186 100% 88%);
  --fn-spark-halo: hsl(188 100% 68% / 0.9);
  --fn-flare: #f2fdff;
  --fn-flare-mid: hsl(187 100% 72%);
  --fn-bloom: hsl(189 100% 74% / 0.85);
  --fn-beam: hsl(187 100% 78%);
  --fn-beam-deep: hsl(234 76% 52%);
  --fn-boot-title: hsl(206 90% 80%);
  --fn-sweep: hsl(196 100% 78% / 0.12);

  /* --- Motion ------------------------------------------------------------
     Between the two again. §1b's squash-and-stretch is how a moulded plastic
     shell behaves; §1's 0.97 is an instrument panel refusing to move. A filmic
     register wants a press you can feel and a hover that does not bounce. */
  --fn-tap-hover: 1.02;
  --fn-tap-press: 0.965;

  /* --- Art ---------------------------------------------------------------
     The §1.1 registry repointed at `static/img/human/`. Same contract as §1b:
     only the stems this skin actually redraws appear here, anything omitted
     keeps resolving to §1's entry, and `asset.rs::HUMAN_ART` is the same list
     for the `<img>` half. */
  --img-logo:                url("/static/img/human/logo.png");
  --img-skynet-hero:         url("/static/img/human/skynet-hero.png");
  --img-skynet-avatar:       url("/static/img/human/skynet-avatar.png");
  --img-skynet-grid:         url("/static/img/human/skynet-grid.png");
  --img-boot-sphere:         url("/static/img/human/boot-sphere.png");
  --img-boot-endoskull:      url("/static/img/human/boot-endoskull.png");
  --img-bank-vault-hall:     url("/static/img/human/bank-vault-hall.png");
  --img-bank-emblem:         url("/static/img/human/bank-emblem.png");
  --img-dashboard-emblem:    url("/static/img/human/dashboard-emblem.png");
  --img-banker-core:         url("/static/img/human/banker-core.png");
  --img-empty-rooms:         url("/static/img/human/empty-rooms.png");
  --img-empty-rooms-dark:    url("/static/img/human/empty-rooms-dark.png");
  --img-empty-messages:      url("/static/img/human/empty-messages.png");
  --img-empty-messages-dark: url("/static/img/human/empty-messages-dark.png");
  --img-empty-invitations:      url("/static/img/human/empty-invitations.png");
  --img-empty-invitations-dark: url("/static/img/human/empty-invitations-dark.png");
  --img-empty-search:        url("/static/img/human/empty-search.png");
  --img-empty-search-dark:   url("/static/img/human/empty-search-dark.png");
  --img-empty-files:         url("/static/img/human/empty-files.png");
  --img-empty-files-dark:    url("/static/img/human/empty-files-dark.png");
  --img-empty-knowledge:     url("/static/img/human/empty-knowledge.png");
  --img-empty-knowledge-dark: url("/static/img/human/empty-knowledge-dark.png");
  --img-empty-publish:       url("/static/img/human/empty-publish.png");
  --img-empty-publish-dark:  url("/static/img/human/empty-publish-dark.png");
  --img-pick-room:           url("/static/img/human/pick-room.png");
  --img-pick-room-dark:      url("/static/img/human/pick-room-dark.png");
  --img-encrypted-badge:     url("/static/img/human/encrypted-badge.png");
  --img-encrypted-badge-dark: url("/static/img/human/encrypted-badge-dark.png");
  --img-error-offline:       url("/static/img/human/error-offline.png");
  --img-error-offline-dark:  url("/static/img/human/error-offline-dark.png");
  --img-bank-hero:           url("/static/img/human/bank-hero.png");
  --img-bank-hero-dark:      url("/static/img/human/bank-hero-dark.png");
  --img-bank-banker:         url("/static/img/human/bank-banker.png");
  --img-bank-banker-dark:    url("/static/img/human/bank-banker-dark.png");
}

/* --- Type preferences (Settings → Font / Text size) ------------------------
   `ps-font` swaps the UI stack; `ps-font-scale` scales the root em, which
   every type token above is measured in. Standard/System carry no attribute
   at all, so a fresh install is byte-identical to the old behaviour. */
:root[data-font="skynet"] {
  --fn-font-ui: "Chakra Petch", system-ui, -apple-system, BlinkMacSystemFont,
                "Segoe UI Variable Text", "Segoe UI", Roboto,
                "Helvetica Neue", Arial, sans-serif;
}
:root[data-font="mono"] { --fn-font-ui: var(--fn-font-mono); }
:root[data-font="serif"] {
  --fn-font-ui: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia,
                "Times New Roman", serif;
}
:root[data-fontsize="compact"] { font-size: 87.5%; }
:root[data-fontsize="large"]   { font-size: 112.5%; }
:root[data-fontsize="xlarge"]  { font-size: 125%; }
/* The picker buttons preview the face they select. */
[data-font-sample="skynet"] { font-family: "Chakra Petch", sans-serif; }
[data-font-sample="mono"]   { font-family: var(--fn-font-mono); }
[data-font-sample="serif"]  { font-family: Georgia, "Times New Roman", serif; }

/* The skin buttons do the same job, and they are the one place in this sheet
   allowed to name colours and radii outright. Every `--fn-*` token resolves to
   the skin *in effect*, so a swatch built from tokens would paint both buttons
   identically — the preview has to describe a skin that is not currently on.

   Two literals each, because two is what identifies a palette: the accent
   you press and the surface it sits on. The corner radius is part of the
   preview too — it is the loudest difference between the two skins, and a
   square chip beside a pill says more than either colour does. */
[data-skin-sample] {
  display: inline-flex;
  align-items: center;
  gap: var(--fn-s1);
}
[data-skin-sample]::after {
  content: "";
  inline-size: 15px;
  block-size: 11px;
  flex: 0 0 auto;
  box-shadow: inset 0 0 0 1px hsl(0 0% 50% / 0.35);
}
[data-skin-sample="skynet"]::after {
  border-radius: 3px;
  background: linear-gradient(135deg, hsl(190 95% 50%) 50%, hsl(214 32% 8%) 50%);
}
[data-skin-sample="cuteskynet"]::after {
  border-radius: 999px;
  background: linear-gradient(135deg, hsl(214 84% 46%) 50%, hsl(44 96% 58%) 50%);
}
/* Indigo over midnight, at the middle radius — which is the swatch doing its
   job twice: the third skin is genuinely between the other two on the one axis
   the chip can show, and three chips in a row say so faster than three names
   do. The second stop is the *surface* rather than a second accent, because
   this skin has only one accent; the cyan is light, and a chip cannot glow. */
[data-skin-sample="humanskynet"]::after {
  border-radius: 6px;
  background: linear-gradient(135deg, hsl(234 62% 56%) 50%, hsl(230 44% 14%) 50%);
}
/* The narrow-screen rule above hides `.fn-seg__btn > span`, leaving the glyph
   to carry the meaning. For the skin buttons the swatch *is* the meaning, so
   it survives where the label does not — and the palette glyph goes instead,
   since two marks in a 26px button at that width is one too many. */
@media (max-width: 380px) {
  .fn-seg__btn[data-skin-sample] > svg { display: none; }
}
/* Non-colour scheme values (poster strength) branch the same three ways. */
@media (prefers-color-scheme: light) {
  :root {
    --fn-poster-rest: 0.08;
    --fn-poster-hover: 0.14;
    --fn-poster-lit: 0.2;
  }
}
:root[data-theme="light"] {
  --fn-poster-rest: 0.08;
  --fn-poster-hover: 0.14;
  --fn-poster-lit: 0.2;
}
:root[data-theme="dark"] {
  --fn-poster-rest: 0.14;
  --fn-poster-hover: 0.26;
  --fn-poster-lit: 0.38;
}

/* §1b, continued — the tokens that are plain numbers.
   These have to live down here rather than in the skin block above, because
   the three rules they override are themselves (0,2,0) and come later in the
   sheet. Adding `[data-theme]` (or the `:not()` the OS-follow branch uses)
   takes each to (0,3,0), which wins on specificity rather than on order, so
   this stays correct even if the blocks are ever reordered.

   Both values move the same way and for the same reason: §1's artwork is a
   photoreal watermark that has to stay *under* the interface, while the cute
   set is flat, bright, and drawn to be looked at. So the rack's posters come
   up a step, and the HUD lattice — a machine cue this skin has no use for —
   drops to almost nothing.

   Only a step, though. The first pass took the lit row to 0.52/0.68 on the
   theory that brighter art deserves more presence, and it was wrong in a way
   that only shows on a real row: flat cel art has *hard edges and solid
   fills*, where the photoreal set is soft and tonal, so the same opacity
   costs far more legibility. At 0.52 the sigil's outline cut straight through
   "Encrypted message". These are the values where the poster still reads as
   artwork and the row still reads as text. */
@media (prefers-color-scheme: light) {
  :root[data-skin="cuteskynet"]:not([data-theme="dark"]) {
    --fn-poster-rest: 0.14;
    --fn-poster-hover: 0.24;
    --fn-poster-lit: 0.32;
  }
}
@media (prefers-color-scheme: dark) {
  :root[data-skin="cuteskynet"]:not([data-theme="light"]) {
    --fn-poster-rest: 0.20;
    --fn-poster-hover: 0.34;
    --fn-poster-lit: 0.46;
    --fn-mark-grid:   0.10;
    --fn-mark-avatar: 0.22;
  }
}
:root[data-skin="cuteskynet"][data-theme="light"] {
  --fn-poster-rest: 0.14;
  --fn-poster-hover: 0.24;
  --fn-poster-lit: 0.32;
  --fn-mark-grid:   0.03;
  --fn-mark-avatar: 0.14;
}
:root[data-skin="cuteskynet"][data-theme="dark"] {
  --fn-poster-rest: 0.20;
  --fn-poster-hover: 0.34;
  --fn-poster-lit: 0.46;
  --fn-mark-grid:   0.10;
  --fn-mark-avatar: 0.22;
}
/* The unforced case in an OS-light session: the skin block alone, no
   `[data-theme]`, so this needs only to beat `:root` (0,1,0). */
:root[data-skin="cuteskynet"] {
  --fn-mark-grid:   0.03;
  --fn-mark-avatar: 0.14;
}

/* §1c, continued — the same three plain-number tokens, branched the same four
   ways and for the same structural reason (see the note above §1b's copy).

   The values land between §1's and §1b's, which is where this skin lands
   generally, but not by splitting the difference. The room sigils here are
   *glowing line art on a dark plate* — high local contrast in a thin stroke,
   which is the worst case for a poster sitting under a room name: it does not
   wash the row the way a tonal photograph does, it cuts across it. So they go
   up from §1 but stop short of §1b's flat fills.

   `--fn-mark-avatar` is the assistant's watermark, and in this skin that is a
   face. A face at any real opacity reads as someone looking out from behind the
   text rather than as texture, so it stays at §1b's light-face value in both
   schemes rather than following the poster steps up. */
@media (prefers-color-scheme: light) {
  :root[data-skin="humanskynet"]:not([data-theme="dark"]) {
    --fn-poster-rest: 0.12;
    --fn-poster-hover: 0.22;
    --fn-poster-lit: 0.30;
  }
}
@media (prefers-color-scheme: dark) {
  :root[data-skin="humanskynet"]:not([data-theme="light"]) {
    --fn-poster-rest: 0.18;
    --fn-poster-hover: 0.30;
    --fn-poster-lit: 0.42;
    --fn-mark-grid:   0.09;
    --fn-mark-avatar: 0.16;
  }
}
:root[data-skin="humanskynet"][data-theme="light"] {
  --fn-poster-rest: 0.12;
  --fn-poster-hover: 0.22;
  --fn-poster-lit: 0.30;
  --fn-mark-grid:   0.03;
  --fn-mark-avatar: 0.12;
}
:root[data-skin="humanskynet"][data-theme="dark"] {
  --fn-poster-rest: 0.18;
  --fn-poster-hover: 0.30;
  --fn-poster-lit: 0.42;
  --fn-mark-grid:   0.09;
  --fn-mark-avatar: 0.16;
}
:root[data-skin="humanskynet"] {
  --fn-mark-grid:   0.03;
  --fn-mark-avatar: 0.12;
}

/* Fallback for engines without light-dark(): only the tokens that would
   otherwise resolve to an invalid value need restating. */
@supports not (color: light-dark(#000, #fff)) {
  :root {
    --fn-primary-text: hsl(193 95% 27%);
    --fn-primary-soft: hsl(190 95% 50% / 0.10);
    --fn-primary-wash: hsl(190 95% 50% / 0.16);
    --fn-primary-line: hsl(191 80% 40% / 0.34);
    --fn-bg: hsl(210 18% 96%);
    --fn-surface: hsl(0 0% 100%);
    --fn-surface-2: hsl(210 20% 98%);
    --fn-surface-3: hsl(210 16% 93%);
    --fn-surface-4: hsl(210 12% 87%);
    --fn-overlay: hsl(214 30% 10% / 0.52);
    --fn-fg: hsl(214 20% 8%);
    --fn-fg-muted: hsl(214 10% 38%);
    --fn-fg-faint: hsl(214 8% 38%);
    --fn-hair: hsl(210 16% 91%);
    --fn-border: hsl(210 16% 88%);
    --fn-border-strong: hsl(210 12% 76%);
    --fn-field-line: hsl(210 12% 72%);
    --fn-rim: hsl(0 0% 100% / 0);
    --fn-raise-a: hsl(0 0% 100%);
    --fn-raise-b: hsl(210 20% 97%);
    --fn-sunk-a: hsl(210 20% 96%);
    --fn-sunk-b: hsl(0 0% 100%);
    --fn-hi: inset 0 1px 0 hsl(0 0% 100% / 0.9);
    --fn-encrypt: hsl(160 84% 30%);
    --fn-encrypt-text: hsl(163 90% 22%);
    --fn-encrypt-soft: hsl(160 84% 34% / 0.12);
    --fn-crown: hsl(41 96% 38%);
    --fn-crown-text: hsl(38 94% 26%);
    --fn-crown-soft: hsl(43 93% 45% / 0.18);
    --fn-danger: hsl(0 74% 42%);
    --fn-danger-text: hsl(0 76% 40%);
    --fn-danger-fill: hsl(0 72% 42%);
    --fn-danger-ink: hsl(0 0% 100%);
    --fn-danger-soft: hsl(0 72% 48% / 0.10);
    --fn-online: hsl(145 80% 30%);
    --fn-online-text: hsl(145 82% 23%);
    --fn-info: hsl(212 88% 42%);
    --fn-info-text: hsl(212 90% 38%);
    --fn-info-fill: hsl(212 88% 40%);
    --fn-info-ink: hsl(0 0% 100%);
    --fn-info-soft: hsl(212 88% 48% / 0.10);
    --fn-focus: hsl(192 95% 36%);
    --fn-focus-halo: hsl(0 0% 100%);
    --fn-ink-key: hsl(224 32% 12% / 0.07);
    --fn-ink-amb: hsl(224 32% 12% / 0.10);
    --fn-sh-inset: inset 0 1px 2px hsl(214 20% 20% / 0.18);
    --fn-grid: hsl(214 20% 20% / 0.026);
    --fn-login-edge: hsl(240 10% 50% / 0.14);
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      --fn-primary-text: hsl(187 95% 60%);
      --fn-primary-soft: hsl(188 95% 55% / 0.16);
      --fn-primary-wash: hsl(188 95% 55% / 0.22);
      --fn-primary-line: hsl(188 90% 58% / 0.38);
      --fn-bg: hsl(214 32% 4%);
      --fn-surface: hsl(214 26% 8%);
      --fn-surface-2: hsl(214 24% 11%);
      --fn-surface-3: hsl(214 20% 16%);
      --fn-surface-4: hsl(214 18% 22%);
      --fn-overlay: hsl(0 0% 0% / 0.80);
      --fn-fg: hsl(210 24% 97%);
      --fn-fg-muted: hsl(212 12% 72%);
      --fn-fg-faint: hsl(212 10% 66%);
      --fn-hair: hsl(0 0% 100% / 0.05);
      --fn-border: hsl(0 0% 100% / 0.08);
      --fn-border-strong: hsl(0 0% 100% / 0.18);
      --fn-field-line: hsl(0 0% 100% / 0.16);
      --fn-rim: hsl(0 0% 100% / 0.05);
      --fn-raise-a: hsl(214 24% 14%);
      --fn-raise-b: hsl(214 26% 10%);
      --fn-sunk-a: hsl(214 30% 5%);
      --fn-sunk-b: hsl(214 26% 9%);
      --fn-hi: inset 0 1px 0 hsl(0 0% 100% / 0.05);
      --fn-encrypt: hsl(160 70% 52%);
      --fn-encrypt-text: hsl(160 70% 56%);
      --fn-encrypt-soft: hsl(160 70% 50% / 0.16);
      --fn-crown: hsl(45 93% 60%);
      --fn-crown-text: hsl(45 93% 64%);
      --fn-crown-soft: hsl(45 93% 55% / 0.18);
      --fn-danger: hsl(0 78% 65%);
      --fn-danger-text: hsl(0 84% 74%);
      --fn-danger-fill: hsl(0 70% 58%);
      --fn-danger-ink: hsl(0 60% 8%);
      --fn-danger-soft: hsl(0 78% 60% / 0.16);
      --fn-online: hsl(142 65% 55%);
      --fn-online-text: hsl(142 62% 62%);
      --fn-info: hsl(212 85% 68%);
      --fn-info-text: hsl(212 85% 72%);
      --fn-info-fill: hsl(212 82% 64%);
      --fn-info-ink: hsl(214 72% 9%);
      --fn-info-soft: hsl(212 85% 60% / 0.16);
      --fn-focus: hsl(188 95% 60%);
      --fn-focus-halo: hsl(214 32% 4%);
      --fn-ink-key: hsl(0 0% 0% / 0.55);
      --fn-ink-amb: hsl(0 0% 0% / 0.44);
      --fn-sh-inset: inset 0 1px 2px hsl(0 0% 0% / 0.5);
      --fn-grid: hsl(0 0% 100% / 0.022);
      --fn-login-edge: hsl(0 0% 100% / 0.09);
    }
  }

  /* The same restatement for §1b. Without it a legacy engine gets the cute
     skin's radii, face and artwork over §1's near-black cyan surfaces — every
     `light-dark()` declaration in the skin block drops, and what is left is a
     mix that belongs to neither skin. The tokens that are already plain
     values up there (the primary ramp, `--fn-sh-cta`, the radii) need no
     entry here; they survived. */
  :root[data-skin="cuteskynet"] {
    --fn-primary-text: hsl(216 84% 40%);
    --fn-primary-soft: hsl(214 84% 50% / 0.12);
    --fn-primary-wash: hsl(214 84% 50% / 0.18);
    --fn-primary-line: hsl(214 70% 46% / 0.36);
    --fn-bg: hsl(200 26% 95%);
    --fn-surface: hsl(0 0% 100%);
    --fn-surface-2: hsl(200 40% 98%);
    --fn-surface-3: hsl(200 24% 91%);
    --fn-surface-4: hsl(202 20% 85%);
    --fn-overlay: hsl(215 40% 20% / 0.46);
    --fn-fg: hsl(215 40% 18%);
    --fn-fg-muted: hsl(215 16% 40%);
    --fn-fg-faint: hsl(215 13% 44%);
    --fn-hair: hsl(200 22% 88%);
    --fn-border: hsl(202 22% 82%);
    --fn-border-strong: hsl(206 22% 66%);
    --fn-field-line: hsl(206 20% 64%);
    --fn-rim: hsl(0 0% 100% / 0);
    --fn-raise-a: hsl(0 0% 100%);
    --fn-raise-b: hsl(200 34% 97%);
    --fn-sunk-a: hsl(200 26% 94%);
    --fn-sunk-b: hsl(0 0% 100%);
    --fn-hi: inset 0 1px 0 hsl(0 0% 100%);
    --fn-encrypt: hsl(158 72% 30%);
    --fn-encrypt-text: hsl(160 80% 24%);
    --fn-encrypt-soft: hsl(158 72% 36% / 0.14);
    --fn-crown: hsl(40 94% 38%);
    --fn-crown-text: hsl(37 92% 28%);
    --fn-crown-soft: hsl(42 95% 50% / 0.22);
    --fn-danger: hsl(4 74% 45%);
    --fn-danger-text: hsl(4 76% 42%);
    --fn-danger-fill: hsl(4 72% 47%);
    --fn-danger-ink: hsl(0 0% 100%);
    --fn-danger-soft: hsl(4 74% 52% / 0.12);
    --fn-online: hsl(145 72% 31%);
    --fn-online-text: hsl(147 78% 24%);
    --fn-info: hsl(205 86% 42%);
    --fn-info-text: hsl(206 88% 38%);
    --fn-info-fill: hsl(205 86% 44%);
    --fn-info-ink: hsl(0 0% 100%);
    --fn-info-soft: hsl(205 86% 50% / 0.12);
    --fn-focus: hsl(214 88% 40%);
    --fn-focus-halo: hsl(0 0% 100%);
    --fn-ink-key: hsl(214 45% 30% / 0.10);
    --fn-ink-amb: hsl(214 45% 30% / 0.14);
    --fn-sh-inset: inset 0 1px 2px hsl(214 30% 26% / 0.16);
    --fn-grid: hsl(214 30% 30% / 0.018);
    --fn-vignette: hsl(214 30% 10% / 0);
  }
  @media (prefers-color-scheme: dark) {
    :root[data-skin="cuteskynet"]:not([data-theme="light"]) {
      --fn-primary-text: hsl(209 92% 72%);
      --fn-primary-soft: hsl(210 90% 62% / 0.18);
      --fn-primary-wash: hsl(210 90% 62% / 0.24);
      --fn-primary-line: hsl(210 85% 66% / 0.40);
      --fn-bg: hsl(216 42% 11%);
      --fn-surface: hsl(216 36% 15%);
      --fn-surface-2: hsl(215 32% 19%);
      --fn-surface-3: hsl(215 28% 25%);
      --fn-surface-4: hsl(214 24% 32%);
      --fn-overlay: hsl(216 50% 6% / 0.72);
      --fn-fg: hsl(200 32% 96%);
      --fn-fg-muted: hsl(210 18% 76%);
      --fn-fg-faint: hsl(210 14% 70%);
      --fn-hair: hsl(0 0% 100% / 0.07);
      --fn-border: hsl(0 0% 100% / 0.12);
      --fn-border-strong: hsl(0 0% 100% / 0.24);
      --fn-field-line: hsl(0 0% 100% / 0.22);
      --fn-rim: hsl(0 0% 100% / 0.07);
      --fn-raise-a: hsl(215 34% 20%);
      --fn-raise-b: hsl(216 38% 16%);
      --fn-sunk-a: hsl(217 44% 12%);
      --fn-sunk-b: hsl(216 36% 16%);
      --fn-hi: inset 0 1px 0 hsl(0 0% 100% / 0.09);
      --fn-encrypt: hsl(156 66% 56%);
      --fn-encrypt-text: hsl(156 66% 60%);
      --fn-encrypt-soft: hsl(156 66% 54% / 0.18);
      --fn-crown: hsl(44 96% 62%);
      --fn-crown-text: hsl(44 96% 66%);
      --fn-crown-soft: hsl(44 96% 58% / 0.20);
      --fn-danger: hsl(6 88% 68%);
      --fn-danger-text: hsl(6 92% 76%);
      --fn-danger-fill: hsl(5 74% 60%);
      --fn-danger-ink: hsl(6 60% 10%);
      --fn-danger-soft: hsl(6 82% 62% / 0.18);
      --fn-online: hsl(142 62% 58%);
      --fn-online-text: hsl(142 60% 64%);
      --fn-info: hsl(203 88% 70%);
      --fn-info-text: hsl(203 88% 74%);
      --fn-info-fill: hsl(203 82% 66%);
      --fn-info-ink: hsl(212 70% 10%);
      --fn-info-soft: hsl(203 88% 62% / 0.18);
      --fn-focus: hsl(206 94% 68%);
      --fn-focus-halo: hsl(216 42% 11%);
      --fn-ink-key: hsl(217 60% 3% / 0.50);
      --fn-ink-amb: hsl(217 60% 3% / 0.40);
      --fn-sh-inset: inset 0 1px 2px hsl(217 60% 3% / 0.45);
      --fn-grid: hsl(0 0% 100% / 0.014);
      --fn-vignette: hsl(218 50% 6% / 0.30);
    }
  }

  /* And the same restatement for §1c. Same stakes as §1b's: without it a legacy
     engine gets this skin's radii, face and artwork over §1's near-black cyan
     surfaces, which is a mix belonging to no skin. Only the tokens written with
     `light-dark()` need an entry — the primary ramp, the CTA ramp, the radii
     and the ignition colours are already plain values and survived. */
  :root[data-skin="humanskynet"] {
    --fn-primary-text: hsl(238 64% 42%);
    --fn-primary-soft: hsl(234 62% 54% / 0.12);
    --fn-primary-wash: hsl(234 62% 54% / 0.18);
    --fn-primary-line: hsl(234 56% 50% / 0.36);
    --fn-bg: hsl(228 26% 94%);
    --fn-surface: hsl(0 0% 100%);
    --fn-surface-2: hsl(228 36% 98%);
    --fn-surface-3: hsl(228 22% 90%);
    --fn-surface-4: hsl(228 18% 84%);
    --fn-overlay: hsl(230 40% 18% / 0.50);
    --fn-fg: hsl(230 34% 16%);
    --fn-fg-muted: hsl(230 14% 40%);
    --fn-fg-faint: hsl(230 11% 45%);
    --fn-hair: hsl(228 20% 89%);
    --fn-border: hsl(228 18% 84%);
    --fn-border-strong: hsl(228 16% 68%);
    --fn-field-line: hsl(228 15% 66%);
    --fn-rim: hsl(0 0% 100% / 0);
    --fn-raise-a: hsl(0 0% 100%);
    --fn-raise-b: hsl(228 32% 97%);
    --fn-sunk-a: hsl(228 24% 93%);
    --fn-sunk-b: hsl(0 0% 100%);
    --fn-hi: inset 0 1px 0 hsl(0 0% 100%);
    --fn-encrypt: hsl(168 70% 31%);
    --fn-encrypt-text: hsl(170 78% 25%);
    --fn-encrypt-soft: hsl(168 70% 36% / 0.14);
    --fn-crown: hsl(36 92% 37%);
    --fn-crown-text: hsl(33 90% 28%);
    --fn-crown-soft: hsl(38 92% 48% / 0.20);
    --fn-danger: hsl(352 72% 45%);
    --fn-danger-text: hsl(352 74% 42%);
    --fn-danger-fill: hsl(352 70% 46%);
    --fn-danger-ink: hsl(0 0% 100%);
    --fn-danger-soft: hsl(352 72% 52% / 0.12);
    --fn-online: hsl(152 70% 30%);
    --fn-online-text: hsl(154 76% 24%);
    --fn-info: hsl(192 88% 35%);
    --fn-info-text: hsl(193 90% 31%);
    --fn-info-fill: hsl(193 90% 30%);
    --fn-info-ink: hsl(0 0% 100%);
    --fn-info-soft: hsl(192 88% 44% / 0.12);
    --fn-focus: hsl(234 80% 44%);
    --fn-focus-halo: hsl(0 0% 100%);
    --fn-ink-key: hsl(230 45% 26% / 0.12);
    --fn-ink-amb: hsl(230 45% 26% / 0.16);
    --fn-sh-inset: inset 0 1px 2px hsl(230 30% 24% / 0.16);
    --fn-grid: hsl(230 30% 30% / 0.022);
    --fn-login-edge: hsl(234 60% 50% / 0.30);
    --fn-vignette: hsl(230 30% 12% / 0.05);
  }
  @media (prefers-color-scheme: dark) {
    :root[data-skin="humanskynet"]:not([data-theme="light"]) {
      --fn-primary-text: hsl(230 88% 76%);
      --fn-primary-soft: hsl(230 88% 70% / 0.18);
      --fn-primary-wash: hsl(230 88% 70% / 0.24);
      --fn-primary-line: hsl(230 84% 72% / 0.40);
      --fn-bg: hsl(230 44% 10%);
      --fn-surface: hsl(230 38% 14%);
      --fn-surface-2: hsl(230 34% 18%);
      --fn-surface-3: hsl(230 28% 24%);
      --fn-surface-4: hsl(230 24% 31%);
      --fn-overlay: hsl(232 60% 4% / 0.78);
      --fn-fg: hsl(228 28% 95%);
      --fn-fg-muted: hsl(226 16% 75%);
      --fn-fg-faint: hsl(226 12% 69%);
      --fn-hair: hsl(0 0% 100% / 0.06);
      --fn-border: hsl(0 0% 100% / 0.10);
      --fn-border-strong: hsl(0 0% 100% / 0.20);
      --fn-field-line: hsl(0 0% 100% / 0.18);
      --fn-rim: hsl(0 0% 100% / 0.06);
      --fn-raise-a: hsl(230 36% 19%);
      --fn-raise-b: hsl(230 40% 15%);
      --fn-sunk-a: hsl(231 46% 11%);
      --fn-sunk-b: hsl(230 38% 15%);
      --fn-hi: inset 0 1px 0 hsl(0 0% 100% / 0.07);
      --fn-encrypt: hsl(166 64% 55%);
      --fn-encrypt-text: hsl(166 64% 60%);
      --fn-encrypt-soft: hsl(166 64% 54% / 0.18);
      --fn-crown: hsl(40 94% 62%);
      --fn-crown-text: hsl(40 94% 66%);
      --fn-crown-soft: hsl(40 94% 58% / 0.20);
      --fn-danger: hsl(354 86% 70%);
      --fn-danger-text: hsl(354 90% 77%);
      --fn-danger-fill: hsl(353 72% 60%);
      --fn-danger-ink: hsl(232 60% 9%);
      --fn-danger-soft: hsl(354 82% 64% / 0.18);
      --fn-online: hsl(150 60% 57%);
      --fn-online-text: hsl(150 58% 63%);
      --fn-info: hsl(188 86% 66%);
      --fn-info-text: hsl(188 86% 72%);
      --fn-info-fill: hsl(188 80% 62%);
      --fn-info-ink: hsl(232 60% 9%);
      --fn-info-soft: hsl(188 86% 62% / 0.18);
      --fn-focus: hsl(228 92% 72%);
      --fn-focus-halo: hsl(230 44% 10%);
      --fn-ink-key: hsl(232 70% 2% / 0.60);
      --fn-ink-amb: hsl(232 70% 2% / 0.48);
      --fn-sh-inset: inset 0 1px 2px hsl(232 70% 2% / 0.55);
      --fn-grid: hsl(0 0% 100% / 0.020);
      --fn-login-edge: hsl(188 92% 62% / 0.34);
      --fn-vignette: hsl(232 60% 4% / 0.52);
    }
  }
}


/* ==========================================================================
   2. RESET & BASE
   ========================================================================== */

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

html,
body {
  height: 100%;
  margin: 0;
}

html {
  /* Both panes scroll internally; this only affects programmatic jumps
     (skip link, anchor navigation). Disabled under reduced motion in §17. */
  scroll-behavior: smooth;
  accent-color: var(--fn-primary);
}

body {
  background: var(--fn-bg);
  color: var(--fn-fg);
  font-family: var(--fn-font-ui);
  font-size: var(--fn-t-md);
  line-height: var(--fn-lh-body);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  overscroll-behavior-y: none;
  /* Numbers in running copy line up column-wise without opting in per rule. */
  font-variant-numeric: tabular-nums;
}

h1, h2, h3, h4 {
  margin: 0;
  line-height: var(--fn-lh-tight);
  font-weight: 700;
  letter-spacing: var(--fn-tr-tight);
  text-wrap: balance;
}
h1 { font-size: var(--fn-t-xl); }
h2 { font-size: var(--fn-t-lg); }
h3 { font-size: var(--fn-t-md); }

p { margin: 0; text-wrap: pretty; }

ul, ol { text-wrap: pretty; }

a {
  color: var(--fn-primary-text);
  text-decoration-thickness: 1px;
  text-underline-offset: 2px;
  transition: color var(--fn-dur-fast) var(--fn-ease);
}
a:hover { color: var(--fn-primary); }

img { max-inline-size: 100%; }

/* One focus treatment for the entire app. Never removed, only relocated.
   The double ring (brand + halo) survives landing on white, on the orange
   CTA fill and on a dark panel, which a single ring does not. */
:focus { outline: none; }
:focus-visible {
  outline: 2px solid var(--fn-focus);
  outline-offset: 2px;
  border-radius: var(--fn-r-ctl);
  box-shadow: 0 0 0 4px var(--fn-focus-halo);
}

/* Selection is brand-coloured, and deliberately a wash rather than a fill:
   the highlighted text keeps its own ink and stays readable, which a solid
   orange behind espresso ink does not. */
::selection {
  background: var(--fn-primary-wash);
  color: var(--fn-fg);
  text-shadow: none;
}

/* Scrollbars: thin, non-reserving, themed, and identical in both schemes
   because both read them from tokens. */
.fn-scroll {
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--fn-border-strong) transparent;
  overscroll-behavior: contain;
  scroll-behavior: smooth;
}
.fn-scroll::-webkit-scrollbar { width: 10px; height: 10px; }
.fn-scroll::-webkit-scrollbar-track { background: transparent; }
.fn-scroll::-webkit-scrollbar-thumb {
  background: var(--fn-border-strong);
  border: 3px solid transparent;
  background-clip: padding-box;
  border-radius: var(--fn-r-pill);
  transition: background-color var(--fn-dur-fast) var(--fn-ease);
}
.fn-scroll::-webkit-scrollbar-thumb:hover { background-color: var(--fn-fg-faint); }
.fn-scroll::-webkit-scrollbar-corner { background: transparent; }


/* ==========================================================================
   3. TOPCOAT OVERRIDES
   Topcoat ships a neutral grey control set built for one light theme. These
   rules keep its geometry and interaction model, and replace colour, radius
   and focus.

   §3.0 is not cosmetic: the vendor sheet hard-codes light-mode greys into
   `:hover`, `:active` and — critically — `:focus`, at a specificity
   (0,2,0) that beats a bare `:focus-visible` (0,1,0). Left alone it makes
   every focused text field turn #edf1f1/#000 in dark mode, paints a blue
   #0940fd ring on every button in both modes, and `outline: 0`s the app's
   own focus ring out of existence. Each of those is neutralised below.
   ========================================================================== */

/* --- §3.0 vendor-leak neutralisers ---------------------------------------- */

/* Topcoat's `text-shadow: 0 1px #fff` under every button label is a white
   glow on a dark surface. */
.topcoat-button,
.topcoat-button--large,
.topcoat-button--quiet,
.topcoat-button--large--quiet,
.topcoat-button--cta,
.topcoat-button--large--cta,
.topcoat-button-bar__button,
.topcoat-icon-button,
.topcoat-icon-button--large,
.topcoat-icon-button--quiet,
.topcoat-icon-button--large--quiet,
.topcoat-button:hover,
.topcoat-button--quiet:hover,
.topcoat-button--large--quiet:hover,
.topcoat-button--quiet:active,
.topcoat-button--large--quiet:active,
.topcoat-button--cta:hover,
.topcoat-button--large--cta:hover { text-shadow: none; }

/* The vendor focus ring, deleted. §2's `:focus-visible` takes over — and
   these selectors match its specificity so it can. */
.topcoat-button:focus,
.topcoat-button--large:focus,
.topcoat-button--quiet:focus,
.topcoat-button--large--quiet:focus,
.topcoat-button--cta:focus,
.topcoat-button--large--cta:focus,
.topcoat-button-bar__button:focus,
.topcoat-icon-button:focus,
.topcoat-icon-button--large:focus,
.topcoat-icon-button--quiet:focus,
.topcoat-icon-button--quiet:hover:focus,
.topcoat-icon-button--large--quiet:focus,
.topcoat-tab-bar__button:focus {
  border-color: inherit;
  box-shadow: none;
  outline: none;
}
.topcoat-button:focus-visible,
.topcoat-button--large:focus-visible,
.topcoat-button--quiet:focus-visible,
.topcoat-button--large--quiet:focus-visible,
.topcoat-button--cta:focus-visible,
.topcoat-button--large--cta:focus-visible,
.topcoat-button-bar__button:focus-visible,
.topcoat-icon-button:focus-visible,
.topcoat-icon-button--large:focus-visible,
.topcoat-icon-button--quiet:focus-visible,
.topcoat-icon-button--large--quiet:focus-visible {
  outline: 2px solid var(--fn-focus);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--fn-focus-halo);
}

/* Focused fields: the vendor forces #edf1f1 on #000. In dark mode that is a
   white box with black text in the middle of a dark composer. */
.topcoat-text-input:focus,
.topcoat-text-input--large:focus,
.topcoat-textarea:focus,
.topcoat-textarea--large:focus,
.topcoat-search-input:focus,
.topcoat-search-input--large:focus {
  background-color: var(--fn-surface);
  background-image: none;
  color: var(--fn-fg);
}

/* Vendor hover/active greys on the solid icon button. */
.topcoat-icon-button:hover,
.topcoat-icon-button--large:hover { background-color: var(--fn-surface-3); }
.topcoat-icon-button:active,
.topcoat-icon-button--large:active {
  background-color: var(--fn-surface-4);
  box-shadow: var(--fn-sh-inset);
}

/* --- §3.1 buttons --------------------------------------------------------- */

.topcoat-button,
.topcoat-button--large,
.topcoat-button--quiet,
.topcoat-button--large--quiet,
.topcoat-button--cta,
.topcoat-button--large--cta,
.topcoat-button-bar__button,
.topcoat-icon-button,
.topcoat-icon-button--large,
.topcoat-icon-button--quiet,
.topcoat-icon-button--large--quiet {
  font-family: var(--fn-font-ui);
  border-radius: var(--fn-r-ctl);
  /* Geometry, which the vendor sets to 12px/21px type in 9px of padding —
     the single loudest "this is an unstyled widget" signal in the sheet.
     One family: 36px standing height, 14px of side padding, 13px semibold
     type, centred, with a gap so an icon and a label sit together without
     the label carrying a leading space to separate them. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  min-block-size: 36px;
  padding-block: 0;
  padding-inline: 14px;
  font-size: var(--fn-t-sm);
  font-weight: 600;
  line-height: var(--fn-lh-ui);
  letter-spacing: var(--fn-tr-label);
  /* tapScale: hover 1.015 / active 0.97 (motion.ts). The transform is on a
     snappy spring; colour follows the shorter interaction curve. */
  transition: background-color var(--fn-dur-fast) var(--fn-ease),
              background-image var(--fn-dur-fast) var(--fn-ease),
              border-color var(--fn-dur-fast) var(--fn-ease),
              color var(--fn-dur-fast) var(--fn-ease),
              box-shadow var(--fn-dur) var(--fn-ease),
              transform var(--fn-dur-snappy) var(--fn-spring-snappy);
}
/* `--large` is the CTA size: 44px, the same target a coarse pointer gets. */
.topcoat-button--large,
.topcoat-button--large--quiet,
.topcoat-button--large--cta,
.topcoat-icon-button--large,
.topcoat-icon-button--large--quiet {
  min-block-size: 44px;
  padding-inline: 18px;
  font-size: var(--fn-t-md);
}
/* Icon buttons are square. Without this they inherit the label padding and
   sit as 60px-wide lozenges around a 16px glyph. */
.topcoat-icon-button,
.topcoat-icon-button--quiet {
  padding-inline: 0;
  min-inline-size: 36px;
}
.topcoat-icon-button--large,
.topcoat-icon-button--large--quiet {
  padding-inline: 0;
  min-inline-size: 44px;
}
/* An SVG in a button is a glyph, not content: it must not stretch, and it
   must not be the thing that sets the baseline. */
.topcoat-button > svg,
.topcoat-button--large > svg,
.topcoat-button--quiet > svg,
.topcoat-button--large--quiet > svg,
.topcoat-button--cta > svg,
.topcoat-button--large--cta > svg,
.topcoat-icon-button > svg,
.topcoat-icon-button--quiet > svg,
.topcoat-icon-button--large > svg,
.topcoat-icon-button--large--quiet > svg { flex: 0 0 auto; display: block; }
@media (hover: hover) {
  .topcoat-button:hover:not([disabled]),
  .topcoat-button--large:hover:not([disabled]),
  .topcoat-button--cta:hover:not([disabled]),
  .topcoat-button--large--cta:hover:not([disabled]),
  .topcoat-icon-button:hover:not([disabled]),
  .topcoat-icon-button--large:hover:not([disabled]) {
    transform: scale(var(--fn-tap-hover));
  }
}
.topcoat-button:active:not([disabled]),
.topcoat-button--large:active:not([disabled]),
.topcoat-button--quiet:active:not([disabled]),
.topcoat-button--large--quiet:active:not([disabled]),
.topcoat-button--cta:active:not([disabled]),
.topcoat-button--large--cta:active:not([disabled]),
.topcoat-icon-button:active:not([disabled]),
.topcoat-icon-button--quiet:active:not([disabled]),
.topcoat-icon-button--large--quiet:active:not([disabled]) {
  transform: scale(var(--fn-tap-press));
  transition-duration: var(--fn-dur-fast);
}

/* Default (secondary) button. A raised surface: gradient fill, contact +
   ambient shadow, and a 1px highlight on the top edge. */
.topcoat-button,
.topcoat-button--large,
.topcoat-button-bar__button {
  background-color: var(--fn-raise-a);
  background-image: linear-gradient(180deg, var(--fn-raise-a), var(--fn-raise-b));
  border: 1px solid var(--fn-border-strong);
  color: var(--fn-fg);
  box-shadow: var(--fn-hi), var(--fn-elev-1);
  font-weight: 600;
}
.topcoat-button:hover,
.topcoat-button--large:hover {
  background-color: var(--fn-raise-a);
  background-image: linear-gradient(180deg, var(--fn-raise-a), var(--fn-raise-a));
  border-color: var(--fn-fg-faint);
  box-shadow: var(--fn-hi), var(--fn-elev-2);
}
.topcoat-button:active,
.topcoat-button--large:active {
  background-image: linear-gradient(180deg, var(--fn-raise-b), var(--fn-raise-b));
  box-shadow: var(--fn-sh-inset);
}
/* A `role="radio"` button group (Appearance, Connection) needs a checked
   state; without one the current setting is invisible. */
.topcoat-button[aria-checked="true"],
.topcoat-button[aria-pressed="true"] {
  background-color: var(--fn-primary-soft);
  background-image: none;
  border-color: var(--fn-primary-line);
  color: var(--fn-primary-text);
  font-weight: 700;
  box-shadow: var(--fn-sh-inset);
}

/* CTA — the only saturated cyan fill in the product. Dark abyssal ink,
   not white: white on this cyan is 3.1:1, the ink is 5.3:1.

   The fill is a two-stop gradient inside a single hue, ~9% of lightness top
   to bottom. That is enough to read as a lit object and not enough to read
   as a 2008 gel button; the ink clears AA against the *darker* end, so the
   gradient can only improve contrast, never reduce it. */
.topcoat-button--cta,
.topcoat-button--large--cta {
  background-color: var(--fn-primary);
  background-image: linear-gradient(180deg, var(--fn-cta-a), var(--fn-cta-b));
  border: 1px solid var(--fn-cta-line);
  color: var(--fn-primary-ink);
  font-weight: 700;
  letter-spacing: var(--fn-tr-label);
  box-shadow: inset 0 1px 0 var(--fn-cta-sheen), var(--fn-sh-cta);
}
.topcoat-button--cta:hover,
.topcoat-button--large--cta:hover {
  background-image: linear-gradient(180deg, var(--fn-cta-a-hi), var(--fn-cta-b-hi));
  box-shadow: inset 0 1px 0 var(--fn-cta-sheen-hi), var(--fn-sh-glow);
}
.topcoat-button--cta:active,
.topcoat-button--large--cta:active {
  background-image: linear-gradient(180deg, var(--fn-cta-press), var(--fn-cta-press));
  box-shadow: var(--fn-sh-inset);
}

/* Quiet — borderless, for row-level and header actions. */
.topcoat-button--quiet,
.topcoat-button--large--quiet,
.topcoat-icon-button--quiet,
.topcoat-icon-button--large--quiet {
  background: transparent;
  background-image: none;
  border: 1px solid transparent;
  color: var(--fn-fg-muted);
  box-shadow: none;
}
.topcoat-button--quiet:hover,
.topcoat-button--large--quiet:hover,
.topcoat-icon-button--quiet:hover,
.topcoat-icon-button--large--quiet:hover {
  background: var(--fn-surface-3);
  color: var(--fn-fg);
}
.topcoat-button--quiet:active,
.topcoat-button--large--quiet:active,
.topcoat-icon-button--quiet:active,
.topcoat-icon-button--large--quiet:active {
  background: var(--fn-surface-4);
}

.topcoat-icon-button,
.topcoat-icon-button--large {
  background-color: var(--fn-raise-a);
  background-image: linear-gradient(180deg, var(--fn-raise-a), var(--fn-raise-b));
  border: 1px solid var(--fn-border-strong);
  color: var(--fn-fg-muted);
  box-shadow: var(--fn-hi), var(--fn-elev-1);
}
.topcoat-icon-button:hover { color: var(--fn-fg); }

/* Icon buttons that carry a badge (top bar invitations) need a stacking
   context for it. */
.topcoat-icon-button--quiet,
.topcoat-icon-button { position: relative; }

/* Destructive variant (not in stock Topcoat; composes with the above).

   Outlined at rest, filled under the pointer. A permanently red button is a
   red flag on a screen that is not in trouble — and next to a black settings
   list it becomes the brightest object on the page, which is the opposite of
   what a rarely-wanted, irreversible action should be. */
.topcoat-button--danger,
.topcoat-button--large--danger {
  background-color: transparent;
  background-image: none;
  border-color: color-mix(in srgb, var(--fn-danger) 45%, transparent);
  color: var(--fn-danger-text);
  font-weight: 700;
  box-shadow: none;
}
.topcoat-button--danger:hover,
.topcoat-button--large--danger:hover {
  background-color: var(--fn-danger-fill);
  border-color: var(--fn-danger-fill);
  color: var(--fn-danger-ink);
  filter: none;
}
.topcoat-button--danger:active,
.topcoat-button--large--danger:active { filter: brightness(0.94); }

/* The *committing* destructive button — solid, because in a confirm dialog
   the destructive action is the primary one and an outline reads as the
   weaker of the two choices, which inverts the hierarchy the dialog exists
   to present. Outlined `--danger` remains for destructive actions living
   inside non-confirm surfaces.

   A recolour, not a standalone class: it rides WITH `topcoat-button--cta`
   (`class="topcoat-button--cta topcoat-button--cta-danger"`), which is what
   supplies the family geometry — radius, height, padding, focus ring. */
.topcoat-button--cta-danger {
  background-color: var(--fn-danger-fill);
  background-image: none;
  border-color: transparent;
  color: var(--fn-danger-ink);
  font-weight: 700;
  box-shadow: var(--fn-elev-1);
}
.topcoat-button--cta-danger:hover { filter: brightness(1.07); }
.topcoat-button--cta-danger:active { filter: brightness(0.94); }

/* Disabled.

   Fading a saturated fill to 45% is what makes a disabled CTA read as a
   *broken* CTA: pale peach with brown text in the middle of a working page
   looks like a rendering fault, not like a decision. A disabled control is
   instead redrawn — flat neutral fill, no lift, muted-but-legible ink. It
   stops looking like the primary action, which is the point, and the label
   stays readable at ~4.9:1, which matters because the label is usually the
   only thing that says *why* it is disabled. */
.topcoat-button[disabled],
.topcoat-button--large[disabled],
.topcoat-button-bar__button[disabled],
.topcoat-button--cta[disabled],
.topcoat-button--large--cta[disabled],
.topcoat-button--danger[disabled],
.topcoat-button--large--danger[disabled],
.topcoat-icon-button[disabled],
.topcoat-icon-button--large[disabled] {
  opacity: 1;
  background-color: var(--fn-surface-3);
  background-image: none;
  border-color: var(--fn-border);
  color: var(--fn-fg-faint);
  cursor: not-allowed;
  box-shadow: none;
  filter: none;
  transform: none;
}
/* Borderless controls have no fill to redraw, so they do fade — but only to
   the point where they are still legible. */
.topcoat-button--quiet[disabled],
.topcoat-button--large--quiet[disabled],
.topcoat-icon-button--quiet[disabled],
.topcoat-icon-button--large--quiet[disabled],
[aria-disabled="true"] {
  opacity: 0.55;
  cursor: not-allowed;
  box-shadow: none;
  filter: none;
  transform: none;
}

/* --- §3.2 text inputs ----------------------------------------------------- */

/* A field is the one surface in the product that is *recessed* rather than
   raised: a gradient running the other way (deeper at the top, where the
   shadow of the edge above it would fall) and a soft inner shadow. That,
   not the border, is what says "type here" — so the border can stay a
   hairline instead of the heavy grey box the vendor draws. */
.topcoat-text-input,
.topcoat-text-input--large,
.topcoat-textarea,
.topcoat-search-input,
.topcoat-search-input--large {
  font-family: var(--fn-font-ui);
  font-size: var(--fn-t-md);
  line-height: var(--fn-lh-ui);
  min-block-size: 40px;
  padding: 8px 12px;
  background-color: var(--fn-sunk-b);
  background-image: linear-gradient(180deg, var(--fn-sunk-a), var(--fn-sunk-b) 14px);
  border: 1px solid var(--fn-field-line);
  border-radius: var(--fn-r-field);
  color: var(--fn-fg);
  box-shadow: inset 0 1px 2px var(--fn-ink-key);
  transition: border-color var(--fn-dur-fast) var(--fn-ease),
              background-color var(--fn-dur-fast) var(--fn-ease),
              box-shadow var(--fn-dur) var(--fn-spring-snappy);
}
.topcoat-textarea { padding-block: 10px; }
.topcoat-search-input,
.topcoat-search-input--large { border-radius: var(--fn-r-pill); padding-inline: 14px; }

.topcoat-text-input::placeholder,
.topcoat-textarea::placeholder,
.topcoat-search-input::placeholder { color: var(--fn-fg-faint); }

/* A focused field comes up to meet you: the recess flattens out, the fill
   goes to the plain surface and the brand ring appears. */
.topcoat-text-input:focus,
.topcoat-text-input--large:focus,
.topcoat-textarea:focus,
.topcoat-search-input:focus,
.topcoat-search-input--large:focus {
  border-color: var(--fn-primary);
  background-image: none;
  box-shadow: 0 0 0 3px var(--fn-primary-soft);
  outline: none;
}
.topcoat-text-input:hover:not(:focus, [disabled]),
.topcoat-textarea:hover:not(:focus, [disabled]),
.topcoat-search-input:hover:not(:focus, [disabled]) {
  border-color: color-mix(in srgb, var(--fn-field-line) 55%, var(--fn-fg-faint));
}
/* Keyboard focus additionally gets the app ring; a pointer click into a
   field does not need one, but a Tab into it does. */
.topcoat-text-input:focus-visible,
.topcoat-textarea:focus-visible,
.topcoat-search-input:focus-visible {
  outline: 2px solid var(--fn-focus);
  outline-offset: 1px;
  box-shadow: none;
}
.topcoat-text-input[aria-invalid="true"],
.topcoat-textarea[aria-invalid="true"] {
  border-color: var(--fn-danger);
  box-shadow: 0 0 0 3px var(--fn-danger-soft);
}
/* Vendor `:invalid` paints a magenta border on any empty `required` field. */
.topcoat-text-input:invalid { border-color: var(--fn-field-line); }
.topcoat-text-input[disabled],
.topcoat-textarea[disabled] {
  background-color: var(--fn-surface-3);
  background-image: none;
  border-color: var(--fn-border);
  color: var(--fn-fg-faint);
  box-shadow: none;
  cursor: not-allowed;
}

/* --- §3.3 navigation bar & lists ------------------------------------------ */

.topcoat-navigation-bar {
  background: var(--fn-surface);
  box-shadow: none;
  min-height: var(--fn-navbar-h);
  display: flex;
  align-items: center;
  /* Back + title + count + a call-to-action is more than a narrow viewport
     holds. Wrapping only engages when it genuinely does not fit, so wide
     layouts are untouched and the CTA drops to its own right-aligned line
     instead of hanging off the edge. */
  flex-wrap: wrap;
  gap: var(--fn-s2);
  row-gap: var(--fn-s1);
  padding: var(--fn-s1) var(--fn-s3);
  padding-inline-start: max(var(--fn-s3), var(--fn-safe-left));
  padding-inline-end: max(var(--fn-s3), var(--fn-safe-right));
}
.topcoat-navigation-bar__title {
  font-family: var(--fn-font-display);
  font-size: var(--fn-t-lg);
  font-weight: 600;
  color: var(--fn-fg);
  letter-spacing: 0.015em;
}
.topcoat-navigation-bar__item { min-width: 0; }

.topcoat-list { margin: 0; }
.topcoat-list__header {
  background: transparent;
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-xs);
  font-weight: 700;
  letter-spacing: var(--fn-tr-caps);
  text-transform: uppercase;
  padding: var(--fn-s5) var(--fn-s1) var(--fn-s2);
  box-shadow: none;
  border: 0;
}
.topcoat-list__container {
  background: transparent;
  border: 0;
  box-shadow: none;
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--fn-s2);
}
.topcoat-list__item {
  background: transparent;
  border: 0;
  box-shadow: none;
  color: inherit;
  padding: 0;
}

/* --- §3.4 switch / checkbox / radio / range ------------------------------- */

.topcoat-switch__input:checked + .topcoat-switch__toggle {
  background: var(--fn-primary);
  border-color: var(--fn-primary-active);
}
.topcoat-checkbox__checkmark,
.topcoat-radio-button__checkmark { accent-color: var(--fn-primary); }
.topcoat-radio-button__input,
.topcoat-range { accent-color: var(--fn-primary); }

/* Topcoat hides every `input[type="checkbox"]` — `opacity: 0.001`, absolutely
   positioned — on the assumption that a `.topcoat-checkbox__checkmark` span
   follows it and gets drawn in its place. The create-room dialog has no such
   span, so "Encrypt this room" was an invisible control: the row toggled, and
   nothing on screen said which way. Drawn here instead, as the same object as
   every other control in the sheet. */
input.topcoat-checkbox__input {
  position: static;
  opacity: 1;
  appearance: none;
  -webkit-appearance: none;
  inline-size: 20px;
  block-size: 20px;
  flex: 0 0 auto;
  margin: 0;
  border: 0;
  border-radius: var(--fn-r-xs);
  background: var(--fn-sunk-a);
  box-shadow: inset 0 0 0 1px var(--fn-field-line);
  cursor: pointer;
  transition: background-color var(--fn-dur-fast) var(--fn-ease),
              box-shadow var(--fn-dur-fast) var(--fn-ease);
}
input.topcoat-checkbox__input:hover { box-shadow: inset 0 0 0 1px var(--fn-fg-faint); }
input.topcoat-checkbox__input:checked {
  background-color: var(--fn-primary);
  /* The tick is a background image rather than a masked pseudo-element:
     `::before` on a replaced element is not reliably rendered, and this has
     to be visible everywhere. */
  background-image: var(--fn-icon-check);
  background-size: 12px 12px;
  background-position: center;
  background-repeat: no-repeat;
  box-shadow: none;
}

.topcoat-switch__input:focus-visible + .topcoat-switch__toggle,
.topcoat-checkbox__input:focus-visible + .topcoat-checkbox__checkmark,
.topcoat-radio-button__input:focus-visible + .topcoat-radio-button__checkmark {
  outline: 2px solid var(--fn-focus);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--fn-focus-halo);
}


/* ==========================================================================
   4. PRIMITIVES
   ========================================================================== */

/* --- Identity tile ------------------------------------------------------
   The signature element: a flat colour tile carrying two characters of the
   identifier it stands for. One djb2 hash of the lowercased, 0x-stripped
   seed yields the hue; Yew sets `style="--fn-hue: <n>"` and the rest is CSS.

   The colour is written in OKLCH on purpose. In HSL a fixed lightness is not
   a fixed *brightness* — `hsl(60 60% 46%)` (yellow) is nearly three times as
   luminous as `hsl(240 60% 46%)` (blue) — so a white monogram would clear AA
   on some hues and fail badly on others. OKLCH's L is perceptual, so one
   value serves all 360 hues at the same contrast. Identical in both themes:
   the tile is the one saturated object on the screen either way.
   ------------------------------------------------------------------------ */
.fn-ident {
  --fn-ident-size: 36px;
  inline-size: var(--fn-ident-size);
  block-size: var(--fn-ident-size);
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  position: relative;
  font-family: var(--fn-font-mono);
  font-size: calc(var(--fn-ident-size) * 0.36);
  font-weight: 700;
  letter-spacing: 0.02em;
  line-height: 1;
  border-radius: 26%;
  background: oklch(0.5 0.15 var(--fn-hue));
  color: hsl(0 0% 100%);
  user-select: none;
  transition: transform var(--fn-dur-snappy) var(--fn-spring-snappy);
}
/* --- Identity art --------------------------------------------------------
   The generated avatar (identity.rs `art_for`) sits over the coloured tile:
   an operator face for a person, a machine sigil for a room. The tile is not
   replaced but *covered*, so it remains the state before the image lands and
   the fallback if it never does — a blank square where a face belongs reads
   as breakage, where two letters read as an identity.

   The monogram survives on top of it, small and dark-scrimmed. It is the one
   part of this tile that is checkable against the `0x9f2a…` printed beside it,
   and a picture cannot do that job; losing it would turn a verification aid
   into decoration. Below 32px there is no room for both, so the letters step
   aside and the picture carries the identity alone. */
.fn-ident__art {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
  border-radius: inherit;
  /* Fades in rather than snapping, so a list of eighteen does not flicker. */
  animation: fn-fade-in var(--fn-dur) var(--fn-ease) both;
}
.fn-ident__mono {
  position: absolute;
  inset-block-end: 0;
  inset-inline: 0;
  font-size: calc(var(--fn-ident-size) * 0.3);
  line-height: 1.35;
  text-align: center;
  background: linear-gradient(transparent, rgb(0 0 0 / 0.72));
  border-end-start-radius: inherit;
  border-end-end-radius: inherit;
  text-shadow: 0 1px 2px rgb(0 0 0 / 0.9);
}
.fn-ident--xs .fn-ident__mono,
.fn-ident--sm .fn-ident__mono { display: none; }
@keyframes fn-fade-in { from { opacity: 0; } to { opacity: 1; } }

/* Engines without OKLCH fall back to HSL at a lightness low enough that even
   the brightest hue keeps the monogram above 4.5:1. */
@supports not (color: oklch(0.5 0.15 24)) {
  .fn-ident { background: hsl(var(--fn-hue) 52% 30%); }
}

.fn-ident--xs { --fn-ident-size: 24px; font-size: 9px; }
.fn-ident--sm { --fn-ident-size: 28px; }
.fn-ident--lg { --fn-ident-size: 44px; }
.fn-ident--xl { --fn-ident-size: 64px; border-radius: 22%; }

/* "It's you" — the only tile that carries the accent, and the only place
   outside a primary action where the accent appears at all. */
.fn-ident--self { box-shadow: 0 0 0 2px var(--fn-bg), 0 0 0 4px var(--fn-primary); }

/* Presence dot, bottom-right, notched out of the tile.

   Online is filled, away is a ring — a difference of *shape*, not of colour,
   which is the rule (§17) and also the only version that survives the tile it
   sits on. These dots land on generated portraits in every hue in the palette,
   so a second colour would collide with one of them somewhere; a hole in the
   middle collides with nothing. Offline draws nothing at all: three states and
   only two marks, because "not here" is the common case and a badge on every
   absent colleague is a screen of noise. */
.fn-ident--online::after,
.fn-ident--away::after {
  content: "";
  position: absolute;
  inset-inline-end: -2px;
  inset-block-end: -2px;
  inline-size: 10px;
  block-size: 10px;
  border-radius: var(--fn-r-pill);
  background: var(--fn-online);
  box-shadow: 0 0 0 2px var(--fn-bg);
}
.fn-ident--away::after {
  background: var(--fn-bg);
  border: 2px solid var(--fn-fg-muted);
}

/* The word beside the dot. Carried by the row rather than by the tile, which
   is `aria-hidden` decoration — the dot is the glance, this is the meaning. */
.fn-presence {
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-muted);
}
.fn-presence--online { color: var(--fn-online-text); }

/* --- Wallet address ------------------------------------------------------ */
.fn-addr,
.fn-mono {
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-sm);
  font-variant-ligatures: none;
  letter-spacing: 0.01em;
  color: var(--fn-fg-muted);
  white-space: nowrap;
}
.fn-addr--full { word-break: break-all; white-space: normal; }
input.fn-mono, textarea.fn-mono { color: var(--fn-fg); }
/* A copyable address. A span with `role="button"` rather than a real button,
   so it stays part of the line it sits in — see common.rs. Everything a button
   would have given it is put back by hand: the pointer, the ring, the states. */
.fn-addr--copy {
  padding: 2px 4px;
  margin: -2px -4px;
  border-radius: var(--fn-r-ctl);
  cursor: pointer;
  transition: background-color var(--fn-dur-fast) var(--fn-ease),
              color var(--fn-dur-fast) var(--fn-ease);
}
.fn-addr--copy:hover { background: var(--fn-surface-3); color: var(--fn-fg); }
.fn-addr--copy:focus-visible {
  outline: 2px solid var(--fn-focus);
  outline-offset: 1px;
}
/* The copied state is a colour, not a swapped label. Replacing the address
   with the word "Copied" moves the text out from under the pointer and takes
   away the thing the user was just looking at. */
.fn-addr--copy[data-copied="true"] {
  color: var(--fn-encrypt-text);
  background: var(--fn-encrypt-soft);
}

/* An address with a reveal beside it (`revealable`, common.rs).

   `inline-flex`, not flex: this lands mid-sentence inside a message bubble,
   and a block would break the line either side of it. `baseline` alignment
   for the same reason — a 14px glyph centred on a line of 15px text sits
   visibly high, and the eye has to look like punctuation next to the address
   rather than a control parked beside it.

   `align-items` is the one thing that cannot be inherited from the bubble: an
   inline-flex container establishes its own formatting context, so without
   this the two children align to each other and the group as a whole drifts
   off the text baseline. */
.fn-addr-group {
  display: inline-flex;
  align-items: baseline;
  gap: 2px;
  max-inline-size: 100%;
  /* The group may wrap as a unit, but never *between* the address and its
     eye — an eye alone on the next line belongs to nothing. */
  white-space: nowrap;
}
.fn-addr__reveal {
  flex: 0 0 auto;
  /* Overriding the icon-button's 36px standing height: this one is inside a
     line of text, and a 36px control in a 20px line pushes the whole line
     apart. The tap target is restored below on coarse pointers, where it
     actually matters. */
  min-block-size: 0;
  min-inline-size: 0;
  padding: 2px;
  align-self: center;
  color: var(--fn-fg-faint);
  opacity: 0.75;
  transition: opacity var(--fn-dur-fast) var(--fn-ease),
              color var(--fn-dur-fast) var(--fn-ease);
}
.fn-addr__reveal:hover,
.fn-addr__reveal[aria-pressed="true"] {
  color: var(--fn-primary-text);
  opacity: 1;
}
/* A thumb cannot hit an 18px glyph. The button keeps its typographic size —
   the layout depends on it — and grows a transparent target around itself
   instead, which is what `::after` is for. */
@media (pointer: coarse) {
  .fn-addr__reveal { position: relative; }
  .fn-addr__reveal::after {
    content: "";
    position: absolute;
    inset: -11px;
  }
}
/* Revealed, the address wraps; the group must let it. */
.fn-addr-group:has(.fn-addr--full) {
  white-space: normal;
  align-items: flex-start;
}
.fn-addr-group:has(.fn-addr--full) .fn-addr__reveal { align-self: flex-start; }
/* A members row is a copy target in its own right (members.rs). The pointer
   says so; the address inside it is the named, focusable version for everyone
   not using one. */
.fn-person--tap { cursor: pointer; }
.fn-person--tap:active { background: var(--fn-surface-4); }

/* --- Badges & pills ------------------------------------------------------
   Every badge is the same neutral chip. Only the *ink* carries the meaning,
   and only where there is meaning to carry: six differently tinted lozenges
   on one row is six colours competing for the attention that belongs to the
   message underneath them. Small, uppercase and tracked, so they read as
   labels on the machine rather than as decorations on the content. */
.fn-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--fn-s1);
  padding: 2px 7px;
  border-radius: var(--fn-r-xs);
  background: var(--fn-surface-3);
  color: var(--fn-fg-muted);
  font-size: 10px;
  font-weight: 700;
  line-height: 1.5;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  /* springBouncy — badges pop (motion.ts §springBouncy). */
  animation: fn-pop var(--fn-dur-bouncy) var(--fn-spring-bouncy) both;
}
.fn-badge--admin   { color: var(--fn-crown-text); }
.fn-badge--self    { color: var(--fn-primary-text); }
.fn-badge--muted   { color: var(--fn-fg-faint); }
.fn-badge--danger  { color: var(--fn-danger-text); }
.fn-badge--info    { color: var(--fn-info-text); }
.fn-badge--encrypt { color: var(--fn-encrypt-text); }

/* Unread count. Tabular figures so 1 and 11 sit on the same baseline grid. */
.fn-unread {
  min-inline-size: 20px;
  block-size: 20px;
  padding: 0 6px;
  display: inline-grid;
  place-items: center;
  border-radius: var(--fn-r-pill);
  background: var(--fn-primary);
  color: var(--fn-primary-ink);
  font-size: var(--fn-t-xs);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  box-shadow: 0 0 0 2px var(--fn-surface);
  animation: fn-pop var(--fn-dur-bouncy) var(--fn-spring-bouncy) both;
}
.fn-unread--dot {
  min-inline-size: 8px;
  inline-size: 8px;
  block-size: 8px;
  padding: 0;
}

/* --- Mention badge -------------------------------------------------------
   Deliberately a different *shape* from .fn-unread, not a different colour.
   The two sit side by side and answer different questions — "is there
   anything new" and "is any of it mine" — and it is the second that people
   triage by, so it has to survive being next to a count of forty. A square
   with an @ in it reads at a glance where a second pill would not. */
.fn-mention-badge {
  display: inline-flex;
  align-items: center;
  gap: 1px;
  min-inline-size: 20px;
  block-size: 20px;
  padding: 0 5px;
  border-radius: var(--fn-r-sm, 6px);
  background: var(--fn-accent, var(--fn-primary));
  color: var(--fn-primary-ink);
  font-size: var(--fn-t-xs);
  font-weight: 800;
  line-height: 1;
  justify-content: center;
  box-shadow: 0 0 0 2px var(--fn-surface);
  animation: fn-pop var(--fn-dur-bouncy) var(--fn-spring-bouncy) both;
}
.fn-mention-badge__count {
  font-variant-numeric: tabular-nums;
  font-weight: 700;
}
/* The @ chip and the unread pill share the row's trailing column. Laid out
   side by side rather than stacked so the row keeps one line of badges
   whether or not a mention is present — a column that changes height as
   mentions arrive makes the whole list jump. */
.fn-room-row__aside {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 4px;
}
.fn-room-row__aside:has(.fn-mention-badge) {
  gap: 3px;
}

/* --- Threads --------------------------------------------------------------
   A thread is drawn *under* its parent with a rail down the left, rather than
   in a side panel. This app has a phone layout, and on a phone a side panel is
   the whole screen — which turns "glance at the replies" into "leave the
   conversation". The rail is what says these belong to the message above. */
.fn-thread-open {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-block-start: 4px;
  padding: 3px 8px;
  border: 0;
  border-radius: var(--fn-r-pill);
  background: color-mix(in srgb, var(--fn-primary) 12%, transparent);
  color: var(--fn-primary);
  font-size: var(--fn-t-xs);
  font-weight: 600;
  cursor: pointer;
}
.fn-thread-open:hover,
.fn-thread-open:focus-visible {
  background: color-mix(in srgb, var(--fn-primary) 22%, transparent);
}
.fn-thread-open.is-open {
  background: color-mix(in srgb, var(--fn-primary) 22%, transparent);
}

.fn-thread {
  margin: 4px 0 8px 18px;
  padding-inline-start: 12px;
  border-inline-start: 2px solid
    color-mix(in srgb, var(--fn-primary) 35%, transparent);
}
.fn-thread__reply {
  margin-block-start: 2px;
  padding: 4px 0;
  border: 0;
  background: none;
  color: var(--fn-ink-2);
  font-size: var(--fn-t-xs);
  cursor: pointer;
}
.fn-thread__reply:hover,
.fn-thread__reply:focus-visible {
  color: var(--fn-primary);
  text-decoration: underline;
}

/* --- Mentions -------------------------------------------------------------
   Two treatments, because they answer different questions. Any mention is
   tinted so the eye can find who a message is addressed to; a mention of
   *you* is filled, because that is the one a person is scanning for. */
.fn-mention {
  padding: 0 3px;
  border-radius: 4px;
  background: color-mix(in srgb, var(--fn-primary) 14%, transparent);
  color: var(--fn-primary);
  font-weight: 600;
}
.fn-mention--me {
  background: var(--fn-primary);
  color: var(--fn-primary-ink);
}

/* The composer's @ autocomplete. Anchored above the field: below it is where
   the on-screen keyboard is on a phone. */
.fn-mention-pop {
  position: absolute;
  inset-block-end: calc(100% + 6px);
  inset-inline-start: 8px;
  z-index: 20;
  margin: 0;
  padding: 4px;
  list-style: none;
  min-inline-size: 220px;
  max-inline-size: min(320px, calc(100% - 16px));
  border-radius: var(--fn-r-md, 10px);
  background: var(--fn-surface-2, var(--fn-surface));
  border: 1px solid var(--fn-line);
  box-shadow: var(--fn-shadow-lg, 0 12px 32px rgb(0 0 0 / 0.35));
}
.fn-mention-pop li {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  border-radius: var(--fn-r-sm, 6px);
  cursor: pointer;
}
.fn-mention-pop li.is-active {
  background: color-mix(in srgb, var(--fn-primary) 18%, transparent);
}

/* The "replying in thread" chip above the composer. Unmissable on purpose:
   a reply that silently went somewhere other than the channel is the worst
   outcome this screen can produce. */
.fn-composer__reply {
  position: absolute;
  inset-block-end: calc(100% + 4px);
  inset-inline-start: 8px;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 3px 6px 3px 10px;
  border-radius: var(--fn-r-pill);
  background: color-mix(in srgb, var(--fn-primary) 16%, transparent);
  color: var(--fn-primary);
  font-size: var(--fn-t-xs);
  font-weight: 600;
}

/* Both of the above are positioned against the composer. */
.fn-composer {
  position: relative;
}

/* --- Jump to latest -------------------------------------------------------
   Sits above the composer, overlaying the bottom of the stream. Shown only
   while scrolled away *and* something has arrived — a permanent "jump to
   bottom" control answers a question nobody asked. */
.fn-stream-wrap {
  position: relative;
  display: grid;
  min-block-size: 0;
}
.fn-jump-latest {
  position: absolute;
  inset-block-end: 12px;
  inset-inline: 0;
  margin-inline: auto;
  inline-size: fit-content;
  z-index: 15;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 14px;
  border: 0;
  border-radius: var(--fn-r-pill);
  background: var(--fn-primary);
  color: var(--fn-primary-ink);
  font-size: var(--fn-t-xs);
  font-weight: 700;
  cursor: pointer;
  box-shadow: var(--fn-shadow-lg, 0 8px 24px rgb(0 0 0 / 0.35));
  animation: fn-pop var(--fn-dur-bouncy) var(--fn-spring-bouncy) both;
}
/* The chevron points down; `back` is a left-facing caret. */
.fn-jump-latest svg {
  transform: rotate(-90deg);
}

/* --- In-room search --------------------------------------------------------
   Client-side only: the server never indexes an encrypted room's content, so
   this is scoped to whatever this device already has loaded and decrypted in
   memory (`web/src/components/chat.rs`). Sits between the header and the
   stream, closed by default. */
.fn-room-search {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px 16px;
  border-block-end: 1px solid var(--fn-border, rgb(255 255 255 / 0.08));
  background: var(--fn-surface-2, rgb(255 255 255 / 0.03));
}
.fn-room-search input {
  inline-size: 100%;
}
.fn-room-search__results {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-block-size: 240px;
  overflow-y: auto;
  list-style: none;
  margin: 0;
  padding: 0;
}
.fn-room-search__hit {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 6px 8px;
  border-radius: var(--fn-r-sm, 6px);
}
.fn-room-search__hit:hover {
  background: var(--fn-surface-3, rgb(255 255 255 / 0.06));
}
.fn-room-search__who {
  flex-shrink: 0;
  font-weight: 700;
  font-size: var(--fn-t-xs);
  opacity: 0.85;
}
.fn-room-search__snippet {
  flex-grow: 1;
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--fn-t-sm, 0.875rem);
}
.fn-room-search__time {
  flex-shrink: 0;
  font-size: var(--fn-t-xs);
  opacity: 0.6;
}
.fn-room-search__empty {
  padding: 6px 8px;
  font-size: var(--fn-t-sm, 0.875rem);
  opacity: 0.6;
}

/* --- Mentions inbox ------------------------------------------------------- */
.fn-mention-row {
  inline-size: 100%;
  text-align: start;
  border: 0;
  background: none;
  cursor: pointer;
}
.fn-mention-row.is-unread {
  background: color-mix(in srgb, var(--fn-primary) 8%, transparent);
}
.fn-mention-row__head {
  display: flex;
  align-items: baseline;
  gap: 8px;
  font-size: var(--fn-t-xs);
}
.fn-mention-row__room {
  color: var(--fn-ink-2);
}
.fn-mention-row__head time {
  margin-inline-start: auto;
  color: var(--fn-ink-3, var(--fn-ink-2));
}
.fn-mention-row__text {
  margin: 2px 0 0;
  overflow: hidden;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
}

/* --- Admin console -------------------------------------------------------- */
.fn-tabs {
  display: flex;
  gap: 4px;
  margin-block-end: 10px;
  border-block-end: 1px solid var(--fn-line);
}
.fn-tab {
  padding: 6px 12px;
  border: 0;
  border-block-end: 2px solid transparent;
  background: none;
  color: var(--fn-ink-2);
  font-weight: 600;
  cursor: pointer;
}
.fn-tab.is-active {
  border-block-end-color: var(--fn-primary);
  color: var(--fn-ink);
}
.fn-admin-row__name {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
}
.fn-admin-row__meta {
  color: var(--fn-ink-2);
  font-size: var(--fn-t-xs);
}
.fn-admin-row__actions {
  display: flex;
  gap: 6px;
  flex-shrink: 0;
}
.fn-admin-list {
  margin: 4px 0 0;
  padding-inline-start: 18px;
  color: var(--fn-ink-2);
  font-size: var(--fn-t-xs);
  word-break: break-all;
}

/* --- Room list sections ---------------------------------------------------
   Channels and direct messages are the same primitive server-side and want to
   be two lists on screen: "where the team talks" and "who I am talking to"
   are different questions, and one merged list sorted by recency answers
   neither well. */
.fn-room-section {
  display: block;
  padding: 10px 12px 4px;
  font-size: var(--fn-t-xs);
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--fn-ink-3, var(--fn-ink-2));
}
.fn-room-section:first-child {
  padding-block-start: 4px;
}

/* --- Encryption lock ----------------------------------------------------- */
.fn-lock {
  display: inline-grid;
  place-items: center;
  inline-size: 16px;
  block-size: 16px;
  flex: 0 0 auto;
  color: var(--fn-encrypt);
  vertical-align: -2px;
  transition: color var(--fn-dur) var(--fn-ease);
}
.fn-lock::before {
  content: "";
  inline-size: 12px;
  block-size: 12px;
  background: currentColor;
  -webkit-mask: var(--fn-icon-lock) center / contain no-repeat;
  mask: var(--fn-icon-lock) center / contain no-repeat;
}
.fn-lock--off { color: var(--fn-fg-faint); }
.fn-lock--pending { color: var(--fn-crown); }

:root {
  --fn-icon-lock: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='11' width='18' height='11' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E");
  --fn-icon-crown: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23000'%3E%3Cpath d='M2 18h20l-2-9-5 4-3-7-3 7-5-4z'/%3E%3C/svg%3E");
  --fn-icon-check: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12.5 9.5 18 20 6'/%3E%3C/svg%3E");
  --fn-icon-arrow: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h13M13 6l6 6-6 6'/%3E%3C/svg%3E");
}

.fn-crown-icon {
  display: inline-block;
  inline-size: 13px;
  block-size: 13px;
  background: var(--fn-crown);
  -webkit-mask: var(--fn-icon-crown) center / contain no-repeat;
  mask: var(--fn-icon-crown) center / contain no-repeat;
  vertical-align: -1px;
}

/* --- Connection status pill ----------------------------------------------
   Each state carries its own entrance animation. Because the modifier class
   itself changes when the transport changes, the animation re-runs — which
   is the only way to get "animate on state change" out of pure CSS. */
.fn-conn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px var(--fn-s2) 3px 7px;
  border-radius: var(--fn-r-pill);
  border: 1px solid transparent;
  background: var(--fn-surface-3);
  color: var(--fn-fg-muted);
  font-family: var(--fn-font-mono);
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  cursor: pointer;
  white-space: nowrap;
  transition: color var(--fn-dur) var(--fn-ease),
              border-color var(--fn-dur) var(--fn-ease),
              background-color var(--fn-dur) var(--fn-ease),
              transform var(--fn-dur-snappy) var(--fn-spring-snappy);
}
.fn-conn:hover { transform: scale(var(--fn-tap-hover)); }
.fn-conn:active { transform: scale(var(--fn-tap-press)); }
/* The dot is the only thing that carries colour, and it emits a little of
   it: a live connection should look live. */
.fn-conn::before {
  content: "";
  inline-size: 6px;
  block-size: 6px;
  border-radius: var(--fn-r-pill);
  background: currentColor;
  box-shadow: 0 0 7px 0 currentColor;
  flex: 0 0 auto;
}
.fn-conn--ws {
  color: var(--fn-online-text);
  animation: fn-pop var(--fn-dur-bouncy) var(--fn-spring-bouncy) both;
}
.fn-conn--poll {
  color: var(--fn-info-text);
  animation: fn-pop var(--fn-dur-bouncy) var(--fn-spring-bouncy) both;
}
.fn-conn--syncing { color: var(--fn-info-text); }
.fn-conn--syncing::before { animation: fn-pulse 1.1s var(--fn-ease-cosine) infinite; }
/* Local mode: a state, not a health — steady green, no pulse, no shake. */
.fn-conn--local {
  color: var(--fn-online-text);
  animation: fn-pop var(--fn-dur-bouncy) var(--fn-spring-bouncy) both;
}
.fn-conn--offline {
  color: var(--fn-danger-text);
  background: var(--fn-danger-soft);
  animation: fn-shake 420ms var(--fn-ease-cosine) both;
}
span.fn-conn { cursor: default; }

@keyframes fn-pulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50%      { opacity: 0.35; transform: scale(0.7); }
}

/* --- Spinner -------------------------------------------------------------- */
.fn-spinner {
  inline-size: 20px;
  block-size: 20px;
  border: 2px solid var(--fn-primary-soft);
  border-top-color: var(--fn-primary);
  border-radius: var(--fn-r-pill);
  animation: fn-spin 620ms linear infinite;
  vertical-align: -4px;
}
.fn-spinner--lg { inline-size: 34px; block-size: 34px; border-width: 3px; }
.fn-spinner--on-primary {
  border-color: color-mix(in srgb, var(--fn-primary-ink) 30%, transparent);
  border-top-color: var(--fn-primary-ink);
}
@keyframes fn-spin { to { transform: rotate(1turn); } }

/* --- Divider with label --------------------------------------------------- */
.fn-rule {
  display: flex;
  align-items: center;
  gap: var(--fn-s3);
  color: var(--fn-fg-faint);
  font-size: var(--fn-t-xs);
  font-weight: 700;
  letter-spacing: var(--fn-tr-caps);
  text-transform: uppercase;
}
.fn-rule::before,
.fn-rule::after {
  content: "";
  flex: 1;
  block-size: 1px;
  background: var(--fn-hair);
}


/* ==========================================================================
   5. APP SHELL & TWO-PANE LAYOUT
   ========================================================================== */

/* The single column is `minmax(0, 1fr)`, not the implicit `auto`. An `auto`
   track floors at the widest child's min-content, so one unshrinkable row —
   the top bar's five icons, the bottom nav's five labels — widens every row
   with it and the whole shell scrolls sideways. Below about 300px that is
   what happens, and it is reachable on a normal phone: Safari page zoom and
   iOS Display Zoom both shrink the layout viewport rather than scaling it,
   so a 390pt phone at 175% lays out at 223pt. Flooring the track at 0 makes
   the viewport the authority and pushes each row to solve its own overflow
   (§16 narrow tier). */
.fn-app {
  display: grid;
  grid-template-rows: auto auto 1fr;
  grid-template-columns: minmax(0, 1fr);
  block-size: 100dvh;
  background: var(--fn-bg);
  overflow: hidden;
}

/* Testnet ribbon — persistent, never dismissible.

   It used to be a saturated blue bar across the top of the product, which
   made the single loudest thing on the screen a piece of environment
   metadata. It is a readout now: black strip, mono, tracked, with one lit
   dot. Same information, and it stops competing with the conversation. */
.fn-ribbon {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--fn-s2);
  padding: 4px var(--fn-s3);
  padding-block-start: calc(4px + var(--fn-safe-top));
  padding-inline-start: max(var(--fn-s3), var(--fn-safe-left));
  padding-inline-end: max(var(--fn-s3), var(--fn-safe-right));
  background: var(--fn-bg);
  color: var(--fn-fg-faint);
  font-family: var(--fn-font-mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  text-align: center;
}
.fn-ribbon::before {
  content: "";
  inline-size: 5px;
  block-size: 5px;
  flex: 0 0 auto;
  border-radius: var(--fn-r-pill);
  background: var(--fn-info);
  box-shadow: 0 0 6px 1px color-mix(in srgb, var(--fn-info) 60%, transparent);
}
/* Mainnet. The readout keeps its shape — same strip, same mono, same one lit
   dot — and changes only its colour, because the difference between play
   money and real money should register before the sentence is read. */
.fn-ribbon[data-live] { color: var(--fn-warn-text, #f6c177); }
.fn-ribbon[data-live]::before {
  background: var(--fn-warn, #f6c177);
  box-shadow: 0 0 6px 1px color-mix(in srgb, var(--fn-warn, #f6c177) 60%, transparent);
}

/* Top bar. No rule under it: it is a lighter surface than the page it sits
   above, and that step is the separation. A hairline on top of a luminance
   change is a belt with braces, and it is the detail that makes an interface
   look like a form. */
.fn-topbar {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  min-block-size: var(--fn-topbar-h);
  min-inline-size: 0;
  padding: var(--fn-s2) var(--fn-s3);
  padding-inline-start: max(var(--fn-s3), var(--fn-safe-left));
  padding-inline-end: max(var(--fn-s3), var(--fn-safe-right));
  background: var(--fn-surface);
}
.fn-topbar__identity {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  padding: var(--fn-s1) var(--fn-s2);
  border: 0;
  border-radius: var(--fn-r-card);
  background: transparent;
  color: inherit;
  cursor: pointer;
  /* The name is the elastic half of the bar: it gives up width so the icons
     keep theirs, and truncates rather than pushing them off-screen. Shrink
     only — growing would stretch the hover highlight across the whole bar. */
  flex: 0 1 auto;
  min-inline-size: 0;
  text-align: start;
  transition: background-color var(--fn-dur-fast) var(--fn-ease),
              transform var(--fn-dur-snappy) var(--fn-spring-snappy);
}
.fn-topbar__identity:hover { background: var(--fn-surface-3); }
.fn-topbar__identity:active { transform: scale(var(--fn-tap-press)); }
/* The name and the address are two lines, not one run-on string. The
   wrapping <span> has no display of its own, so it gets one here. */
.fn-topbar__identity > span:last-child {
  display: grid;
  gap: 1px;
  min-inline-size: 0;
}
.fn-topbar__name {
  font-size: var(--fn-t-sm);
  font-weight: 700;
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.fn-topbar__addr {
  font-size: var(--fn-t-xs);
  line-height: 1.2;
  min-inline-size: 0;
  overflow: hidden;
}
.fn-topbar__addr .fn-addr { font-size: var(--fn-t-xs); }
/* The build number sits beside the identity as quiet metadata — legible when
   looked for, invisible when not. */
.fn-topbar__version {
  flex: 0 0 auto;
  font-size: var(--fn-t-xs);
  letter-spacing: 0.04em;
  color: var(--fn-fg-muted);
}
/* The live transport badge. Present only while the page is actually on
   QUIC — a browser upgrades itself once it has seen `Alt-Svc`, so this
   appearing *is* the signal. Lit rather than muted, because unlike the
   version beside it this is a state that changed. */
.fn-topbar__h3 {
  flex: 0 0 auto;
  padding: 2px var(--fn-s2);
  border: 1px solid var(--fn-primary-line);
  border-radius: var(--fn-r-xs);
  background: var(--fn-primary-soft);
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-xs);
  font-weight: 700;
  letter-spacing: 0.04em;
  color: var(--fn-primary-text);
  cursor: pointer;
}
.fn-topbar__h3:hover { background: var(--fn-primary-wash); }
.fn-topbar__h3:focus-visible {
  outline: 2px solid var(--fn-focus);
  outline-offset: 2px;
}

.fn-topbar__actions {
  display: flex;
  align-items: center;
  gap: var(--fn-s1);
  margin-inline-start: auto;
  flex: 0 0 auto;
}
/* The invitations button carries an <Unread> chip; pin it to the corner
   instead of letting it push the icon sideways. */
.fn-topbar__actions .fn-unread {
  position: absolute;
  inset-block-start: -4px;
  inset-inline-end: -4px;
}

/* Panes */
.fn-panes {
  display: grid;
  grid-template-columns: var(--fn-list-w) minmax(0, 1fr);
  min-block-size: 0;
  overflow: hidden;
  inline-size: 100%;
}
.fn-pane {
  display: flex;
  flex-direction: column;
  min-inline-size: 0;
  min-block-size: 0;
  overflow: hidden;
}
.fn-pane--list {
  background: var(--fn-surface);
  padding-inline-start: var(--fn-safe-left);
}
/* The one HUD flourish that runs the whole product: a 2% lattice over the
   largest empty surface on screen. At this alpha it is invisible as a
   pattern and only reads as "this space is instrumented" — which is the
   entire budget for the machine idea. It costs one paint of a repeating
   gradient and nothing per frame. */
.fn-pane--detail {
  background-color: var(--fn-bg);
  /* The vignette rides above the lattice: the stage is brightest where the
     conversation is and falls away toward the corners, which is the single
     cheapest thing a surface can do to read as *lit* rather than filled.
     One radial, painted once, never animated. It disappears politely in
     light mode because the vignette colour is transparent there — daylight
     has no corners. */
  background-image:
    radial-gradient(120% 90% at 50% 30%, transparent 55%, var(--fn-vignette) 100%),
    repeating-linear-gradient(0deg, var(--fn-grid) 0 1px, transparent 1px 48px),
    repeating-linear-gradient(90deg, var(--fn-grid) 0 1px, transparent 1px 48px);
  padding-inline-end: var(--fn-safe-right);
}

/* The route wrapper inside the detail pane. Keyed on the nav section in
   `shell.rs`, so it remounts — and therefore replays its entrance — when
   the section changes, but not when you move between rooms. */
.fn-view {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-block-size: 0;
  min-inline-size: 0;
  animation: fn-view-in var(--fn-dur-expo) var(--fn-ease-expo) both;
}

/* Bottom nav (single-column layout only; see §16) */
.fn-bottomnav {
  display: none;
  grid-auto-flow: column;
  /* `1fr` alone still floors at min-content, so "Members" and "Settings" set
     a width the nav cannot go below. Five equal tracks that may reach zero. */
  grid-auto-columns: minmax(0, 1fr);
  align-items: stretch;
  background: var(--fn-surface);
  padding-block-end: var(--fn-safe-bottom);
  padding-inline: var(--fn-safe-left) var(--fn-safe-right);
}
.fn-bottomnav__item {
  display: grid;
  place-items: center;
  gap: 2px;
  padding: var(--fn-s2) var(--fn-s1);
  border: 0;
  background: none;
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-xs);
  font-weight: 600;
  cursor: pointer;
  position: relative;
  min-block-size: var(--fn-navbar-h);
  min-inline-size: 0;
  transition: color var(--fn-dur-fast) var(--fn-ease),
              background-color var(--fn-dur-fast) var(--fn-ease);
}
/* The label truncates inside its own track instead of widening it. */
.fn-bottomnav__item > span:not(.fn-bottomnav__badge) {
  max-inline-size: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.fn-bottomnav__item:hover:not([disabled]) { background: var(--fn-surface-3); color: var(--fn-fg); }
.fn-bottomnav__item:active:not([disabled]) > svg { transform: scale(var(--fn-tap-press)); }
.fn-bottomnav__item > svg { transition: transform var(--fn-dur-snappy) var(--fn-spring-snappy); }
.fn-bottomnav__item[aria-current="page"] { color: var(--fn-primary-text); font-weight: 700; }
.fn-bottomnav__item[aria-current="page"]::before {
  content: "";
  position: absolute;
  inset-block-start: 0;
  inline-size: 28px;
  block-size: 2px;
  border-radius: var(--fn-r-pill);
  background: var(--fn-primary);
  animation: fn-nav-underline var(--fn-dur-snappy) var(--fn-spring-snappy) both;
}
.fn-bottomnav__item[disabled] { opacity: 0.4; cursor: not-allowed; }
.fn-bottomnav__badge {
  position: absolute;
  inset-block-start: 4px;
  inset-inline-start: calc(50% + 6px);
}

/* The More sheet (dialogs/more.rs) — what the bottom nav's fifth slot opens.
   A tab bar holds five things before the labels truncate, and this app has
   nine destinations and three tools; the four that are used constantly keep
   their slots and the rest live here.

   Rows with words rather than a grid of glyphs: this is the surface people
   arrive at *because* they could not find something, so every entry says its
   name and the icon is only there to be recognised alongside it. */
.fn-more { display: grid; gap: var(--fn-s3); }
.fn-more__group { display: grid; gap: 2px; }
.fn-more__row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--fn-s3);
  inline-size: 100%;
  /* A full touch target even where the sheet is scrolled with a thumb. */
  min-block-size: 48px;
  padding: var(--fn-s2) var(--fn-s3);
  border: 0;
  border-radius: var(--fn-r-card);
  background: none;
  color: var(--fn-fg);
  font-size: var(--fn-t-md);
  font-weight: 600;
  text-align: start;
  cursor: pointer;
  transition: background-color var(--fn-dur-fast) var(--fn-ease),
              transform var(--fn-dur-snappy) var(--fn-spring-snappy);
}
.fn-more__row > svg { color: var(--fn-fg-muted); }
.fn-more__row:hover { background: var(--fn-surface-3); }
.fn-more__row:active { transform: scale(var(--fn-tap-press)); }
/* The row you are already on. The sheet is a map as much as a menu, and one
   marked row is what tells you where the map is centred. */
.fn-more__row[aria-current="page"] { color: var(--fn-primary-text); }
.fn-more__row[aria-current="page"] > svg { color: var(--fn-primary); }

/* The top bar's More, the mirror image of `fn-topbar__wide`: hidden wherever
   that row is shown, because a wide top bar already *is* the menu and a sheet
   listing what is on screen is a door into the room you are standing in. */
.fn-topbar__narrow { display: none; }

/* Back button appears only in the single-column layout (§16). */
.fn-back { display: none; }


/* ==========================================================================
   6. ROOM LIST
   ========================================================================== */

.fn-roomlist__head {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  padding: var(--fn-s3);
}
.fn-roomlist__head .topcoat-search-input { flex: 1; min-inline-size: 0; }

/* The ⚡ one-click room button. Tinted, because next to the plain `+` it is
   the *other* answer to the same question — quick versus configurable — and
   two grey icon buttons would read as one button drawn twice. */
.fn-fastbtn {
  color: var(--fn-primary-text);
  border-color: var(--fn-primary-line);
  background: var(--fn-primary-soft);
}
.fn-fastbtn:hover { background: var(--fn-primary-wash); }
.fn-fastbtn:disabled { opacity: 0.5; }
.fn-roomlist__body {
  flex: 1;
  min-block-size: 0;
  padding: var(--fn-s1) var(--fn-s2) var(--fn-s3);
  display: grid;
  align-content: start;
  gap: 2px;
}

/* A room is a tile, not a list item: no rule above or below it, a fill that
   appears under the pointer, and a small lift. The lift is a transform so it
   stays on the compositor — a row that reflows on hover is a row that stutters
   on a long list. */
.fn-room-row {
  --fn-hue: 24;
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  grid-template-rows: auto auto;
  column-gap: var(--fn-s3);
  row-gap: 3px;
  inline-size: 100%;
  padding: var(--fn-s2) var(--fn-s3);
  border: 0;
  border-radius: var(--fn-r-card);
  background: none;
  color: inherit;
  text-align: start;
  cursor: pointer;
  transition: background-color var(--fn-dur-fast) var(--fn-ease),
              transform var(--fn-dur-snappy) var(--fn-spring-snappy);
  /* listItem: opacity 0, y 12, scale .98 → springSoft. */
  animation: fn-list-in var(--fn-dur-soft) var(--fn-spring-soft) both;
  animation-delay: min(calc(var(--i, 0) * var(--fn-stagger)), var(--fn-stagger-max));
  /* The rack has to clip its own poster. */
  overflow: hidden;
}
/* The rack. Each row wears its room's sigil — the same PNG its <Ident> shows,
   so it is already in cache — bled off the trailing edge and faded to nothing
   by mid-row. Two masks multiply: horizontal, so the art never sits under the
   text; vertical, so it reads as *emerging from* the row rather than pasted
   on. At rest it is barely there (a rack, not a gallery); selection is what
   turns the poster up, which gives the row's imagery the same job as its
   accent bar — saying "you are here" — instead of a second, competing voice.

   Why `::after` and not `background-image`: the row's own background is
   state (hover, selected) and the two must compose, not fight. */
.fn-room-row::after {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline-end: 0;
  inline-size: 34%;
  pointer-events: none;
  background: var(--row-art, none) center / cover no-repeat;
  /* Chrome on black is theatre; chrome on white is a smudge. The poster gets
     the full treatment only where the art was made to live. */
  opacity: var(--fn-poster-rest);
  -webkit-mask-image: linear-gradient(to left, black 30%, transparent 96%),
    linear-gradient(to bottom, transparent, black 40% 70%, transparent);
  -webkit-mask-composite: source-in;
  mask-image: linear-gradient(to left, black 30%, transparent 96%),
    linear-gradient(to bottom, transparent, black 40% 70%, transparent);
  mask-composite: intersect;
  transition: opacity var(--fn-dur) var(--fn-ease);
  /* A positioned pseudo paints above the row's in-flow text; the poster
     belongs *behind* the timestamp, not on it. */
  z-index: 0;
}
.fn-room-row > * {
  position: relative;
  z-index: 1;
}
@media (hover: hover) {
  .fn-room-row:hover { background: var(--fn-surface-3); }
  .fn-room-row:hover::after { opacity: var(--fn-poster-hover); }
}
.fn-room-row:active { transform: scale(0.995); }
.fn-room-row[aria-selected="true"]::after { opacity: var(--fn-poster-lit); }
/* Selected: a raised surface and one accent bar down the leading edge. The
   old treatment washed the whole row orange, which put the loudest colour in
   the product on a piece of navigation state. */
.fn-room-row[aria-selected="true"] {
  background: var(--fn-surface-4);
}
.fn-room-row[aria-selected="true"]::before {
  content: "";
  position: absolute;
  inset-block: 8px;
  inset-inline-start: 0;
  inline-size: 3px;
  border-radius: var(--fn-r-pill);
  background: var(--fn-primary);
  box-shadow: 0 0 8px 0 color-mix(in srgb, var(--fn-primary) 55%, transparent);
}
.fn-room-row__avatar { grid-row: 1 / span 2; align-self: center; }
.fn-room-row__title {
  grid-column: 2;
  display: flex;
  align-items: center;
  gap: 6px;
  min-inline-size: 0;
  font-size: var(--fn-t-md);
  font-weight: 600;
  color: var(--fn-fg);
}
/* The room's name is a *title* — the one word of display type in the row,
   which is exactly what makes the rack read as a rack and not a table. */
.fn-room-row__name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-family: var(--fn-font-display);
  font-weight: 600;
  letter-spacing: 0.01em;
}
.fn-room-row__meta {
  grid-column: 2;
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-muted);
  min-inline-size: 0;
  /* Without these the member count wrapped onto two lines the moment a
     preview appeared beside it, and the row grew by 14px. */
  white-space: nowrap;
  overflow: hidden;
}
/* Everything in the meta row holds its size; only the preview gives way. */
.fn-room-row__meta > *:not(.fn-room-row__preview) { flex: 0 0 auto; }
.fn-room-row__preview {
  flex: 0 1 auto;
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.fn-room-row__aside {
  grid-column: 3;
  grid-row: 1 / span 2;
  display: grid;
  justify-items: end;
  align-content: center;
  gap: var(--fn-s1);
}
.fn-room-row__time {
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-faint);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* Unread: the name goes to full weight and the row gains a count chip. */
.fn-room-row.is-unread .fn-room-row__name { font-weight: 700; color: var(--fn-fg); }
.fn-room-row.is-unread .fn-room-row__preview { color: var(--fn-fg); }

/* Rotation-pending: the room can be read but not posted to. */
.fn-room-row.is-rotation-pending .fn-lock { color: var(--fn-crown); }

/* --- Swipe to remove ------------------------------------------------------
   The drawer is not positioned over the row and revealed by opacity; it sits
   *after* it in one flex track that slides. That way the reveal is a single
   compositor transform with nothing painted underneath it, and the row needs
   no opaque background of its own to hide the buttons at rest — they are
   genuinely outside the clip.

   `touch-action: pan-y` is what makes the gesture cohabit with the scroller:
   the browser keeps vertical panning, and horizontal movement is left for the
   pointer handlers to claim. */
.fn-swipe {
  position: relative;
  overflow: hidden;
  border-radius: var(--fn-r-card);
  touch-action: pan-y;
  /* A drag with a mouse must not paint a text selection across the row, and a
     long press on iOS must not raise the callout. */
  -webkit-user-select: none;
  user-select: none;
}
.fn-swipe__track {
  display: flex;
  align-items: stretch;
  inline-size: 100%;
  will-change: transform;
  transition: transform var(--fn-dur-snappy) var(--fn-spring-snappy);
}
/* While a finger is on it there is nothing to animate towards: the row is
   already where the thumb put it. */
.fn-swipe__track[data-dragging="true"] { transition: none; }
.fn-swipe__track > .fn-room-row { flex: 0 0 100%; }
.fn-swipe__actions {
  flex: 0 0 auto;
  display: flex;
  align-items: stretch;
  /* Rounded on the trailing side only: the leading edge is a cut, not a gap —
     the buttons are the underside of the row, not a second card. */
  border-start-end-radius: var(--fn-r-card);
  border-end-end-radius: var(--fn-r-card);
  overflow: hidden;
}
.fn-swipe__action {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  inline-size: 76px;
  padding: 0 var(--fn-s2);
  border: 0;
  cursor: pointer;
  font-family: inherit;
  font-size: var(--fn-t-xs);
  font-weight: 600;
  line-height: 1.1;
  text-align: center;
  background: var(--fn-surface-4);
  color: var(--fn-fg);
  transition: filter var(--fn-dur-fast) var(--fn-ease);
}
.fn-swipe__action:hover { filter: brightness(1.08); }
.fn-swipe__action:active { filter: brightness(0.94); }
.fn-swipe__action--danger {
  background: var(--fn-danger-fill);
  color: var(--fn-danger-ink);
}

.fn-roomlist__empty,
.fn-roomlist__loading {
  display: grid;
  gap: var(--fn-s3);
  padding: var(--fn-s5) var(--fn-s4);
  color: var(--fn-fg-muted);
}
.fn-roomlist__loading .fn-skel:nth-child(even) { inline-size: 70%; }


/* ==========================================================================
   7. CHAT VIEW
   ========================================================================== */

.fn-chat {
  display: grid;
  grid-template-rows: auto auto 1fr auto;
  grid-template-columns: minmax(0, 1fr);
  block-size: 100%;
  min-block-size: 0;
}

/* --- Header --------------------------------------------------------------- */
.fn-chat__head {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  padding: var(--fn-s2) var(--fn-s3);
  background: var(--fn-surface);
  min-block-size: var(--fn-topbar-h);
  min-inline-size: 0;
}
.fn-chat__title {
  display: flex;
  align-items: center;
  gap: 6px;
  min-inline-size: 0;
  font-family: var(--fn-font-display);
  font-size: var(--fn-t-lg);
  font-weight: 600;
  letter-spacing: 0.015em;
  flex-wrap: wrap;
  row-gap: 2px;
}
.fn-chat__title > span:first-child {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Room name, member count and the connection pill share one line when there
   is room and stack when there is not, rather than widening the header. */
.fn-chat__submeta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  row-gap: 2px;
  gap: var(--fn-s2);
  min-inline-size: 0;
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-muted);
}
.fn-chat__submeta .topcoat-button--quiet {
  font-size: var(--fn-t-xs);
  font-weight: 600;
  padding-inline: var(--fn-s2);
}
.fn-chat__actions {
  display: flex;
  align-items: center;
  gap: var(--fn-s1);
  margin-inline-start: auto;
}

/* --- Banner strip (offline / rotation / kicked) ---------------------------- */
.fn-banner {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  padding: var(--fn-s2) var(--fn-s4);
  font-size: var(--fn-t-sm);
  font-weight: 600;
  animation: fn-banner-in var(--fn-dur-snappy) var(--fn-spring-snappy) both;
}
.fn-banner__actions { margin-inline-start: auto; display: flex; gap: var(--fn-s2); }
.fn-banner--warn   { background: var(--fn-crown-soft);  color: var(--fn-crown-text); }
.fn-banner--danger { background: var(--fn-danger-soft); color: var(--fn-danger-text); }
.fn-banner--info   { background: var(--fn-info-soft);   color: var(--fn-info-text); }
.fn-banner--offline {
  background: var(--fn-surface-3);
  color: var(--fn-fg-muted);
}

/* --- Message stream -------------------------------------------------------- */
.fn-stream {
  min-block-size: 0;
  padding: var(--fn-s4) var(--fn-s4) var(--fn-s2);
  display: flex;
  flex-direction: column;
  gap: var(--fn-s1);
  /* Ultrawide: stop a 2400px window from producing 200-character lines. */
  inline-size: 100%;
  max-inline-size: var(--fn-stream-max);
  margin-inline: auto;
}

/* A key carrier, not a box. `display: contents` keeps the message rows as
   direct flex children of `.fn-stream`, so the gap between them and the
   entrance stagger behave exactly as before this wrapper existed. Its only job
   is to give Yew a stable key on the outermost node of each list item —
   without which the whole list is torn down and rebuilt on every render,
   which is seen as every message flickering. */
.fn-stream__item {
  display: contents;
}

.fn-daymark {
  align-self: center;
  margin: var(--fn-s4) 0 var(--fn-s2);
  padding: 3px var(--fn-s3);
  border-radius: var(--fn-r-pill);
  background: var(--fn-surface-2);
  color: var(--fn-fg-faint);
  font-family: var(--fn-font-mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  animation: fn-list-in var(--fn-dur-soft) var(--fn-spring-soft) both;
}

/* A message row = [avatar gutter] [bubble stack]. Own messages mirror it.
   bubbleVariants: hidden { opacity 0, y 26 } → spring 380/26. */
.fn-msg {
  --fn-hue: 24;
  display: grid;
  grid-template-columns: 36px minmax(0, 1fr);
  column-gap: var(--fn-s2);
  max-inline-size: 100%;
  margin-block-start: var(--fn-s3);
  animation: fn-bubble-in var(--fn-dur-bubble) var(--fn-spring-bubble) both;
}
.fn-msg--grouped { margin-block-start: 2px; }
.fn-msg--grouped .fn-msg__avatar,
.fn-msg--grouped .fn-msg__sender { display: none; }

.fn-msg__avatar { grid-column: 1; grid-row: 1 / span 3; align-self: end; }
.fn-msg__sender {
  grid-column: 2;
  display: flex;
  align-items: baseline;
  gap: var(--fn-s2);
  margin-block-end: 3px;
  font-size: var(--fn-t-sm);
  font-weight: 700;
  color: var(--fn-fg);
  min-inline-size: 0;
}
.fn-msg__sender .fn-addr { font-size: var(--fn-t-xs); }
.fn-msg__sender > strong {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.fn-msg__time {
  font-size: var(--fn-t-xs);
  font-weight: 400;
  color: var(--fn-fg-faint);
  font-variant-numeric: tabular-nums;
  margin-inline-start: auto;
}

/* A bubble is a fill, not a box: no border, no shadow, one luminance step up
   from the stream behind it. On a near-black page that step is far more
   legible than a hairline was, and a thousand of them scroll without a
   thousand borders to composite. */
.fn-bubble {
  grid-column: 2;
  justify-self: start;
  max-inline-size: min(var(--fn-bubble-max), 100%);
  padding: var(--fn-s2) var(--fn-s3);
  border-radius: 16px;
  border-start-start-radius: 5px;
  background: var(--fn-surface-2);
  color: var(--fn-fg);
  font-size: var(--fn-t-md);
  line-height: var(--fn-lh-body);
  overflow-wrap: anywhere;
  white-space: pre-wrap;
  text-wrap: pretty;
}
.fn-bubble a { color: var(--fn-primary-text); }

/* Inline media (DESIGN.md §7.2 "Content"): a bare image/GIF URL renders as
   the image itself, a YouTube URL as a 16:9 embed. While loading, a surface
   block holds the spot with a spinner; on failure the URL stays clickable. */
.fn-media {
  position: relative;
  display: block;
  margin-block: var(--fn-s1);
}
.fn-media img {
  display: block;
  max-inline-size: 100%;
  max-block-size: 400px;
  border-radius: 10px;
}
.fn-media--loading {
  min-inline-size: 140px;
  min-block-size: 90px;
  border-radius: 10px;
  background: var(--fn-surface-3);
}
.fn-media--loading .fn-spinner {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  translate: -50% -50%;
}
.fn-media--failed { color: var(--fn-fg-muted); }
/* A generated clip posted into a room. Same block as an inline image, with
   the player's own controls: never autoplaying, so a room does not start
   making noise when a message arrives. */
.fn-media--clip video {
  display: block;
  inline-size: min(420px, 100%);
  max-block-size: 400px;
  border-radius: 10px;
  background: #000;
}
.fn-media--video iframe {
  display: block;
  inline-size: min(480px, 100%);
  aspect-ratio: 16 / 9;
  border: 0;
  border-radius: 10px;
}

/* Own messages: mirrored, no avatar gutter, and a lighter neutral rather
   than a colour.

   Two things were tried and rejected here. A saturated orange fill (what
   this was) puts the loudest colour in the product on half of every
   conversation and leaves the accent meaning nothing when it appears on a
   button. A 26% accent wash mixed into a near-black surface is worse: on a
   dark theme every tint of orange resolves to brown. Ownership is carried by
   the side of the screen the bubble is on — every messenger ever made agrees
   — so the bubble only has to be *distinct*, and one luminance step is both
   distinct and quiet. */
.fn-msg--own { grid-template-columns: minmax(0, 1fr); }
.fn-msg--own .fn-msg__avatar { display: none; }
.fn-msg--own .fn-msg__sender { grid-column: 1; justify-content: flex-end; }
.fn-msg--own .fn-msg__time { margin-inline-start: 0; }
.fn-msg--own .fn-bubble {
  grid-column: 1;
  justify-self: end;
  background: var(--fn-surface-4);
  color: var(--fn-fg);
  border-radius: 16px;
  border-start-end-radius: 5px;
}
.fn-msg--own .fn-bubble a { color: var(--fn-primary-text); }
.fn-msg--own .fn-msg__foot { justify-content: flex-end; }
.fn-msg--own .fn-reactions { justify-content: flex-end; }

/* Delivery state */
.fn-msg--pending .fn-bubble {
  opacity: 0.62;
  animation: fn-breathe 1.6s var(--fn-ease-cosine) infinite;
}
.fn-msg--failed .fn-bubble {
  background: var(--fn-danger-soft);
  box-shadow: inset 0 0 0 1px var(--fn-danger);
  color: var(--fn-fg);
}

/* Footer line under a bubble: edited marker, serial, retry. */
.fn-msg__foot {
  grid-column: 2;
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  margin-block-start: 2px;
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-faint);
  flex-wrap: wrap;
}
.fn-msg--own .fn-msg__foot { grid-column: 1; }

/* Ledger slug — the 8-char msgHash prefix that proves a message's integrity.
   Quiet until hover; the on-chain marker is always visible.

   DESIGN.md asks for 55% opacity at rest. That lands the slug at 2.1:1 in
   light mode and 2.6:1 in dark — a *receipt* nobody can read is not a
   receipt. The recession is done with colour instead: it still sits a step
   quieter than the meta line beside it, and still brightens on hover, but
   it clears AA at every step. */
.fn-hash {
  display: inline-flex;
  align-items: center;
  gap: var(--fn-s1);
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-faint);
  border: 0;
  background: none;
  padding: 0;
  cursor: pointer;
  transition: color var(--fn-dur-fast) var(--fn-ease),
              text-decoration-color var(--fn-dur-fast) var(--fn-ease);
}
.fn-msg:hover .fn-hash,
.fn-msg:focus-within .fn-hash { color: var(--fn-fg); }
.fn-hash--verified {
  color: var(--fn-encrypt-text);
  font-weight: 700;
  text-decoration: none;
}
.fn-msg:hover .fn-hash--verified { color: var(--fn-encrypt-text); }
.fn-hash--verified::before {
  content: "";
  inline-size: 11px;
  block-size: 11px;
  background: currentColor;
  -webkit-mask: var(--fn-icon-check) center / contain no-repeat;
  mask: var(--fn-icon-check) center / contain no-repeat;
}

/* Deleted tombstone — same precedence note as `--sealed` below. */
.fn-bubble--deleted,
.fn-msg--own .fn-bubble--deleted {
  background: transparent;
  border: 1px dashed var(--fn-border-strong);
  color: var(--fn-fg-faint);
  font-style: italic;
}

/* Undecryptable — the client holds no wrap for this message's key epoch.
   `.fn-msg--own .fn-bubble` is a two-class selector and used to win over
   this one, so your *own* sealed messages were painted as ordinary orange
   bubbles with ciphertext-placeholder text inside them. */
.fn-bubble--sealed,
.fn-msg--own .fn-bubble--sealed {
  background: var(--fn-surface-2);
  color: var(--fn-fg-muted);
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-sm);
  box-shadow: none;
}

/* System events */
.fn-sysmsg {
  align-self: center;
  max-inline-size: var(--fn-bubble-max);
  margin: var(--fn-s2) 0;
  padding: 2px var(--fn-s3);
  border-radius: var(--fn-r-pill);
  background: var(--fn-surface-3);
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-sm);
  text-align: center;
}
.fn-sysmsg--rotation { background: var(--fn-crown-soft); color: var(--fn-crown-text); font-weight: 600; }

/* Hover actions rail */
.fn-msg__tools {
  grid-column: 2;
  justify-self: start;
  display: flex;
  gap: 2px;
  opacity: 0;
  position: relative;
  transition: opacity var(--fn-dur-fast) var(--fn-ease),
              transform var(--fn-dur-snappy) var(--fn-spring-snappy);
  transform: translateY(-2px);
}
.fn-msg--own .fn-msg__tools { grid-column: 1; justify-self: end; }
.fn-msg:hover .fn-msg__tools,
.fn-msg:focus-within .fn-msg__tools { opacity: 1; transform: none; }
@media (hover: none) {
  .fn-msg__tools { opacity: 1; transform: none; }
}
/* The per-message menu is a popover, not a block in the flow — without
   this it pushed the next message down by its own height. */
.fn-msg__tools > .fn-picker {
  position: absolute;
  inset-block-start: calc(100% + var(--fn-s1));
  inset-inline-start: 0;
  z-index: 30;
}
/* …and that z-index alone is not enough. `.fn-msg` animates in with
   `fill-mode: both`, so it stays a stacking context after the animation ends,
   and 30 is only 30 *within this message*: every later message is a sibling
   painted afterwards, straight over the open menu. The article itself has to
   rise, which is something only the component knows to ask for (message.rs
   adds the class while its menu is open). `isolation` keeps the raised
   article from also lifting above the composer and the top bar. */
.fn-msg--menu-open {
  position: relative;
  z-index: 30;
  isolation: isolate;
}
/* The newest messages open their menu *upward*.
   The stream scrolls, so it clips: an absolutely positioned box inside an
   `overflow: auto` ancestor is cut at that ancestor's edge, and for the last
   message there is nothing below to scroll to — the menu simply loses its
   bottom half, which is where Delete lives. Flipping it above the row puts it
   over messages already on screen instead of over the edge.

   Bounded to the last two, and never the first: a lone message in a new room
   has the whole stream below it and nothing above, so flipping there would
   move the clipping from the bottom edge to the top one. */
.fn-msg:nth-last-child(-n + 2):not(:first-child) .fn-msg__tools > .fn-picker {
  inset-block-start: auto;
  inset-block-end: calc(100% + var(--fn-s1));
  /* Grow from the edge it is anchored to, or it appears to fly in from the
     wrong direction. */
  transform-origin: bottom center;
}
/* The flipped menus enter from below and retreat downward — the same
   transition machinery as the base `.fn-picker`, only the travel differs. */
@starting-style {
  .fn-msg:nth-last-child(-n + 2):not(:first-child) .fn-msg__tools > .fn-picker {
    transform: translateY(8px) scale(0.94);
  }
}
.fn-msg:nth-last-child(-n + 2):not(:first-child)
  .fn-msg__tools
  > .fn-picker[data-closing] {
  transform: translateY(4px) scale(0.97);
}
.fn-msg--own .fn-msg__tools > .fn-picker {
  inset-inline-start: auto;
  inset-inline-end: 0;
}

/* Reactions */
.fn-reactions {
  grid-column: 2;
  display: flex;
  flex-wrap: wrap;
  gap: var(--fn-s1);
  margin-block-start: var(--fn-s1);
}
.fn-msg--own .fn-reactions { grid-column: 1; }
.fn-reaction {
  display: inline-flex;
  align-items: center;
  gap: var(--fn-s1);
  padding: 2px 8px;
  border-radius: var(--fn-r-pill);
  border: 1px solid transparent;
  background: var(--fn-surface-3);
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-xs);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  line-height: 1.7;
  transition: border-color var(--fn-dur-fast) var(--fn-ease),
              background-color var(--fn-dur-fast) var(--fn-ease),
              color var(--fn-dur-fast) var(--fn-ease),
              transform var(--fn-dur-snappy) var(--fn-spring-snappy);
  /* springBouncy — a reaction landing should feel like a game pickup. */
  animation: fn-pop var(--fn-dur-bouncy) var(--fn-spring-bouncy) both;
}
.fn-reaction:hover { background: var(--fn-surface-4); transform: scale(var(--fn-tap-hover)); }
.fn-reaction:active { transform: scale(var(--fn-tap-press)); }
.fn-reaction[aria-pressed="true"] {
  background: var(--fn-primary-soft);
  border-color: var(--fn-primary-line);
  color: var(--fn-primary-text);
}
.fn-reaction__emoji { font-family: var(--fn-font-emoji); font-size: var(--fn-t-sm); }

/* Emoticon picker and the two `⋮` menus share `.fn-picker`. They are not
   the same shape, and the 8-column grid turned every menu into an eight-
   across confetti of buttons. `role` already distinguishes them, so no
   markup change is needed. */
.fn-picker {
  padding: var(--fn-s2);
  border-radius: var(--fn-r-card);
  background: var(--fn-surface-3);
  box-shadow: 0 0 0 1px var(--fn-rim), var(--fn-elev-3);
  max-inline-size: 320px;
  transform-origin: top center;
  /* TRANSITIONS, not keyframes — this is what buys framer-motion's feel
     without framer. A keyframe animation restarts from frame zero and
     cannot be reversed mid-flight; a transition tweens from the *current*
     computed value, so open-during-close and close-during-open reverse
     smoothly from wherever the menu is, velocity intact to the eye. Entry
     starts from `@starting-style` below. Opacity outruns transform on
     purpose: readable in ~80ms, still settling for another 100. */
  opacity: 1;
  transform: none;
  transition: opacity 80ms var(--fn-ease),
              transform 180ms var(--fn-spring-soft);
}
@starting-style {
  .fn-picker {
    opacity: 0;
    transform: translateY(-8px) scale(0.94);
  }
}
/* Exit, driven by `common.rs::Popover`, which keeps the node mounted for
   POPOVER_EXIT_MS after its `open` prop goes false. Faster than the entrance
   and collapsing back toward the anchor, so the menu reads as retreating into
   the button that opened it rather than dissolving where it stands. */
.fn-picker[data-closing] {
  opacity: 0;
  transform: translateY(-4px) scale(0.97);
  transition-duration: 110ms, 110ms;
  transition-timing-function: var(--fn-ease-exit), var(--fn-ease-exit);
  pointer-events: none;
}
.fn-picker[role="dialog"] {
  display: grid;
  grid-template-columns: repeat(8, 1fr);
  gap: var(--fn-s1);
  justify-self: center;
  margin-inline: auto;
  margin-block-end: var(--fn-s2);
}
.fn-picker[role="dialog"] > .fn-tabs { grid-column: 1 / -1; }
/* A message's reaction picker rides the same anchor as its `⋮` menu (the
   `.fn-msg__tools > .fn-picker` rules above). The centering just set is for
   the composer instance; anchored to a row the grid must size to content —
   an absolutely positioned box otherwise shrinks to its tiny relative
   parent, the two-button tools rail. */
.fn-msg__tools > .fn-picker[role="dialog"] {
  justify-self: auto;
  margin-inline: 0;
  margin-block-end: 0;
  inline-size: max-content;
}
.fn-picker[role="menu"] {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 1px;
  inline-size: max-content;
  min-inline-size: 180px;
}
.fn-picker[role="menu"] > .topcoat-button--quiet {
  text-align: start;
  justify-content: flex-start;
  padding: var(--fn-s2) var(--fn-s3);
  border-radius: var(--fn-r-ctl);
}
/* Destructive items are tinted, and the first one is fenced off from the
   harmless neighbour above it. */
.fn-menuitem--danger { color: var(--fn-danger-text); }
.fn-menuitem--danger:hover {
  background: var(--fn-danger-soft);
  color: var(--fn-danger-text);
}
.fn-picker[role="menu"] > :not(.fn-menuitem--danger) + .fn-menuitem--danger {
  margin-block-start: var(--fn-s2);
  border-block-start: 1px solid var(--fn-hair);
  padding-block-start: calc(var(--fn-s2) + var(--fn-s1));
  border-start-start-radius: 0;
  border-start-end-radius: 0;
}
/* The room `⋮` menu is a direct child of the chat grid; float it over the
   stream rather than letting it shove the messages down. */
.fn-chat > .fn-picker[role="menu"] {
  position: absolute;
  inset-block-start: calc(var(--fn-topbar-h) + var(--fn-s1));
  inset-inline-end: var(--fn-s3);
  z-index: 30;
  transform-origin: top right;
}
.fn-chat { position: relative; }

.fn-picker__cell {
  aspect-ratio: 1;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: var(--fn-r-ctl);
  background: none;
  font-family: var(--fn-font-emoji);
  font-size: 18px;
  cursor: pointer;
  transition: background-color var(--fn-dur-fast) var(--fn-ease),
              transform var(--fn-dur-snappy) var(--fn-spring-snappy);
  /* gridItem: opacity 0, scale .3 → springBouncy, stagger 0.012s. */
  animation: fn-pop var(--fn-dur-bouncy) var(--fn-spring-bouncy) both;
  animation-delay: min(calc(var(--i, 0) * var(--fn-stagger-grid)), var(--fn-stagger-grid-max));
}
.fn-picker__cell:hover { background: var(--fn-surface-3); transform: scale(1.18); }
.fn-picker__cell:active { transform: scale(var(--fn-tap-press)); }

/* --- Typing indicator ------------------------------------------------------ */
.fn-typing {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  padding: var(--fn-s1) var(--fn-s4);
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-muted);
  min-block-size: 22px;
}
.fn-typing__dots { display: inline-flex; gap: 3px; align-items: center; }
.fn-typing__dots i {
  inline-size: 5px;
  block-size: 5px;
  border-radius: var(--fn-r-pill);
  background: var(--fn-fg-faint);
  animation: fn-bob 1.05s var(--fn-ease-cosine) infinite;
}
.fn-typing__dots i:nth-child(2) { animation-delay: 140ms; }
.fn-typing__dots i:nth-child(3) { animation-delay: 280ms; }
@keyframes fn-bob {
  0%, 60%, 100% { transform: translateY(0); opacity: 0.45; }
  30%           { transform: translateY(-3px); opacity: 1; }
}

/* --- Composer -------------------------------------------------------------- */
.fn-composer {
  display: grid;
  /* emoji · AI · attach · files · textarea · send. The last two slots are the
     attachment pair (§18.2); the hint below follows the field's column. */
  grid-template-columns: auto auto auto auto minmax(0, 1fr) auto;
  align-items: end;
  gap: var(--fn-s2);
  padding: var(--fn-s3) var(--fn-s4);
  padding-block-end: calc(var(--fn-s3) + var(--fn-safe-bottom));
  padding-inline-start: max(var(--fn-s4), var(--fn-safe-left));
  padding-inline-end: max(var(--fn-s4), var(--fn-safe-right));
  background: var(--fn-surface);
  transition: opacity var(--fn-dur) var(--fn-ease);
}
.fn-composer__input.topcoat-textarea {
  inline-size: 100%;
  min-block-size: 38px;
  max-block-size: 8.5em;
  padding: var(--fn-s2) var(--fn-s3);
  resize: none;
  line-height: var(--fn-lh-body);
  field-sizing: content;
}
.fn-composer__hint {
  grid-column: 5;
  grid-row: 2;
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-faint);
  display: flex;
  gap: var(--fn-s2);
  align-items: center;
}
.fn-composer[data-locked="true"] { opacity: 0.6; pointer-events: none; }
/* Keep the emoji and send buttons optically level with the field's first
   line rather than with its bottom edge as it grows. */
.fn-composer > .topcoat-icon-button--quiet,
.fn-composer > .topcoat-button--cta { align-self: end; min-block-size: 38px; }


/* ==========================================================================
   8. MEMBERS & ADMINS
   ========================================================================== */

.fn-people { padding: var(--fn-s2) 0; }
.fn-person {
  --fn-hue: 24;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--fn-s3);
  margin-inline: var(--fn-s2);
  padding: var(--fn-s2) var(--fn-s3);
  border-radius: var(--fn-r-card);
  transition: background-color var(--fn-dur-fast) var(--fn-ease);
  animation: fn-list-in var(--fn-dur-soft) var(--fn-spring-soft) both;
  animation-delay: min(calc(var(--i, 0) * var(--fn-stagger)), var(--fn-stagger-max));
}
.fn-person:hover { background: var(--fn-surface-3); }
.fn-person--self { background: var(--fn-surface-3); }
.fn-person__name {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: var(--fn-t-md);
  font-weight: 600;
  min-inline-size: 0;
  flex-wrap: wrap;
  row-gap: 2px;
}
.fn-person__name > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The second line of a member row: status, then address. A dot separates them
   only when the status is there to separate — offline renders no label, and a
   leading bullet in front of an address would be a mark with nothing before
   it. */
.fn-person__meta {
  display: flex;
  align-items: baseline;
  gap: 6px;
  min-inline-size: 0;
}
.fn-person__meta > .fn-presence + .fn-addr::before {
  content: "·";
  margin-inline-end: 6px;
  color: var(--fn-fg-faint);
}
.fn-person__actions {
  display: flex;
  gap: var(--fn-s1);
  opacity: 0;
  transition: opacity var(--fn-dur-fast) var(--fn-ease);
}
.fn-person:hover .fn-person__actions,
.fn-person:focus-within .fn-person__actions { opacity: 1; }
@media (hover: none) { .fn-person__actions { opacity: 1; } }

/* Admin card (Manage admins dialog) */
.fn-admin-card {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--fn-s3);
  padding: var(--fn-s2) var(--fn-s3);
  border-radius: var(--fn-r-card);
  background: var(--fn-surface-3);
  box-shadow: inset 3px 0 0 0 var(--fn-crown);
  animation: fn-list-in var(--fn-dur-soft) var(--fn-spring-soft) both;
  animation-delay: min(calc(var(--i, 0) * var(--fn-stagger)), var(--fn-stagger-max));
}
.fn-admin-count {
  font-size: var(--fn-t-xs);
  font-weight: 700;
  color: var(--fn-fg-muted);
  font-variant-numeric: tabular-nums;
}
.fn-admin-count[data-full="true"] { color: var(--fn-danger-text); }


/* ==========================================================================
   9. MODAL / DIALOG
   ========================================================================== */

.fn-modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: grid;
  place-items: center;
  padding: var(--fn-s4);
  padding-block: max(var(--fn-s4), var(--fn-safe-top)) max(var(--fn-s4), var(--fn-safe-bottom));
  padding-inline: max(var(--fn-s4), var(--fn-safe-left)) max(var(--fn-s4), var(--fn-safe-right));
  background: var(--fn-overlay);
  /* `--fn-dur`, not cosine's 240ms: the scrim is the *cause* of the dialog
     being readable, so it cannot afford to arrive after it. */
  animation: fn-fade var(--fn-dur) var(--fn-ease-cosine) both;
  /* The page behind goes soft as the dialog takes over — the depth cue that
     makes a modal read as *above* rather than merely on top. Animated from 0
     so it arrives with the panel instead of snapping on. */
  animation-name: fn-fade, fn-backdrop-blur;
}
@keyframes fn-fade { from { opacity: 0; } }
@keyframes fn-backdrop-blur {
  from { backdrop-filter: blur(0); }
  to   { backdrop-filter: blur(6px); }
}

/* --- Exit ---------------------------------------------------------------
   The dialog animates out before it unmounts; `modal.rs` holds it mounted for
   EXIT_MS and sets `data-closing`, which is the half of an entrance/exit pair
   that CSS alone cannot do. The two constants are one timeline — 140ms here
   and in `modal.rs::EXIT_MS`.

   Exit is faster than entry and reverses the *travel* but not the spring: it
   leaves on `--fn-ease-exit`, which accelerates away rather than easing to a
   stop. Something arriving should settle; something leaving should just go. */
.fn-modal-backdrop[data-closing] {
  animation: fn-fade-out var(--fn-dur-exit) var(--fn-ease-exit) both;
}
.fn-modal-backdrop[data-closing] .fn-modal {
  animation: fn-modal-out var(--fn-dur-exit) var(--fn-ease-exit) both;
}
@keyframes fn-fade-out { to { opacity: 0; backdrop-filter: blur(0); } }
@keyframes fn-modal-out {
  to { opacity: 0; transform: translateY(6px) scale(0.98); }
}

.fn-modal {
  inline-size: min(440px, 100%);
  max-block-size: min(86dvh, 720px);
  display: flex;
  flex-direction: column;
  background: var(--fn-surface-2);
  border-radius: var(--fn-r-panel);
  box-shadow: 0 0 0 1px var(--fn-rim), var(--fn-elev-4);
  overflow: hidden;
  /* 300ms, not `--fn-dur-soft`: the keyframes now carry two crossings, and a
     second swing inside 200ms reads as a shudder rather than a settle. The
     bouncy curve is the per-segment easing, per the MOTION.md convention for
     keyframed springs. */
  animation: fn-modal-in 300ms var(--fn-spring-bouncy) both;
}
.fn-modal--wide { inline-size: min(620px, 100%); }

.fn-modal__head {
  display: flex;
  align-items: flex-start;
  gap: var(--fn-s2);
  padding: var(--fn-s5) var(--fn-s5) var(--fn-s3);
}
.fn-modal__title {
  font-family: var(--fn-font-display);
  font-size: var(--fn-t-lg);
  font-weight: 600;
  letter-spacing: 0.015em;
  text-wrap: balance;
}
.fn-modal__desc { margin-block-start: 2px; font-size: var(--fn-t-sm); color: var(--fn-fg-muted); }
/* Ghost, not a boxed button: a bordered square in the corner competes with
   the actual choices below. It is a convenience exit — Cancel is the real
   one — and it should whisper. */
.fn-modal__close {
  margin-inline-start: auto;
  flex: 0 0 auto;
  border: 0;
  background: transparent;
  box-shadow: none;
  color: var(--fn-fg-muted);
}
.fn-modal__close:hover { color: var(--fn-fg); background: var(--fn-surface-3); }
/* One red mark, not three: the sigil carries the danger; the title stays in
   the page's own voice and the solid button does the committing. */
.fn-modal__sigil {
  flex: 0 0 auto;
  inline-size: 40px;
  block-size: 40px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--fn-danger-soft);
  color: var(--fn-danger-text);
}
/* What the action will destroy, quoted. Clamped: three lines are identity,
   a scrollback is not. */
.fn-modal__quote {
  margin: 0;
  padding: var(--fn-s2) var(--fn-s3);
  border-inline-start: 3px solid var(--fn-danger);
  border-radius: var(--fn-r-ctl);
  background: var(--fn-surface-3);
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-sm);
  line-height: var(--fn-lh-body);
  overflow: hidden;
  overflow-wrap: anywhere;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
}

.fn-modal__body {
  padding: var(--fn-s4);
  overflow-y: auto;
  display: grid;
  gap: var(--fn-s4);
  min-block-size: 0;
  align-content: start;
}
.fn-modal__body:empty { display: none; }
.fn-modal__foot {
  display: flex;
  gap: var(--fn-s2);
  justify-content: flex-end;
  padding: var(--fn-s3) var(--fn-s5) var(--fn-s5);
}
.fn-modal__foot > * { min-inline-size: 96px; }

/* Field group. The label is a caption, not a headline: 12px, semibold,
   muted. A form whose labels are as loud as its content reads as a form. */
.fn-field { display: grid; gap: 6px; }
.fn-field__label {
  font-size: var(--fn-t-label);
  font-weight: 600;
  letter-spacing: var(--fn-tr-label);
  color: var(--fn-fg-muted);
}
.fn-field__help { font-size: var(--fn-t-xs); color: var(--fn-fg-faint); }
.fn-field__error { font-size: var(--fn-t-xs); color: var(--fn-danger-text); font-weight: 600; }
.fn-field .topcoat-text-input,
.fn-field .topcoat-textarea { inline-size: 100%; }

/* Toggle row (e.g. "Encrypt this room") */
.fn-toggle-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--fn-s3);
  padding: var(--fn-s3);
  border-radius: var(--fn-r-card);
  background: var(--fn-surface-2);
  transition: border-color var(--fn-dur) var(--fn-ease),
              background-color var(--fn-dur) var(--fn-ease);
}
.fn-toggle-row[data-on="true"] {
  background: var(--fn-surface-3);
  box-shadow: inset 3px 0 0 0 var(--fn-encrypt);
}

/* Result list inside modals (user search, invitations, blocked users) */
.fn-picklist {
  max-block-size: 260px;
  overflow-y: auto;
  border-radius: var(--fn-r-card);
  background: var(--fn-surface-2);
}
/* Flex, not grid. As a three-column grid any row with a fourth child — the
   settings rows are [icon][label][value][button] — dropped that child onto a
   second line and doubled the row's height. */
.fn-picklist__row {
  --fn-hue: 24;
  display: flex;
  align-items: center;
  gap: var(--fn-s3);
  padding: var(--fn-s3);
  transition: background-color var(--fn-dur-fast) var(--fn-ease);
  animation: fn-list-in var(--fn-dur-soft) var(--fn-spring-soft) both;
  animation-delay: min(calc(var(--i, 0) * var(--fn-stagger)), var(--fn-stagger-max));
}
.fn-picklist__row + .fn-picklist__row { border-top: 1px solid var(--fn-hair); }
.fn-picklist__row:hover { background: var(--fn-surface-3); }
/* Used standalone (settings rows, invitation cards) it needs a card of its
   own — inside `.fn-picklist` it is a row and must stay flush. */
.fn-picklist__row:not(.fn-picklist *) {
  border-radius: var(--fn-r-card);
  background: var(--fn-surface-2);
  box-shadow: var(--fn-elev-1);
}


/* ==========================================================================
   10. TOAST
   ========================================================================== */

.fn-toasts {
  position: fixed;
  z-index: 200;
  inset-block-end: calc(var(--fn-s4) + var(--fn-safe-bottom));
  inset-inline: max(var(--fn-s4), var(--fn-safe-left)) max(var(--fn-s4), var(--fn-safe-right));
  display: grid;
  justify-items: center;
  gap: var(--fn-s2);
  pointer-events: none;
}
@media (min-width: 768px) {
  .fn-toasts {
    inset-block: calc(var(--fn-s4) + var(--fn-safe-top)) auto;
    inset-inline: auto max(var(--fn-s4), var(--fn-safe-right));
    justify-items: end;
  }
}

.fn-toast {
  pointer-events: auto;
  inline-size: min(380px, 100%);
  display: grid;
  grid-template-columns: 3px minmax(0, 1fr) auto;
  align-items: start;
  gap: var(--fn-s3);
  padding: 0;
  background: var(--fn-surface-3);
  border-radius: var(--fn-r-card);
  box-shadow: 0 0 0 1px var(--fn-rim), var(--fn-elev-4);
  overflow: hidden;
  animation: fn-toast-in var(--fn-dur-snappy) var(--fn-spring-snappy) both;
}
.fn-toast::before { content: ""; background: var(--fn-fg-faint); align-self: stretch; }
.fn-toast__body { padding: var(--fn-s3) 0; min-inline-size: 0; }
.fn-toast__title { font-size: var(--fn-t-sm); font-weight: 700; }
.fn-toast__desc { font-size: var(--fn-t-sm); color: var(--fn-fg-muted); }
.fn-toast__close { margin: var(--fn-s2) var(--fn-s2) 0 0; }
.fn-toast--success::before { background: var(--fn-encrypt); }
.fn-toast--error::before   { background: var(--fn-danger); }
.fn-toast--info::before    { background: var(--fn-info); }
.fn-toast--warn::before    { background: var(--fn-crown); }
.fn-toast[data-leaving="true"] {
  animation: fn-toast-out var(--fn-dur-exit) var(--fn-ease-exit) forwards;
}


/* ==========================================================================
   11. EMPTY / ERROR / OFFLINE STATES
   ========================================================================== */

.fn-empty {
  block-size: 100%;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: var(--fn-s3);
  padding: var(--fn-s7) var(--fn-s5);
  text-align: center;
  animation: fn-view-in var(--fn-dur-expo) var(--fn-ease-expo) both;
}
.fn-empty__art {
  inline-size: 88px;
  block-size: 88px;
  display: grid;
  place-items: center;
  border-radius: 28%;
  background: var(--fn-surface-3);
  font-family: var(--fn-font-emoji);
  font-size: 40px;
  /* floatLoop: y [0, -8, 0], 5s, easeInOut, infinite. */
  animation: fn-float var(--fn-dur-float) var(--fn-ease-cosine) infinite;
}
.fn-empty__title {
  font-family: var(--fn-font-display);
  font-size: var(--fn-t-lg);
  font-weight: 600;
  letter-spacing: 0.015em;
  text-wrap: balance;
}
.fn-empty__desc {
  font-size: var(--fn-t-sm);
  color: var(--fn-fg-muted);
  max-inline-size: 42ch;
  text-wrap: pretty;
}
.fn-empty--error .fn-empty__art { background: var(--fn-surface-3); }
.fn-empty > .topcoat-button--cta,
.fn-empty > .topcoat-button { margin-block-start: var(--fn-s2); }

/* --- Illustration art ------------------------------------------------------
   `light-dark()` cannot carry a `url()`, so the theme swap is three rules:
   the light default, the OS-dark fallback, and the two forced-theme
   overrides. Every variant only has to set the two custom properties.

   The PNGs are flat, with an opaque plate matched to the theme surface, so
   they get the card radius and sit directly on the page — no wash, no
   contrasting panel behind them. */
.fn-art {
  --fn-art: var(--fn-art-light);
  background-image: var(--fn-art);
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .fn-art { --fn-art: var(--fn-art-dark); }
}
:root[data-theme="dark"]  .fn-art { --fn-art: var(--fn-art-dark); }
:root[data-theme="light"] .fn-art { --fn-art: var(--fn-art-light); }

/* Empty-state illustrations are flat PNGs with an opaque background, so however
   carefully the palette is matched they still read as a rectangle pasted onto
   the page — the seam is obvious the moment the surface behind them is not the
   exact colour baked into the file.

   Feathering the edges dissolves that seam, and does it without knowing what is
   behind: the same asset now sits correctly on the page surface, inside a card,
   and in either theme. Applied only to the large empty-state usage — the 24px
   badge variants are mostly edge, and a mask would erase them. */
.fn-empty__art.fn-art {
  /* The tinted plate exists for the emoji fallback; with artwork it would show
     through the feathered edge as a coloured halo. */
  background-color: transparent;

  /* Two linear ramps intersected, rather than one radial: the artwork is a
     rectangle, so fading each edge inward keeps the whole subject at full
     opacity and only dissolves the border. A radial vignette large enough to
     spare the subject would put its fade outside the element and do nothing. */
  --fn-art-fade: linear-gradient(
      to right,
      transparent 0,
      #000 30%,
      #000 70%,
      transparent 100%
    ),
    linear-gradient(to bottom, transparent 0, #000 30%, #000 70%, transparent 100%);
  -webkit-mask-image: var(--fn-art-fade);
  mask-image: var(--fn-art-fade);
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
}

.fn-art--rooms {
  --fn-art-light: var(--img-empty-rooms);
  --fn-art-dark: var(--img-empty-rooms-dark);
}
.fn-art--messages {
  --fn-art-light: var(--img-empty-messages);
  --fn-art-dark: var(--img-empty-messages-dark);
}
.fn-art--invitations {
  --fn-art-light: var(--img-empty-invitations);
  --fn-art-dark: var(--img-empty-invitations-dark);
}
.fn-art--search {
  --fn-art-light: var(--img-empty-search);
  --fn-art-dark: var(--img-empty-search-dark);
}
.fn-art--pick {
  --fn-art-light: var(--img-pick-room);
  --fn-art-dark: var(--img-pick-room-dark);
}
.fn-art--encrypted {
  --fn-art-light: var(--img-encrypted-badge);
  --fn-art-dark: var(--img-encrypted-badge-dark);
}
.fn-art--offline {
  --fn-art-light: var(--img-error-offline);
  --fn-art-dark: var(--img-error-offline-dark);
}
.fn-art--bank {
  --fn-art-light: var(--img-bank-hero);
  --fn-art-dark: var(--img-bank-hero-dark);
}
/* Cinematic, themeless (they live on dark plates in both themes), like the
   login hero: the Bank page's vault backdrop and the executing AI Banker. */
.fn-art--vault-hall {
  --fn-art-light: var(--img-bank-vault-hall);
  --fn-art-dark: var(--img-bank-vault-hall);
}
.fn-art--banker-core {
  --fn-art-light: var(--img-banker-core);
  --fn-art-dark: var(--img-banker-core);
}
.fn-art--bank-emblem {
  --fn-art-light: var(--img-bank-emblem);
  --fn-art-dark: var(--img-bank-emblem);
}
.fn-art--dashboard-emblem {
  --fn-art-light: var(--img-dashboard-emblem);
  --fn-art-dark: var(--img-dashboard-emblem);
}
.fn-art--banker {
  --fn-art-light: var(--img-bank-banker);
  --fn-art-dark: var(--img-bank-banker-dark);
}
.fn-art--knowledge {
  --fn-art-light: var(--img-empty-knowledge);
  --fn-art-dark: var(--img-empty-knowledge-dark);
}
/* Full-bleed, used as the login screen's backdrop rather than as a plate.
   Dark gets the cinematic guardian portrait — the one place the app spends
   its movie moment; light keeps the flat illustration, because a photoreal
   night scene on a bright page reads as a hole in the UI, not a hero. */
/* The guardian, in **both** themes. This used to fall back to the flat
   hexagon illustration in light mode, which meant a light-theme user never saw
   the app's actual identity — the sign-in screen looked like a different,
   older product. The artwork panel is treated as its own dark media surface
   (see `.fn-login__aside`) rather than as a themed surface, so the photograph
   sits on the dark it was lit against no matter what the rest of the UI is
   doing. */
.fn-art--login {
  --fn-art-light: var(--img-skynet-hero);
  --fn-art-dark: var(--img-skynet-hero);
}

/* An illustrated empty state: the emoji tile becomes a plate. The glyph is
   still in the DOM (it is `aria-hidden` decoration either way) but is not
   drawn over the artwork. */
.fn-empty__art.fn-art {
  inline-size: min(280px, 68vw);
  block-size: auto;
  aspect-ratio: 4 / 3;
  max-block-size: 42dvh;
  border-radius: var(--fn-r-panel);
  background-color: transparent;
  font-size: 0;
  color: transparent;
}
.fn-empty--error .fn-empty__art.fn-art { background-color: transparent; }

/* Skeleton rows — used only where a network round-trip exceeds ~400ms. */
.fn-skel {
  block-size: 12px;
  border-radius: var(--fn-r-ctl);
  background: linear-gradient(
    90deg,
    var(--fn-surface-3) 0%,
    var(--fn-surface-2) 50%,
    var(--fn-surface-3) 100%
  );
  background-size: 200% 100%;
  animation: fn-shimmer 1.3s linear infinite;
}
.fn-skel + .fn-skel { margin-block-start: var(--fn-s3); }
@keyframes fn-shimmer { to { background-position: -200% 0; } }


/* ==========================================================================
   12. LOGIN SCREEN
   ========================================================================== */

/* The showcase screen: full-bleed artwork, a scrim, and one panel floating
   over it. Nothing else.

   What it replaced was a 440px white card centred on a grey wash, with the
   hero illustration painted behind it as wallpaper — the card had nothing to
   do with the art, and the art had nothing to do with the product. The
   composition now is the streaming-service sign-in: the image is the screen,
   the scrim buys the contrast, and the form is a dark slab in front of it. */
.fn-login {
  position: relative;
  min-block-size: 100dvh;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  background: var(--fn-bg);
  isolation: isolate;
  /* No `overflow-y: auto` here. It was here, and it did nothing: the element's
     height is `min-block-size` plus content, so it grows to fit rather than
     ever exceeding itself, and a scroll container that can never overflow is
     not a scroll container. The page scrolls instead — which works, because
     the artwork is `position: fixed` and so stays put while the form moves.
     Short viewports are handled by the card scrolling its own body; see §14b. */
}

/* --- The artwork ----------------------------------------------------------
   Fixed, not scrolled: the image is the backdrop of the screen rather than
   an element on it, so a tall form slides over a still frame instead of
   dragging a 1280×720 PNG up the viewport with it.

   Three layers, painted in this order: a scrim that goes from near-opaque on
   the form's side to clear on the artwork's, a vignette that closes the
   bottom, and the illustration. The scrim is what makes AA possible over an
   image whose local luminance we do not control. */
/* Deliberately **not** themed. The scrims are the dark ones in both schemes,
   because the thing behind them is a photograph lit against black — dropping a
   light scrim over it in light mode washed it to a grey smear, which is why
   the light theme silently lost the artwork. The form card stays themed and
   sits on top, so light mode is still light where the user is reading. */
.fn-login__aside {
  position: fixed;
  inset: 0;
  z-index: -1;
  background-image:
    linear-gradient(
      var(--fn-scrim-angle, 100deg),
      var(--fn-scrim-a) 0%,
      var(--fn-scrim-b) 42%,
      var(--fn-scrim-c) 78%),
    linear-gradient(to top, var(--fn-scrim-a) 0%, transparent 42%),
    var(--fn-art);
  /* Full-bleed `cover` is the **portrait / narrow** treatment: a tall source
     in a tall frame fits on its own, so the whole figure reads and the card
     floats over it. The wide-landscape rule below gives the artwork its own
     column instead — `contain` was tried across the board and left a dead
     band between the form and the image on desktop. */
  background-size: auto, auto, cover;
  /* 60%, not 50%. On a phone the frame is narrower than the scaled source, so
     the horizontal crop decides what is on screen — and a centred crop lands
     on the dark plate to the left of the figure, putting stray machinery
     around the card instead of the face. 60% centres the head. On wider
     frames the source is not cropped horizontally at all, so this is inert. */
  background-position: center, center, 60% center;
  background-color: var(--fn-scrim-base);
  background-repeat: no-repeat;
}
/* --- The form panel -------------------------------------------------------- */
.fn-login__panel {
  display: grid;
  align-content: center;
  /* `safe` so a form taller than the viewport overflows downward only; an
     unsafe centre puts the wordmark above the scroll origin, where it cannot
     be reached. Declared second so engines without it keep plain centring. */
  align-content: safe center;
  justify-items: center;
  padding: var(--fn-s6) var(--fn-s5);
  padding-block-start: calc(var(--fn-s6) + var(--fn-safe-top));
  padding-block-end: calc(var(--fn-s6) + var(--fn-safe-bottom));
  padding-inline-start: max(var(--fn-s5), var(--fn-safe-left));
  padding-inline-end: max(var(--fn-s5), var(--fn-safe-right));
}
/* The slab. Translucent over the artwork with a blur behind it, so the image
   reads as *behind* the form rather than as noise under the type — but at an
   opacity that already carries the contrast on its own, because a page whose
   legibility depends on `backdrop-filter` is a page that fails silently on
   the engines that lack it. */
.fn-login__card {
  inline-size: min(420px, 100%);
  /* Held in a variable because the sticky action bar has to bleed out to
     exactly this edge; two literals would drift the moment one is retuned. */
  --fn-login-pad: var(--fn-s6);
  padding: var(--fn-login-pad);
  display: grid;
  gap: var(--fn-s4);
  border-radius: var(--fn-r-panel);
  background: color-mix(in srgb, var(--fn-surface) 88%, transparent);
  -webkit-backdrop-filter: blur(28px) saturate(1.2);
  backdrop-filter: blur(28px) saturate(1.2);
  box-shadow: 0 0 0 1px var(--fn-login-edge), var(--fn-elev-4);
  animation: fn-modal-in var(--fn-dur-expo) var(--fn-ease-expo) both;
}

/* Left-aligned, not centred. A centred stack of mark/title/subtitle is the
   house style of every form template ever shipped; ranging it left gives the
   screen an edge to build the rest of the rhythm on. */
.fn-login__brand { display: grid; justify-items: start; gap: var(--fn-s1); }
/* The lockup's only ornament: a short lit rule, the accent's one appearance
   above the fold besides the button it belongs to. */
.fn-login__mark {
  inline-size: 34px;
  block-size: 3px;
  margin-block-end: var(--fn-s3);
  border-radius: var(--fn-r-pill);
  background: var(--fn-primary);
  box-shadow: 0 0 12px 0 color-mix(in srgb, var(--fn-primary) 60%, transparent);
}
.fn-login__wordmark {
  font-family: var(--fn-font-display);
  font-size: var(--fn-t-display);
  font-weight: 700;
  line-height: var(--fn-lh-display);
  letter-spacing: 0.01em;
}
/* The version rides the wordmark's baseline at body scale: part of the
   lockup, but never competing with the name. */
.fn-login__version {
  margin-inline-start: var(--fn-s2);
  font-family: var(--fn-font);
  font-size: var(--fn-t-sm);
  font-weight: 400;
  letter-spacing: 0.04em;
  color: var(--fn-fg-muted);
}
.fn-login__tagline {
  font-size: var(--fn-t-md);
  line-height: var(--fn-lh-body);
  color: var(--fn-fg-muted);
  max-inline-size: 34ch;
  text-wrap: pretty;
}


/* Language pills */
.fn-langs { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }
.fn-lang {
  padding: 3px var(--fn-s3);
  border-radius: var(--fn-r-pill);
  border: 1px solid var(--fn-border);
  background: var(--fn-surface-2);
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-xs);
  font-weight: 700;
  cursor: pointer;
  transition: background-color var(--fn-dur-fast) var(--fn-ease),
              color var(--fn-dur-fast) var(--fn-ease),
              transform var(--fn-dur-snappy) var(--fn-spring-snappy);
}
.fn-lang:hover { transform: scale(var(--fn-tap-hover)); }
.fn-lang:active { transform: scale(var(--fn-tap-press)); }
.fn-lang[aria-pressed="true"] {
  background: var(--fn-primary-soft);
  border-color: var(--fn-primary-line);
  color: var(--fn-primary-text);
}

/* Hero CTA — the one button on the screen that is a *proposition* rather
   than a control, so it gets two lines of type, a glyph, and an arrow that
   leans forward under the pointer. */
.fn-login__hero { display: grid; gap: var(--fn-s2); }
.fn-hero-btn.topcoat-button--large--cta,
.fn-hero-btn.topcoat-button--large {
  inline-size: 100%;
  min-block-size: 60px;
  display: grid;
  grid-template-columns: 22px minmax(0, 1fr) 16px;
  align-items: center;
  gap: var(--fn-s3);
  padding-inline: var(--fn-s4);
  text-align: start;
}
.fn-hero-btn__label { display: grid; gap: 1px; min-inline-size: 0; }
.fn-hero-btn__label b {
  font-size: var(--fn-t-md);
  font-weight: 700;
  letter-spacing: var(--fn-tr-tight);
}
.fn-hero-btn__label small { font-size: var(--fn-t-xs); font-weight: 600; opacity: 0.82; }
.fn-hero-btn::after {
  content: "";
  inline-size: 16px;
  block-size: 16px;
  background: currentColor;
  -webkit-mask: var(--fn-icon-arrow) center / contain no-repeat;
  mask: var(--fn-icon-arrow) center / contain no-repeat;
  opacity: 0.5;
  transition: transform var(--fn-dur-snappy) var(--fn-spring-snappy),
              opacity var(--fn-dur-fast) var(--fn-ease);
}
.fn-hero-btn:hover::after { transform: translateX(3px); opacity: 0.9; }

/* Tabs (Recovery phrase / Private key, and the emoticon categories).
   A segmented control: a recessed track with one raised cell in it. */
.fn-tabs {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  gap: 3px;
  padding: 3px;
  border-radius: calc(var(--fn-r-xs) + 3px);
  /* The track has to be *darker* than the cell that rides in it, in both
     themes. Reading `--fn-surface-3` here made the dark-mode track lighter
     than the selected cell, which inverted the whole control. */
  background: var(--fn-sunk-a);
  box-shadow: inset 0 1px 2px var(--fn-ink-key);
}
.fn-tab {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-block-size: 32px;
  padding: 0 var(--fn-s2);
  border: 0;
  border-radius: var(--fn-r-xs);
  background: transparent;
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-sm);
  font-weight: 600;
  letter-spacing: var(--fn-tr-label);
  cursor: pointer;
  transition: background-color var(--fn-dur) var(--fn-spring-snappy),
              color var(--fn-dur-fast) var(--fn-ease),
              box-shadow var(--fn-dur) var(--fn-spring-snappy);
}
.fn-tab:hover { color: var(--fn-fg); }
.fn-tab[aria-selected="true"] {
  background-color: var(--fn-raise-a);
  background-image: linear-gradient(180deg, var(--fn-raise-a), var(--fn-raise-b));
  color: var(--fn-fg);
  font-weight: 700;
  box-shadow: var(--fn-hi), var(--fn-elev-1);
}
.fn-tabpanel {
  display: grid;
  gap: var(--fn-s4);
  margin-block-start: var(--fn-s2);
  animation: fn-view-in var(--fn-dur-expo) var(--fn-ease-expo) both;
}
/* The submit button and the two lines that belong to it. On a tall viewport
   this is an ordinary last row; §14b pins it. */
.fn-login__actions { display: grid; gap: var(--fn-s2); }

/* --- Appearance and arrangement, on the sign-in screen itself -------------
   Settings is behind the sign-in, so without these the first screen anyone
   sees is the one screen they cannot adjust — including the person who wants
   the light theme and has been handed a dark one. Top-right and quiet: these
   are preferences, and nothing here should compete with "Sign in". */
.fn-login__prefs {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: var(--fn-s2);
  /* Pulled up into the card's own padding so the lockup still sits at the top
     of the card rather than being pushed down by a row of controls. */
  margin-block-start: calc(-1 * var(--fn-s2));
  margin-inline-end: calc(-1 * var(--fn-s1));
}

/* A segmented control: the same sunk track as `.fn-tabs`, at the smaller size
   a preference deserves. Kept separate from `.fn-tab` because that one carries
   `role="tab"` semantics and `aria-selected`; these are toggle buttons, and
   giving them tab styling would have meant borrowing the ARIA too. */
.fn-seg {
  display: inline-flex;
  gap: 2px;
  padding: 2px;
  border-radius: calc(var(--fn-r-xs) + 2px);
  background: var(--fn-sunk-a);
  box-shadow: inset 0 1px 2px var(--fn-ink-key);
}
/* Six languages do not fit on one line of a phone, and a segmented control
   that scrolls sideways hides the very option someone is hunting for. This
   variant wraps instead — the only place in the product where a `.fn-seg`
   is allowed to become two rows. */
.fn-seg--wrap { flex-wrap: wrap; }
.fn-seg--wrap .fn-seg__btn { flex: 0 0 auto; }
/* The Settings language row for the same reason: six endonyms in a `.fn-row`
   would push the label off a narrow screen. */
.fn-row--wrap { flex-wrap: wrap; row-gap: var(--fn-s1); justify-content: flex-end; }

.fn-seg__btn {
  display: inline-flex;
  align-items: center;
  gap: var(--fn-s1);
  min-block-size: 26px;
  padding: 0 var(--fn-s2);
  border: 0;
  border-radius: var(--fn-r-xs);
  background: transparent;
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-xs);
  font-weight: 600;
  letter-spacing: var(--fn-tr-label);
  cursor: pointer;
  transition: background-color var(--fn-dur) var(--fn-spring-snappy),
              color var(--fn-dur-fast) var(--fn-ease),
              box-shadow var(--fn-dur) var(--fn-spring-snappy);
}
.fn-seg__btn:hover { color: var(--fn-fg); }
.fn-seg__btn[aria-pressed="true"] {
  background-color: var(--fn-raise-a);
  background-image: linear-gradient(180deg, var(--fn-raise-a), var(--fn-raise-b));
  color: var(--fn-fg);
  box-shadow: var(--fn-hi), var(--fn-elev-1);
}
/* Under ~360px the four labels wrap to two rows and eat a third of the card.
   The glyphs carry the meaning at that size; the accessible name is on the
   group and the `title`, so nothing is lost to a screen reader. */
@media (max-width: 380px) {
  .fn-seg__btn > span { display: none; }
  .fn-seg__btn { padding: 0 var(--fn-s2); }
}

/* A secondary action under a field is an aside, not a second submit: it
   takes the width of its own label rather than the width of the form. */
.fn-login__card .fn-field > .topcoat-button { justify-self: start; }

/* Mnemonic textarea + reveal */
.fn-mnemonic { position: relative; }
.fn-mnemonic .topcoat-textarea {
  inline-size: 100%;
  min-block-size: 78px;
  /* The 76px of end padding this used to carry was reserving room for the
     reveal buttons back when they were absolutely positioned over the field.
     They have lived in the label row for a while; the reservation only
     survived to wrap a twelve-word phrase one word early. */
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-sm);
  line-height: 1.75;
  letter-spacing: 0.01em;
  resize: vertical;
}
.fn-mnemonic[data-masked="true"] .topcoat-textarea {
  -webkit-text-security: disc;
  text-security: disc;
}
/* Sits in the label row, after a `.fn-grow` label that pushes it to the end.
   Deliberately NOT absolutely positioned: there is no positioned ancestor in
   this markup, so `position: absolute` anchored these buttons to the viewport's
   top-right corner instead of the field. */
.fn-mnemonic__tools {
  display: flex;
  flex: none;
  gap: 2px;
}

/* Backup warning panel. The most consequential thirty seconds in the whole
   product happen inside this box, so it is built like a panel and not like a
   tinted paragraph: its own surface, an amber edge holding it, a rule under
   the reasons, and the action it gates sitting at the bottom of it. */
.fn-warnpanel {
  display: grid;
  gap: var(--fn-s3);
  padding: var(--fn-s4);
  border-radius: var(--fn-r-card);
  /* Neutral surface, one lit edge. Filling the panel with amber made the
     most important thirty seconds in the product look like a validation
     error, and put muted text on a colour it was never contrast-checked
     against. The edge carries the warning; the type carries the message. */
  background: var(--fn-surface-3);
  box-shadow: inset 3px 0 0 0 var(--fn-crown), var(--fn-elev-2);
  font-size: var(--fn-t-sm);
  line-height: var(--fn-lh-body);
  animation: fn-list-in var(--fn-dur-soft) var(--fn-spring-soft) both;
}
.fn-warnpanel__title {
  font-size: var(--fn-t-md);
  font-weight: 700;
  letter-spacing: var(--fn-tr-tight);
  color: var(--fn-crown-text);
}
.fn-warnpanel ul {
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--fn-s2);
  list-style: none;
}
/* Custom markers: a disc bullet in a panel whose whole point is gravity is a
   detail that says nobody looked. These are small amber diamonds, set on the
   first line's optical centre rather than its baseline. */
.fn-warnpanel li {
  display: grid;
  grid-template-columns: 10px minmax(0, 1fr);
  gap: var(--fn-s2);
  align-items: start;
}
.fn-warnpanel li::before {
  content: "";
  inline-size: 6px;
  block-size: 6px;
  margin-block-start: 7px;
  margin-inline-start: 2px;
  rotate: 45deg;
  border-radius: 1px;
  background: var(--fn-crown);
}
.fn-warnpanel > .fn-addr,
.fn-warnpanel > p > .fn-addr {
  font-size: var(--fn-t-xs);
}
.fn-warnpanel > .fn-row {
  gap: var(--fn-s2);
  padding-block-start: var(--fn-s3);
  border-block-start: 1px solid var(--fn-hair);
}
/* Copy and Download are one decision offered two ways; equal width says so. */
.fn-warnpanel > .fn-row > .topcoat-button { flex: 1 1 0; min-inline-size: 0; }

/* Stepper (BIP-44 wallet index). One control in three parts: the ends are
   square-ish where they meet the field, so the group reads as a unit. */
.fn-stepper { display: grid; grid-template-columns: 40px minmax(0, 1fr) 40px; gap: 6px; }
.fn-stepper .topcoat-button {
  padding-inline: 0;
  min-block-size: 40px;
  font-size: var(--fn-t-lg);
  font-weight: 600;
}
.fn-stepper .topcoat-text-input {
  text-align: center;
  font-family: var(--fn-font-mono);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
}

.fn-login__error {
  padding: var(--fn-s3) var(--fn-s4);
  border: 1px solid color-mix(in srgb, var(--fn-danger) 55%, transparent);
  border-inline-start: 3px solid var(--fn-danger);
  border-radius: var(--fn-r-card);
  background: var(--fn-danger-soft);
  color: var(--fn-danger-text);
  font-size: var(--fn-t-sm);
  font-weight: 600;
  line-height: var(--fn-lh-body);
  text-wrap: pretty;
  animation: fn-shake 420ms var(--fn-ease-cosine) both;
}

/* Wide viewports: the artwork's subject sits centre-right and its left third
   was drawn empty for exactly this, so the slab ranges left into that third
   rather than sitting on top of the constellation. The scrim is angled the
   same way, so the two agree. */
@media (min-width: 1000px) and (min-height: 620px) {
  .fn-login__panel {
    justify-items: start;
    padding-inline-start: clamp(var(--fn-s8), 7vw, 140px);
  }
  .fn-login__card {
    inline-size: min(430px, 100%);
    padding: var(--fn-login-pad) var(--fn-login-pad) var(--fn-s5);
  }

  /* Split screen: the form owns the left, the artwork owns the right
     *outright*. Insetting the fixed backdrop rather than making it a grid
     column keeps it pinned while the form scrolls, which is the whole reason
     it was fixed to begin with.

     `cover` inside that column is what makes the face big — the column is
     roughly the source's own aspect ratio, so almost nothing is cropped and
     nothing is letterboxed. The heavy three-stop scrim is dropped here: it
     exists to buy the form contrast when the form sits *on* the artwork, and
     side by side that is no longer true. All that is left is a short fade on
     the inner edge so the panel dissolves into the page instead of butting
     against it with a hard seam. */
  .fn-login__aside {
    inset-inline-start: 42%;
    background-image:
      linear-gradient(90deg, var(--fn-bg) 0%, transparent 14%),
      var(--fn-art);
    background-size: auto, cover;
    /* Biased up: the head is in the source's top two-thirds, so a centred
       crop trims the crown and keeps shoulder that carries nothing. */
    background-position: center, 50% 18%;
  }
}

/* Phone landscape and other short-but-wide windows. Too narrow for the split
   above (which needs 1000px), too short to show a centred portrait — so the
   artwork slides to the outer edge and the scrim carries the rest. Without
   this the face lands dead behind the card at exactly the height where the
   card is already taking the whole screen. */
@media (orientation: landscape) and (max-height: 619px) {
  /* The same split as desktop, just tighter. A phone held sideways is still a
     landscape frame — centring the card here put it directly over the face and
     left only an ear and some circuitry visible down the right. */
  .fn-login__panel {
    justify-items: start;
    padding-inline-start: max(var(--fn-s5), var(--fn-safe-left));
  }
  .fn-login__aside {
    inset-inline-start: 58%;
    background-image:
      linear-gradient(90deg, var(--fn-bg) 0%, transparent 16%),
      var(--fn-art);
    background-size: auto, cover;
    background-position: center, 50% 12%;
  }
}

/* Phone portrait: stack instead of layer. The card is full-width at this size,
   so a full-bleed backdrop is a backdrop nobody can see — the artwork was
   there the whole time and only ever showed a sliver of shoulder down the
   side. Here it becomes a banner the form sits beneath: `absolute`, not
   `fixed`, so it scrolls away with the page instead of the form sliding under
   a pinned image. */
@media (max-width: 700px) and (orientation: portrait) {
  .fn-login__aside {
    position: absolute;
    inset: 0 0 auto 0;
    block-size: 46vh;
    /* Only a bottom fade into the page. The three-stop scrim is angled for a
       side-by-side layout and would darken the banner's left for no reason. */
    background-image:
      linear-gradient(to top, var(--fn-bg) 3%, transparent 62%),
      var(--fn-art);
    background-size: auto, cover;
    background-position: center, 58% 18%;
  }
  .fn-login__panel {
    /* Top-aligned, and started just inside the banner's fade so the card's
       edge lands in the gradient rather than on a hard line under it. */
    align-content: safe start;
    padding-block-start: calc(37vh + var(--fn-safe-top));
  }
}


/* --- §12b. Pinned layouts -------------------------------------------------
   `data-layout` on `.fn-login` overrides everything above it. The breakpoints
   are a guess about the *window*; this is the person in front of it saying
   which arrangement they want, so it has to win at every width.

   It does, without `!important` and without ordering games: every rule here is
   qualified by an attribute selector on the ancestor, which outweighs the bare
   class selectors the media queries use. Media queries add no specificity of
   their own — the only reason those rules apply at all is that nothing more
   specific contradicted them. */

/* Vertical — artwork above, form below, at any width. The phone-portrait
   treatment, generalised: `absolute` rather than `fixed`, so the banner
   scrolls away with the page instead of the form sliding under a pinned
   image. */
.fn-login[data-layout="vertical"] .fn-login__aside {
  position: absolute;
  inset: 0 0 auto 0;
  inset-inline-start: 0;
  block-size: min(46vh, 420px);
  /* One bottom fade into the page. The angled three-stop scrim above is aimed
     at a side-by-side composition and would darken this banner's left edge for
     no reason at all. */
  background-image:
    linear-gradient(to top, var(--fn-bg) 3%, transparent 62%),
    var(--fn-art);
  background-size: auto, cover;
  background-position: center, 58% 18%;
}
.fn-login[data-layout="vertical"] .fn-login__panel {
  align-content: safe start;
  justify-items: center;
  padding-block-start: calc(min(37vh, 340px) + var(--fn-safe-top));
  padding-inline-start: max(var(--fn-s5), var(--fn-safe-left));
}

/* Horizontal — form on the left, artwork owning the right, at any width. On a
   narrow window that leaves the form a tight column, which is the trade the
   user asked for by pinning it; the card's own `min()` width keeps it from
   collapsing entirely. */
.fn-login[data-layout="horizontal"] .fn-login__aside {
  position: fixed;
  inset: 0;
  inset-inline-start: 42%;
  block-size: auto;
  background-image:
    linear-gradient(90deg, var(--fn-bg) 0%, transparent 14%),
    var(--fn-art);
  background-size: auto, cover;
  background-position: center, 50% 18%;
}
.fn-login[data-layout="horizontal"] .fn-login__panel {
  align-content: safe center;
  justify-items: start;
  padding-block-start: calc(var(--fn-s6) + var(--fn-safe-top));
  padding-inline-start: max(clamp(var(--fn-s5), 6vw, 140px), var(--fn-safe-left));
}
.fn-login[data-layout="horizontal"] .fn-login__card {
  /* The artwork column leaves ~38% of the width, so the card tracks that up to
     its usual 430px — but never below 260px, or a pinned split on a phone
     would produce a column too narrow to type a room name into. Below that
     floor the card simply overlaps the artwork, which is the original
     full-bleed composition and still perfectly legible: the slab is opaque
     enough to carry its own contrast. */
  inline-size: min(100%, clamp(260px, 38vw, 430px));
}

/* --- §14b. Short viewports ------------------------------------------------
   The sign-in card is about 770px tall at default sizing, so any viewport
   shorter than that opens with "Sign in" below the fold. The page does scroll
   — but a submit button that is not on screen when the screen loads reads as
   broken, and the fixed artwork removes the usual cue that there is more.

   Compaction alone cannot close the gap. With the lockup shrunk and every gap
   at its tightest the fields still want ~500px, which is more than a laptop at
   a high zoom level has. So instead: the card takes the viewport, its body
   scrolls, and the action bar stays pinned to the bottom of the card. The
   button is then on screen at every scroll position, at any height.

   Keyed on height alone. The previous rules here were gated on
   `(orientation: landscape) and (max-height: 500px)` — a phone-shaped test for
   what is really just "is there room". An 823x514 window is neither a phone
   nor tall enough, and fell through the gap between the two. */
@media (max-height: 820px) {
  /* The panel is the scroll container, not the card inside it.

     Scrolling the card was tried first and it works, but it turns the artwork
     either side of a 420px card into a dead zone: a wheel over the backdrop —
     more than half the width of a short, wide window — moves nothing, which is
     the same "the page is stuck" complaint in a new place. The panel is full
     bleed, so every pixel of the screen scrolls.

     Scrolling only the *fields* was the other option, and it wastes the
     screen: the lockup, the create-wallet button, the rule and the tabs come
     to ~55% of the card and none of them has to be permanently on show. */
  .fn-login__panel {
    block-size: 100dvh;
    overflow-y: auto;
    overscroll-behavior: contain;
    padding-block-start: calc(var(--fn-s4) + var(--fn-safe-top));
    padding-block-end: calc(var(--fn-s4) + var(--fn-safe-bottom));
  }

  .fn-login__card { --fn-login-pad: var(--fn-s5); gap: var(--fn-s3); }
  .fn-login__wordmark { font-size: var(--fn-t-xl); }
  .fn-login__mark { margin-block-end: var(--fn-s2); }
  .fn-tabpanel { gap: var(--fn-s3); }

  /* Sticky against the panel's scrollport, so the button holds at the bottom
     of the screen while the form runs behind it. It stops where its containing
     block does — the last row of the field panel — so at the end of the scroll
     the card's own bottom padding closes underneath it rather than the bar
     hanging over the card's rounded corners. */
  .fn-login__actions {
    position: sticky;
    inset-block-end: 0;
    /* Opaque. The card's translucency is there to show the artwork behind the
       card, not to show form fields sliding behind the submit button. */
    background: var(--fn-surface);
    /* Out to the card's padding edge, so scrolled text does not run past the
       bar in the gutters beside it. */
    margin-inline: calc(-1 * var(--fn-login-pad));
    padding: var(--fn-s3) var(--fn-login-pad) var(--fn-s2);
    box-shadow: 0 -1px 0 0 var(--fn-border);
  }
}

/* ==========================================================================
   13. NOT FOUND
   ========================================================================== */

.fn-404 {
  min-block-size: 100dvh;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: var(--fn-s3);
  padding: var(--fn-s6);
  text-align: center;
  background: var(--fn-bg);
}
.fn-404__code {
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-2xl);
  font-weight: 700;
  color: var(--fn-primary-text);
  letter-spacing: 0.08em;
  font-variant-numeric: tabular-nums;
}


/* ==========================================================================
   14. MOTION SYSTEM
   Ported from the React client's `src/lib/motion.ts`; see docs/MOTION.md
   for how each Framer spring was reduced to a CSS curve.

   Everything here is disabled wholesale in §17 under
   `prefers-reduced-motion: reduce`, including the stagger delays — a
   zero-duration animation with a 320ms delay is still a 320ms blank row.
   ========================================================================== */

/* listItem — hidden { opacity: 0, y: 12, scale: 0.98 } → springSoft. */
@keyframes fn-list-in {
  from { opacity: 0; transform: translateY(12px) scale(0.98); }
  to   { opacity: 1; transform: none; }
}

/* bubbleVariants — hidden { opacity: 0, y: 26 } → spring 380/26.
   The 6% overshoot lives in `--fn-spring-bubble`, not in the keyframes. */

/* --- Message arrival and disintegration ---------------------------------
   Both effects are pure CSS on the compositor. The alternative considered was
   three.js: it was rejected because this app must run on an air-gapped LAN
   (nothing is fetched from a CDN — see index.html), the wasm bundle is already
   the largest thing on the wire, and a WebGL context plus a JS-interop bridge
   is a great deal of machinery for an effect that transform and opacity render
   for free. The boot sequence sets the precedent: its spark burst is eighteen
   elements placed by index, no renderer involved. */

/* ARRIVAL. The newest message gets a single pass of light across it, as if the
   text were being written by the beam rather than pasted in. `:last-child` is
   doing real work here: a CSS animation runs on mount, so the message that
   arrives *becomes* the last child and plays it, while the one it displaced
   has already finished. No state, no bookkeeping, and history that loads forty
   rows at once stays quiet because only the final row matches. */
.fn-msg:last-child .fn-bubble {
  position: relative;
  overflow: hidden;
  /* The bloom (reference `bubbleBloomVariants`, ported in MOTION.md §7): the
     newest bubble springs from its tail corner with a jelly squash-and-
     stretch — 14% over, 8% under, 5% over, settle — sharpening from a blur
     as it lands. A damped exponential by construction: each swing is ~60% of
     the last. Only ever the newest row: when the next message displaces this
     one the rule stops matching and the animation is *removed*, not
     restarted, so history stays still. */
  transform-origin: 0% 0%;
  /* The charge rides the bloom: see THE LOCK-ON below for why it is stepped.
     Both are inside the 500ms the arrival is budgeted, along with the scan and
     the arc below — the longest of the four is what "a message has landed"
     costs, so they are capped together rather than each on its own merits. */
  animation:
    fn-bubble-bloom 440ms var(--fn-spring-bouncy) both,
    fn-bubble-charge 380ms steps(1, end) both;
}
.fn-msg--own:last-child .fn-bubble { transform-origin: 100% 0%; }
/* A just-sent pending bubble is the last child and blooms like any arrival —
   but it must keep breathing afterwards, so all three animations ride together. */
.fn-msg--pending:last-child .fn-bubble {
  animation:
    fn-bubble-bloom 440ms var(--fn-spring-bouncy) both,
    fn-bubble-charge 380ms steps(1, end) both,
    fn-breathe 1.6s var(--fn-ease-cosine) 440ms infinite;
}
@keyframes fn-bubble-bloom {
  0%   { opacity: 0; transform: scale(0.2) translateY(8px); filter: blur(5px); }
  12%  { opacity: 1; }
  38%  { transform: scale(1.14) translateY(-2px); filter: blur(0); }
  62%  { transform: scale(0.92) translateY(1px); }
  82%  { transform: scale(1.05) translateY(0); }
  100% { opacity: 1; transform: none; filter: none; }
}
/* THE BEAM. A wide soft wash reads as polish; a hard-edged line with a white
   core reads as a machine writing the row. The gradient is mostly transparent
   with one bright seam so what crosses the bubble is a *beam*, not a sheen. */
.fn-msg:last-child .fn-bubble::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(
    100deg,
    transparent 34%,
    color-mix(in srgb, var(--fn-primary) 30%, transparent) 44%,
    #fff 49%,
    color-mix(in srgb, var(--fn-primary) 85%, transparent) 52%,
    transparent 62%
  );
  transform: translateX(-130%);
  /* 410 + 90 = 500, the arrival budget. The beam was the longest thing in the
     set at 650ms and therefore the thing that decided when a message had
     finished arriving; it is now level with the arc, which is also 500. */
  animation: fn-bubble-scan 410ms var(--fn-ease-expo) 90ms both;
}
@keyframes fn-bubble-scan {
  to { transform: translateX(130%); }
}

/* THE LOCK-ON. A stepped border flash, declared on the bloom rule above:
   three discrete states and no easing, because a machine confirming something
   does not fade in. Two beats of glow with a dropout between them is the
   cheapest thing that reads as electrical rather than merely lit. */
@keyframes fn-bubble-charge {
  0%   { box-shadow: 0 0 0 1px var(--fn-primary), 0 0 18px 2px color-mix(in srgb, var(--fn-primary) 60%, transparent); }
  45%  { box-shadow: 0 0 0 1px color-mix(in srgb, var(--fn-primary) 30%, transparent), 0 0 10px 0 color-mix(in srgb, var(--fn-primary) 30%, transparent); }
  70%  { box-shadow: 0 0 0 1px var(--fn-primary), 0 0 14px 1px color-mix(in srgb, var(--fn-primary) 45%, transparent); }
  100% { box-shadow: none; }
}

/* THE ARC. Four sparks flung off the tail corner as the row lands, drawn with
   box-shadows on one node — the same trick the disintegration uses, and for the
   same reason: eighteen elements is a lot of DOM for 400ms of light. Sits on
   the row, outside the bubble's clip. */
.fn-msg:last-child::before {
  content: "";
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  inline-size: 2px;
  block-size: 2px;
  border-radius: 50%;
  pointer-events: none;
  background: #fff;
  box-shadow:
    0 0 5px 1px var(--fn-primary),
    14px -9px 0 0 var(--fn-primary),
    -11px 6px 0 0 var(--fn-primary),
    22px 8px 0 -1px var(--fn-primary),
    -18px -7px 0 -1px var(--fn-primary);
  animation: fn-msg-arc 440ms var(--fn-ease-expo) 60ms both;
}
.fn-msg--own:last-child::before { inset-inline: auto 0; }
/* A row cannot be arriving and being destroyed at once; the termination wins. */
.fn-msg--dissolving::before { display: none; }
@keyframes fn-msg-arc {
  0%   { opacity: 0; transform: scale(0.3); }
  25%  { opacity: 1; transform: scale(1.1); }
  100% { opacity: 0; transform: scale(2.4); }
}

/* DISINTEGRATION. Marked by the store the moment delete is confirmed
   (`state.rs::Dissolve`), so it plays over the network round trip rather than
   after it, and the row is already gone by the time `/sync` removes it.

   The bubble collapses to a bright horizontal line and snaps out — the
   silhouette of something being unmade rather than merely hidden. `blur` on
   the way out keeps the text from staying legible while the box shrinks,
   which is what would otherwise read as a squash rather than a vanishing.

   260ms, and `delete_message.rs::DISSOLVE_MS` has to be the same number: the
   dialog sleeps that long before it calls the server, so a duration here that
   the constant does not know about is either an effect cut off mid-collapse or
   a row sitting invisible-but-present after it has finished. The pair is the
   back half of the 500ms a deletion is allowed; PROC_MS is the front half. */
.fn-msg--dissolving {
  pointer-events: none;
  animation: fn-msg-dissolve 260ms var(--fn-ease-exit) both;
}
.fn-msg--dissolving .fn-bubble {
  animation: fn-bubble-dissolve 260ms var(--fn-ease-exit) both;
}
/* The debris: one element, many box-shadows, thrown outward as the bubble goes.
   Cheaper than eighteen nodes for something on screen for a quarter of a
   second, and it composites as a single layer.

   Cast in the termination tone, not the system tone. The old ash was cyan,
   which made deleting look like the app doing something routine; this is the
   colour of the thing being destroyed. Shadows are asymmetric on purpose — an
   even ring reads as a decoration, a lopsided scatter reads as a blast. */
.fn-msg--dissolving .fn-bubble::before {
  --term: light-dark(hsl(14 92% 52%), hsl(10 100% 62%));
  content: "";
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  inline-size: 3px;
  block-size: 3px;
  border-radius: 50%;
  background: #fff;
  box-shadow:
    0 0 8px 2px var(--term),
    31px -17px 0 -1px var(--term),
    -38px -6px 0 -1px var(--term),
    57px 12px 0 -1px var(--term),
    -66px 15px 0 -1px var(--term),
    20px 25px 0 -1px var(--term),
    -25px 29px 0 -1px var(--term),
    44px -28px 0 -2px var(--term),
    -49px -22px 0 -2px var(--term);
  animation: fn-debris 260ms var(--fn-ease-expo) both;
}
@keyframes fn-msg-dissolve {
  to { opacity: 0; }
}
/* OVERLOAD, then collapse. The old curve went straight to a line, which read
   as a window closing. This one flares first — the row is driven past full
   brightness in a stepped stutter, as if too much current arrived — and only
   then snaps to a line and out. The hue rotate pushes the flare toward the
   termination tone without needing a second element to tint it. */
@keyframes fn-bubble-dissolve {
  0%   { transform: none; filter: none; }
  14%  { transform: scaleX(1.04) scaleY(1.03); filter: brightness(2.4) saturate(1.6) hue-rotate(-38deg); }
  24%  { transform: scaleX(0.99) scaleY(1.01); filter: brightness(1) saturate(1); }
  34%  { transform: scaleX(1.05) scaleY(1.02); filter: brightness(3.1) saturate(1.8) hue-rotate(-46deg); }
  58%  { transform: scaleY(0.42) scaleX(1.03); filter: blur(1px) brightness(2.2) hue-rotate(-30deg); }
  76%  { transform: scaleY(0.05) scaleX(0.97); filter: blur(0) brightness(3.4); opacity: 1; }
  100% { transform: scaleY(0.02) scaleX(0.5); filter: brightness(4); opacity: 0; }
}
@keyframes fn-debris {
  0%   { opacity: 0; transform: scale(0.2); }
  30%  { opacity: 1; transform: scale(1.1); }
  55%  { opacity: 1; }
  100% { opacity: 0; transform: scale(3.1); }
}

/* --- The particle layer (burst.rs; reference FruitBurst.tsx) -------------
   One fixed layer above everything, mounted once in app.rs. Each particle is
   a single span; Rust computes the endpoints into custom properties and this
   one keyframe set flies them all: out to (--dx, --dy) by 55%, then drifting
   a further 15% while falling by (--fall) and fading — the reference's
   three-point path [origin, apex, rest], verbatim. */
.fn-burstlayer {
  position: fixed;
  inset: 0;
  z-index: 130; /* above the modal backdrop (100): a poof fired from a
                   confirm dialog must not play underneath it */
  pointer-events: none;
}
/* Two tones, and they mean opposite things. Cyan is the system running: a
   message left the machine. Red is the system destroying something. A spark is
   hot at the core and coloured at the edge — real ones are white where the
   current is, which is what keeps these from reading as coloured dust. */
.fn-burst--live { --spark: var(--fn-primary); }
.fn-burst--term { --spark: light-dark(hsl(14 92% 52%), hsl(10 100% 62%)); }

/* `absolute`, not `fixed`: burst.rs converts viewport coordinates into this
   layer's own box before placing anything, which is what stops the effect
   landing in the wrong place on iOS when the keyboard is up. See
   `burst.rs::to_layer` — the two must stay in agreement. */
.fn-burst__p {
  position: absolute;
  will-change: transform, opacity;
  animation-timing-function: var(--fn-ease-expo);
  animation-fill-mode: both;
}

/* DEBRIS. A glyph tumbling on its own axis. */
.fn-burst__p--glyph {
  margin: -11px 0 0 -11px;
  font-size: 20px;
  line-height: 1;
  user-select: none;
  color: var(--spark);
  text-shadow:
    0 0 4px #fff,
    0 0 12px var(--spark);
  animation-name: fn-burst-p;
}

/* CURRENT. A hot line laid along its own path: white at the head where the
   charge is, falling off to the tone behind it. `--a` comes from Rust as the
   velocity angle, so the streak always points where it is going. Scaling only
   on X stretches it as it flies without fattening it — a spark thins out, it
   does not bloat. */
.fn-burst__p--streak {
  margin: -1px 0 0 -2px;
  inline-size: 17px;
  block-size: 2px;
  border-radius: 2px;
  transform-origin: 2px 50%;
  background: linear-gradient(
    90deg,
    transparent 0%,
    var(--spark) 30%,
    #fff 88%
  );
  box-shadow: 0 0 7px 0 var(--spark);
  animation-name: fn-burst-streak;
}

@keyframes fn-burst-p {
  0% {
    opacity: 1;
    transform: translate(0, 0) scale(0.2) rotate(0deg);
  }
  55% {
    opacity: 1;
    transform: translate(var(--dx), var(--dy)) scale(var(--s)) rotate(var(--r1));
  }
  100% {
    opacity: 0;
    transform: translate(calc(var(--dx) * 1.15), calc(var(--dy) + var(--fall)))
               scale(calc(var(--s) * 0.5)) rotate(var(--r2));
  }
}
/* Same three-point path as the debris, but the rotation is fixed to the
   heading and the stretch is a whip: long while it is moving fast, collapsing
   to nothing as it dies. */
@keyframes fn-burst-streak {
  0% {
    opacity: 1;
    transform: translate(0, 0) rotate(var(--a)) scaleX(0.3) scaleY(0.8);
  }
  18% {
    opacity: 1;
    transform: translate(calc(var(--dx) * 0.4), calc(var(--dy) * 0.4))
               rotate(var(--a)) scaleX(calc(var(--s) * 1.9)) scaleY(var(--s));
  }
  55% {
    opacity: 1;
    transform: translate(var(--dx), var(--dy))
               rotate(var(--a)) scaleX(var(--s)) scaleY(calc(var(--s) * 0.8));
  }
  100% {
    opacity: 0;
    transform: translate(calc(var(--dx) * 1.15), calc(var(--dy) + var(--fall)))
               rotate(calc(var(--a) + 40deg)) scaleX(0.15) scaleY(0.4);
  }
}

/* THE DISCHARGE. One ring at the origin. Without it the particles appear out of
   nothing; with it they are visibly thrown by something. Two stepped flickers
   on the way out — a clean fade reads as a glow, a stutter reads as arcing. */
.fn-burst__flash {
  position: absolute;
  margin: -3px 0 0 -3px;
  inline-size: 6px;
  block-size: 6px;
  border-radius: 50%;
  background: #fff;
  box-shadow:
    0 0 10px 3px var(--spark),
    0 0 26px 8px color-mix(in srgb, var(--spark) 45%, transparent);
  will-change: transform, opacity;
  animation: fn-burst-flash 300ms var(--fn-ease-expo) both;
}
@keyframes fn-burst-flash {
  0%   { opacity: 0;   transform: scale(0.2); }
  10%  { opacity: 1;   transform: scale(1.5); }
  35%  { opacity: 0.5; transform: scale(2.6); }
  55%  { opacity: 0.9; transform: scale(3.4); }
  100% { opacity: 0;   transform: scale(5.2); }
}

/* --- The processing readout (burst.rs::proc_hud) -------------------------
   A beat of the machine thinking before it acts, then the particles. Centred
   on the layer rather than pinned to whatever fired it: it is the system's own
   display, it must not be clipped by a corner, and at this size it would cover
   the thing it is talking about.

   Everything here is transform and opacity on a handful of nodes. The rings
   counter-rotate at different rates, which is what keeps a circle from reading
   as a loading spinner — a reticle *tracks* something, it does not just spin.

   Durations are locked to burst.rs::PROC_MS, which is 240ms. It was 3000ms,
   and the whole assembly was drawn for that: the *loops* below are the part
   that does not survive a 12× cut on its own, because a period is not a
   duration. A 2.4s ring turns 29° in 240ms and reads as a frozen graphic, not
   a reticle. So every loop here is a ratio of PROC_MS rather than a wall time —
   each one completes between a third of a turn and one and a half turns while
   the readout is up, which is what makes it look alive at this length. */
.fn-proc {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  transform: translate(-50%, -50%);
  display: grid;
  justify-items: center;
  gap: var(--fn-s3);
  pointer-events: none;
  animation: fn-proc-in 240ms linear both;
}
/* The whole assembly arrives fast, holds, and implodes into the burst that
   follows it — the last stretch is the handoff, so the particles appear to
   come out of the collapse rather than after it.

   The percentages widened when the run shortened. At 3000ms the arrival was 5%
   and the collapse 8%, which bought 150ms and 240ms of real time; the same
   percentages of 240ms are 12ms and 19ms, and a 132px reticle that appears in
   twelve milliseconds does not arrive, it just *is* there — one frame of
   nothing, then a full-size graphic. 15/78 restores roughly a fifth of the run
   to each end: 36ms in, 53ms out, which are visible motions again. */
@keyframes fn-proc-in {
  0%   { opacity: 0; transform: translate(-50%, -50%) scale(0.82); }
  15%  { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  78%  { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(0.55); }
}

.fn-proc__rings {
  position: relative;
  inline-size: 132px;
  block-size: 132px;
  display: grid;
  place-items: center;
}
.fn-proc__rings > * { position: absolute; grid-area: 1 / 1; }

.fn-proc__ring {
  border-radius: 50%;
  border: 1px solid transparent;
  will-change: transform;
}
/* Dashed via a conic mask would cost a repaint per frame; four border colours
   with two set to transparent gives the same broken-arc reticle for free. */
/* Periods are ~1.25×, ~0.83× and ~3.1× of PROC_MS, holding the 2.4 : 1.6 : 6
   ratio the three rings had at three seconds. Keeping the ratio is what keeps
   them from ever looking synchronised, which is the entire reason there are
   three of them. */
.fn-proc__ring--a {
  inline-size: 132px;
  block-size: 132px;
  border-block-color: var(--spark);
  animation: fn-proc-spin 300ms linear infinite;
}
.fn-proc__ring--b {
  inline-size: 102px;
  block-size: 102px;
  border-inline-color: color-mix(in srgb, var(--spark) 75%, transparent);
  animation: fn-proc-spin 200ms linear infinite reverse;
}
.fn-proc__ring--c {
  inline-size: 72px;
  block-size: 72px;
  border: 1px dashed color-mix(in srgb, var(--spark) 45%, transparent);
  animation: fn-proc-spin 750ms linear infinite;
}
@keyframes fn-proc-spin { to { transform: rotate(1turn); } }

/* The core: stepped, not eased. A machine's indicator does not breathe. */
.fn-proc__core {
  inline-size: 15px;
  block-size: 15px;
  border-radius: 50%;
  background: #fff;
  box-shadow:
    0 0 12px 4px var(--spark),
    0 0 34px 12px color-mix(in srgb, var(--spark) 40%, transparent);
  animation: fn-proc-core 160ms steps(2, end) infinite;
}
@keyframes fn-proc-core {
  0%   { opacity: 1;    transform: scale(1); }
  50%  { opacity: 0.45; transform: scale(0.82); }
  100% { opacity: 1;    transform: scale(1); }
}

/* One bright radius sweeping the dish — the thing that makes it read as a
   scan rather than a badge. */
.fn-proc__sweep {
  inline-size: 132px;
  block-size: 132px;
  border-radius: 50%;
  background: conic-gradient(
    from 0deg,
    color-mix(in srgb, var(--spark) 55%, transparent) 0deg,
    transparent 46deg,
    transparent 360deg
  );
  mask: radial-gradient(closest-side, transparent 32%, #000 36%, #000 99%, transparent);
  animation: fn-proc-spin 360ms linear infinite;
}

/* THE READOUT. One label, up for the whole of PROC_MS.

   It was three, each shown during its own second by a delay of `--i` seconds,
   stepped so the text switched like a machine's display instead of crossfading
   like a slideshow. At 240ms that schedule gives each label 80ms — under six
   frames — so the three did not read as a sequence, they read as one flickering
   line. The scheduling went with them: nothing is being scheduled any more, so
   there is no delay, no `steps()`, and no keyframes. The element is simply
   visible, and the parent's `fn-proc-in` fades the assembly in and out around
   it.

   `position: absolute` inside a sized box stays, even for a single label: it is
   what stops the readout from resizing the grid and shifting the rings and the
   bar when a longer phase name is used. `min-inline-size` is the same
   guarantee on the other axis — the widest label in burst.rs is 14 characters
   ("PURGING RECORD"), and 15ch leaves it a hair of room. */
.fn-proc__readout {
  position: relative;
  block-size: 1.1em;
  min-inline-size: 15ch;
  text-align: center;
}
.fn-proc__phase {
  position: absolute;
  inset-inline: 0;
  font: 600 var(--fn-fs-xs) / 1.1 var(--fn-font-mono, ui-monospace, monospace);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--spark);
  text-shadow: 0 0 10px color-mix(in srgb, var(--spark) 60%, transparent);
}

.fn-proc__bar {
  inline-size: 168px;
  block-size: 2px;
  border-radius: 2px;
  background: color-mix(in srgb, var(--spark) 18%, transparent);
  overflow: hidden;
}
.fn-proc__bar > i {
  display: block;
  block-size: 100%;
  inline-size: 100%;
  transform-origin: 0 50%;
  background: linear-gradient(90deg, transparent, var(--spark) 60%, #fff);
  box-shadow: 0 0 8px 0 var(--spark);
  /* Linear on purpose: a progress bar that eases is lying about the work. The
     work is now 240ms of it, and the bar still has to be the honest length —
     it is the one element here that claims to measure something. */
  animation: fn-proc-fill 240ms linear both;
}
@keyframes fn-proc-fill {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

/* --- Transaction relay HUD (burst.rs tx_start/tx_phase/tx_end) -------------
   The same reticle, held for the life of a transaction instead of three
   seconds, with a phase roll-call the Rust side advances and a verdict
   flash at the end. Rides the burst layer, so it never blocks input. */
.fn-txhud {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: color-mix(in srgb, var(--fn-bg) 55%, transparent);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  animation: fn-txhud-in var(--fn-dur-expo) var(--fn-ease-expo) both;
}
@keyframes fn-txhud-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}
.fn-txhud__frame {
  display: grid;
  justify-items: center;
  gap: var(--fn-s3);
  padding: var(--fn-s5) var(--fn-s6);
  border-radius: var(--fn-r-card);
  background: color-mix(in srgb, var(--fn-surface) 82%, transparent);
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--spark) 35%, transparent),
    0 0 42px 0 color-mix(in srgb, var(--spark) 22%, transparent),
    var(--fn-sh-2);
  animation: fn-txhud-frame var(--fn-dur-bouncy) var(--fn-spring-soft) both;
}
@keyframes fn-txhud-frame {
  from { transform: scale(0.88); }
  to   { transform: scale(1); }
}
.fn-txhud__title {
  font-family: var(--fn-font-display);
  font-size: var(--fn-t-xs);
  letter-spacing: var(--fn-tr-caps);
  color: var(--spark);
}
.fn-txhud__phases {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 4px;
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-xs);
  letter-spacing: 0.06em;
}
.fn-txhud__phase {
  display: flex;
  align-items: center;
  gap: 8px;
  color: var(--fn-fg-faint);
  transition: color var(--fn-dur-fast) var(--fn-ease);
}
.fn-txhud__phase[data-state="active"] {
  color: var(--spark);
  animation: fn-txhud-blink 900ms steps(2, end) infinite;
}
.fn-txhud__phase[data-state="done"] { color: var(--fn-fg-muted); }
@keyframes fn-txhud-blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.55; }
}
.fn-txhud__tick {
  inline-size: 7px;
  block-size: 7px;
  border-radius: 1px;
  background: currentColor;
}
.fn-txhud__phase[data-state="pending"] .fn-txhud__tick {
  background: transparent;
  box-shadow: inset 0 0 0 1px currentColor;
}
.fn-txhud__verdict {
  font-family: var(--fn-font-display);
  font-size: var(--fn-t-lg);
  font-weight: 700;
  letter-spacing: var(--fn-tr-caps);
  color: var(--spark);
  text-shadow: 0 0 18px color-mix(in srgb, var(--spark) 65%, transparent);
  animation: fn-txhud-verdict 320ms var(--fn-spring-snappy) both;
}
@keyframes fn-txhud-verdict {
  from { transform: scale(1.5); opacity: 0; }
  to   { transform: scale(1); opacity: 1; }
}
.fn-txhud__bar {
  inline-size: 188px;
  block-size: 2px;
  border-radius: 2px;
  background: color-mix(in srgb, var(--spark) 18%, transparent);
  overflow: hidden;
}
.fn-txhud__bar > i {
  display: block;
  block-size: 100%;
  background: linear-gradient(90deg, transparent, var(--spark) 60%, #fff);
  box-shadow: 0 0 8px 0 var(--spark);
  transition: inline-size 380ms var(--fn-ease);
}

/* --- Portrait spotlight (spotlight.rs) -------------------------------------
   Tap a face → the portrait zooms onto a full-screen stage with a particle
   swarm (2D canvas, drawn from Rust) and two counter-rotating light rings.
   `--spot-h` is the accent hue — identity hue for people, 190 for machines.
   Sits above the modal layer (100) and the burst layer (130): the spotlight
   is the topmost moment when it runs. */
.fn-spot__opener {
  appearance: none;
  border: 0;
  padding: 0;
  background: none;
  cursor: pointer;
  border-radius: var(--fn-r-card);
  transition: transform var(--fn-dur-fast) var(--fn-ease);
}
.fn-spot__opener:hover { transform: scale(1.04); }
.fn-spot__opener:active { transform: scale(0.97); }

.fn-spot {
  position: fixed;
  inset: 0;
  z-index: 400;
  display: grid;
  place-items: center;
  background:
    radial-gradient(80% 80% at 50% 45%,
      hsl(var(--spot-h) 60% 18% / 0.35),
      transparent 60%),
    color-mix(in srgb, var(--fn-bg) 82%, transparent);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  animation: fn-txhud-in var(--fn-dur-expo) var(--fn-ease-expo) both;
  cursor: zoom-out;
}
.fn-spot__fx {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  pointer-events: none;
}
.fn-spot__tilt {
  perspective: 900px;
  transition: transform 180ms var(--fn-ease);
  transform-style: preserve-3d;
  pointer-events: none;
}
.fn-spot__stage {
  margin: 0;
  display: grid;
  grid-template-rows: auto auto;
  justify-items: center;
  gap: var(--fn-s4);
  transform-style: preserve-3d;
}
.fn-spot__img,
.fn-spot__ring {
  grid-area: 1 / 1;
  place-self: center;
  border-radius: 50%;
}
.fn-spot__img {
  inline-size: min(56vmin, 420px);
  block-size: min(56vmin, 420px);
  object-fit: cover;
  box-shadow:
    0 0 0 2px hsl(var(--spot-h) 80% 60% / 0.65),
    0 0 44px 4px hsl(var(--spot-h) 90% 55% / 0.45),
    0 0 140px 20px hsl(var(--spot-h) 90% 50% / 0.22),
    var(--fn-sh-3, 0 24px 60px rgb(0 0 0 / 0.5));
  animation: fn-spot-pop var(--fn-dur-bouncy) var(--fn-spring-bouncy) both;
}
@keyframes fn-spot-pop {
  from { transform: scale(0.25) rotate(-6deg); filter: blur(6px); opacity: 0; }
  to   { transform: scale(1) rotate(0deg); filter: blur(0); opacity: 1; }
}
/* Two broken light arcs counter-rotating around the portrait — the same
   reticle language as the relay HUD, scaled up to ceremony. */
.fn-spot__ring {
  pointer-events: none;
  border: 0;
}
.fn-spot__ring--a {
  inline-size: calc(min(56vmin, 420px) * 1.16);
  block-size: calc(min(56vmin, 420px) * 1.16);
  background: conic-gradient(
    from 0deg,
    hsl(var(--spot-h) 95% 60% / 0.9) 0deg,
    transparent 70deg,
    transparent 180deg,
    hsl(var(--spot-h) 95% 60% / 0.5) 250deg,
    transparent 320deg
  );
  mask: radial-gradient(closest-side, transparent 96%, #000 97%);
  animation: fn-proc-spin 5.5s linear infinite;
}
.fn-spot__ring--b {
  inline-size: calc(min(56vmin, 420px) * 1.3);
  block-size: calc(min(56vmin, 420px) * 1.3);
  background: conic-gradient(
    from 120deg,
    transparent 0deg,
    hsl(var(--spot-h) 90% 65% / 0.5) 40deg,
    transparent 110deg,
    transparent 230deg,
    hsl(0 90% 60% / 0.45) 265deg,
    transparent 320deg
  );
  mask: radial-gradient(closest-side, transparent 97.2%, #000 98%);
  animation: fn-proc-spin 9s linear infinite reverse;
}
.fn-spot__caption {
  display: grid;
  justify-items: center;
  gap: var(--fn-s2);
  text-align: center;
  pointer-events: auto;
  animation: fn-spot-caption 420ms 120ms var(--fn-ease-expo) both;
}
@keyframes fn-spot-caption {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: translateY(0); }
}
.fn-spot__title {
  font-family: var(--fn-font-display);
  font-size: var(--fn-t-xl);
  letter-spacing: 0.04em;
  color: hsl(var(--spot-h) 60% 88%);
  text-shadow: 0 0 22px hsl(var(--spot-h) 90% 55% / 0.6);
}
.fn-spot__sub {
  font-size: var(--fn-t-sm);
  color: var(--fn-fg-muted);
  overflow-wrap: anywhere;
  max-inline-size: min(80vw, 480px);
}

/* The address, abbreviated until asked for.
   `overflow-wrap: anywhere` on the revealed state and not on the short one:
   forty-two mono characters have no break opportunity of their own, so on a
   phone they would otherwise run off the stage rather than wrap. The
   abbreviation is eleven characters and must never break — a `0x742d…` split
   across two lines stops reading as one address.

   The size step between the two states is deliberate. Revealed, this is the
   longest string in the product and it is competing with a full-screen
   portrait for the same eye; dropping it a step is what keeps three lines of
   hex from becoming the subject of the picture. */
.fn-spot__addr {
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-sm);
  letter-spacing: 0.02em;
  color: hsl(var(--spot-h) 40% 82%);
  white-space: nowrap;
  max-inline-size: min(86vw, 520px);
  transition: color var(--fn-dur) var(--fn-ease);
}
.fn-spot__addr[data-revealed="true"] {
  font-size: var(--fn-t-xs);
  white-space: normal;
  overflow-wrap: anywhere;
  /* Revealed, it is being read character by character against something else,
     so it gets the brightest ink on the stage rather than the quiet one. */
  color: hsl(var(--spot-h) 30% 92%);
}
.fn-spot__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--fn-s2);
}
/* The reveal button carries a glyph and a label; the icon alone would be one
   more thing to decode on a stage that is already mostly picture. */
.fn-spot__actions .topcoat-button > svg { margin-inline-end: 2px; }
.fn-spot__close {
  position: absolute;
  inset-block-start: calc(var(--fn-s4) + var(--fn-safe-top, 0px));
  inset-inline-end: var(--fn-s4);
}

/* §17 flattens durations globally, but these two are *removals*: with the
   animation gone the row would sit at its final keyframe. Reduced motion gets
   the outcome instantly and no scan pass at all. */
@media (prefers-reduced-motion: reduce) {
  .fn-msg:last-child .fn-bubble::after { display: none; }
  .fn-msg--dissolving { opacity: 0; }
  .fn-msg--dissolving .fn-bubble::before { display: none; }
  /* The electric layer is decoration on top of an outcome that is already
     conveyed: the row arrives, the row goes. None of it survives the
     preference — a stepped flicker is exactly what this setting exists for. */
  .fn-msg:last-child::before { display: none; }
  .fn-msg:last-child .fn-bubble { animation-name: fn-bubble-bloom; }
  /* burst.rs already declines to mount under reduced motion; this covers a
     burst already airborne when the preference flips. */
  .fn-burstlayer { display: none; }
}

@keyframes fn-bubble-in {
  from { opacity: 0; transform: translateY(26px); }
  to   { opacity: 1; transform: none; }
}

/* springBouncy (640/16, ζ=0.316). Too underdamped for any bezier: it
   overshoots 35%, then undershoots 12%, then overshoots 4%. Damped period
   is 262ms, so the peaks land at 25% / 50% / 75% of a 520ms run. */
@keyframes fn-pop {
  0%   { opacity: 0; transform: scale(0.3); }
  25%  { opacity: 1; transform: scale(1.35); }
  50%  { transform: scale(0.88); }
  75%  { transform: scale(1.04); }
  100% { opacity: 1; transform: none; }
}

/* floatLoop — y [0, -8, 0] over 5s, easeInOut, forever. */
@keyframes fn-float {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-8px); }
}

/* viewVariants — hidden { opacity 0, y 10 } → easeOutExpo, 350ms.
   The `exit` half (y -8, 180ms) has no CSS equivalent without JS holding
   the outgoing tree; documented in docs/MOTION.md as a known omission. */
@keyframes fn-view-in {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: none; }
}

/* Dialogs: backdrop cross-fades on the cosine curve, panel scales in on the
   snappy spring. */
/* The panel overshoots very slightly and settles, which is what reads as
   "physical" rather than "timed" — the difference between a tween and a
   spring. Kept to 1.5% and one beat: past that it stops feeling like weight
   and starts feeling like a bounce, and a confirmation dialog that boings is
   not asking a serious question. */
/* Two crossings, not one: the panel overshoots 2%, counter-swings 0.5%, and
   settles — a damped exponential rather than a single bump, so the confirm
   dialog *lands* instead of stopping. Same family as the bubble bloom.
   Opacity is done by 15% (~45ms): the dialog is *there* immediately; only
   the settling continues, in transform, after it is already readable. */
@keyframes fn-modal-in {
  0%   { opacity: 0; transform: translateY(14px) scale(0.92); }
  15%  { opacity: 1; }
  45%  { transform: translateY(-2px) scale(1.02); }
  70%  { transform: translateY(1px) scale(0.995); }
  100% { opacity: 1; transform: none; }
}

/* Menus retired their keyframes: popovers now enter and leave on
   *transitions* (`.fn-picker` + `@starting-style`, app.css §8) so an
   interrupted open or close reverses from its current position instead of
   restarting — the framer-motion quality, without framer. */

/* Toasts slide in from the edge they are docked to and fade back out. */
@keyframes fn-toast-in {
  from { opacity: 0; transform: translateY(14px) scale(0.97); }
  to   { opacity: 1; transform: none; }
}
@keyframes fn-toast-out {
  to { opacity: 0; transform: translateY(8px) scale(0.98); }
}

@keyframes fn-banner-in {
  from { opacity: 0; transform: translateY(-8px); }
  to   { opacity: 1; transform: none; }
}

@keyframes fn-nav-underline {
  from { opacity: 0; transform: scaleX(0.2); }
  to   { opacity: 1; transform: none; }
}

/* Something went wrong, said with motion rather than with more red. */
@keyframes fn-shake {
  0%, 100% { transform: translateX(0); }
  18%      { transform: translateX(-5px); }
  38%      { transform: translateX(4px); }
  58%      { transform: translateX(-3px); }
  78%      { transform: translateX(2px); }
}

/* In-flight, not yet acknowledged. */
@keyframes fn-breathe {
  0%, 100% { opacity: 0.62; }
  50%      { opacity: 0.42; }
}

/* --- Motion utilities ------------------------------------------------------
   Available to markup that needs an entrance without owning a component
   class. `--i` supplies the stagger index; it defaults to 0. */
.fn-anim-in {
  animation: fn-list-in var(--fn-dur-soft) var(--fn-spring-soft) both;
  animation-delay: min(calc(var(--i, 0) * var(--fn-stagger)), var(--fn-stagger-max));
}
.fn-anim-view { animation: fn-view-in var(--fn-dur-expo) var(--fn-ease-expo) both; }
.fn-anim-pop  { animation: fn-pop var(--fn-dur-bouncy) var(--fn-spring-bouncy) both; }
.fn-anim-float { animation: fn-float var(--fn-dur-float) var(--fn-ease-cosine) infinite; }

/* tapScale, for anything that is not already a Topcoat control. */
.fn-tap {
  transition: transform var(--fn-dur-snappy) var(--fn-spring-snappy);
}
@media (hover: hover) {
  .fn-tap:hover { transform: scale(var(--fn-tap-hover)); }
}
.fn-tap:active { transform: scale(var(--fn-tap-press)); }


/* ==========================================================================
   15. UTILITIES
   ========================================================================== */

/* The explicit `minmax(0, 1fr)` is load-bearing: an implicit `auto` column
   floors at its widest child's min-content, and a wallet address is one
   unbreakable 42-character token. Without it a stack containing an address
   refuses to go below ~146px and pushes its container off-screen. */
.fn-stack   { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--fn-s3); }
.fn-row     { display: flex; align-items: center; gap: var(--fn-s2); flex-wrap: wrap; }
.fn-row--wrap { flex-wrap: wrap; }
.fn-grow    { flex: 1; min-inline-size: 0; }
.fn-push    { margin-inline-start: auto; }
.fn-muted   { color: var(--fn-fg-muted); }
.fn-faint   { color: var(--fn-fg-faint); }
.fn-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-inline-size: 0; }
.fn-nums    { font-variant-numeric: tabular-nums; }

.fn-sr-only {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.fn-sr-only:focus-visible {
  position: fixed;
  inset-block-start: calc(var(--fn-s2) + var(--fn-safe-top));
  inset-inline-start: calc(var(--fn-s2) + var(--fn-safe-left));
  inline-size: auto;
  block-size: auto;
  padding: var(--fn-s2) var(--fn-s3);
  clip-path: none;
  background: var(--fn-surface);
  color: var(--fn-fg);
  border-radius: var(--fn-r-ctl);
  box-shadow: var(--fn-elev-4);
  z-index: 300;
}


/* ==========================================================================
   16. RESPONSIVE & ORIENTATION MATRIX

   Two axes, deliberately independent, because a phone in landscape is wide
   *and* short and needs both answers at once:

     A. WIDTH  decides how many panes are on screen.
          < 800px   single column; `data-view` picks the pane, bottom nav
                    and back button appear.
          ≥ 800px   two panes.  list 300 / 340 (≥1080) / 380 (≥1400)
          ≥ 1700px  panes capped and centred; stream capped at 1100px.

     B. HEIGHT decides how much vertical chrome we can afford.
          (orientation: landscape) and (max-height: 500px)
                    short-height mode: shorter bars, smaller avatars,
                    tighter composer, the hint line drops out.

   Concrete devices:
     phone portrait   390 × 844   → A: single column   B: normal
     phone landscape  844 × 390   → A: two panes       B: short  ← the case
                                                                   the old
                                                                   sheet had
                                                                   no answer
                                                                   for
     small phone landscape 667 × 375 → A: single column B: short
     tablet portrait  834 × 1112  → A: two panes       B: normal
     tablet landscape 1112 × 834  → A: two panes       B: normal
     laptop           1440 × 900  → A: two panes       B: normal
     ultrawide        2560 × 1080 → A: two panes, capped
   ========================================================================== */

/* --- A. Single column (< 800px) -------------------------------------------
   Both panes stay mounted; `data-view` on .fn-panes decides which is shown,
   so scroll position and WebSocket state survive the switch. ------------- */
@media (max-width: 799px) {
  .fn-app { grid-template-rows: auto auto 1fr auto; }
  .fn-panes { grid-template-columns: minmax(0, 1fr); }
  .fn-pane--list { border-inline-end: 0; padding-inline: var(--fn-safe-left) var(--fn-safe-right); }
  .fn-pane--detail { padding-inline: var(--fn-safe-left) var(--fn-safe-right); }
  .fn-panes[data-view="chat"] .fn-pane--list,
  .fn-panes[data-view="members"] .fn-pane--list,
  .fn-panes[data-view="settings"] .fn-pane--list { display: none; }
  .fn-panes[data-view="rooms"] .fn-pane--detail { display: none; }

  .fn-bottomnav { display: grid; }
  /* The rail (§24) sits above the nav rather than on it. Declared here, next
     to the rule that puts the nav on screen, so the two cannot drift: a nav
     that appears without this would be covered again. `--fn-safe-bottom` is
     already inside the nav's own padding, so this is the nav box alone. */
  :root { --fn-nav-h: 58px; }
  .fn-back { display: inline-grid; }

  /* Where the bottom nav exists, the top bar stops being a menu.
     Fourteen 36px buttons want 504px; a phone in portrait has 390, and flex
     does not wrap them — it runs them off the screen edge, taking the last
     ones (settings, sign out) with it. Everything tagged `--wide` is reachable
     below through the nav or the More sheet behind its fifth tab, so what is
     left up here is the identity, the wallet, and the way out. */
  .fn-topbar__wide { display: none; }
  /* …and the sheet that reaches all of it takes their place. */
  .fn-topbar__narrow { display: inline-flex; }

  .fn-topbar__addr .fn-addr--full { display: none; }
  .fn-stream { padding-inline: var(--fn-s3); }
  .fn-bubble { max-inline-size: 86%; }
  .fn-modal { inline-size: 100%; max-block-size: 92dvh; }
  .fn-modal__foot > * { flex: 1; min-inline-size: 0; }
  .fn-picklist__row { flex-wrap: wrap; row-gap: var(--fn-s2); }
  .fn-picklist__row > .fn-row:last-child,
  .fn-picklist__row > .fn-person__actions { inline-size: 100%; justify-content: flex-end; }
  .fn-chat > .fn-picker[role="menu"] { inset-inline: var(--fn-s3) var(--fn-s3); inline-size: auto; }
}

/* Narrow phones: buy back the horizontal budget. */
@media (max-width: 380px) {
  .fn-topbar { gap: var(--fn-s1); }
  .fn-stream { padding-inline: var(--fn-s2); }
  .fn-bubble { max-inline-size: 92%; }
  .fn-bottomnav__item { font-size: 10px; }
  .fn-composer { padding-inline: var(--fn-s3); gap: var(--fn-s1); }
}

/* Phones: the composer gets its own row.

   Sharing one row between two icon buttons, the field and an 85px "Send"
   leaves the field under half the width — 182px at 390pt, and far less once
   any zoom is on. The field is the only part you actually work in, so give
   it the whole width and put the controls underneath. Costs about 40px of
   height and buys roughly double the typing width at every phone size.

   Grid placement only: the markup is untouched and DOM order still reads
   emoji, assistant, field, send, so focus order and screen readers are
   unaffected. */
@media (max-width: 480px) {
  .fn-composer {
    grid-template-columns: auto auto auto auto minmax(0, 1fr);
    align-items: center;
  }
  .fn-composer__input.topcoat-textarea {
    grid-area: 1 / 1 / 2 / -1;
    /* Two comfortable lines before it starts scrolling, rather than one. */
    min-block-size: 44px;
  }
  /* The emoji button is the one with no modifier class, hence the :not() list;
     each of the others is placed explicitly. Adding a control here means
     extending both this selector and the column count above. */
  .fn-composer > .topcoat-icon-button--quiet:not(.fn-composer__ai):not(.fn-composer__attach):not(.fn-composer__files) {
    grid-area: 2 / 1 / 3 / 2;
  }
  .fn-composer > .fn-composer__ai { grid-area: 2 / 2 / 3 / 3; }
  .fn-composer > .fn-composer__attach { grid-area: 2 / 3 / 3 / 4; }
  .fn-composer > .fn-composer__files { grid-area: 2 / 4 / 3 / 5; }
  .fn-composer > .topcoat-button--cta { grid-area: 2 / 5 / 3 / -1; justify-self: end; }
  /* "Enter to send · Shift+Enter for a new line" describes a keyboard this
     viewport almost certainly does not have, and it cost up to five lines. */
  .fn-composer__hint { display: none; }
}

/* Very narrow. Not a device width so much as a *zoom* width: Safari's page
   zoom and iOS Display Zoom both shrink the layout viewport instead of
   scaling the picture, so a 390pt phone at 175% lays out at 223pt and a
   320pt one at 183pt. The shell no longer overflows there (§5), but five
   36px icons still leave the account name nothing, so spend the width
   deliberately — lose the avatar and some icon padding, keep every control
   reachable and every hit target at 30px. */
@media (max-width: 339px) {
  .fn-topbar { gap: 2px; padding-inline: var(--fn-s2); }
  .fn-topbar__identity { padding-inline: var(--fn-s1); }
  .fn-topbar__identity > .fn-ident { display: none; }
  /* At this width the account name is already fighting for pixels; the
     version number loses that fight. */
  .fn-topbar__version { display: none; }
  /* Kept where the version is dropped: which transport you are on is worth
     more than which build, and it is only ever present when notable. */
  .fn-topbar__h3 { padding: 1px 5px; }
  .fn-topbar__actions { gap: 0; }
  .fn-topbar__actions .topcoat-icon-button--quiet { min-inline-size: 30px; }

  /* Back, avatar and the two actions leave the title about 67px, and the
     room name, lock, Admin badge and member count then stack four deep —
     145px of header above a 214px conversation. Let the header wrap and
     give the title its own full-width row instead: two short rows, and the
     stream gets the space back. */
  .fn-chat__head {
    padding-inline: var(--fn-s2);
    gap: var(--fn-s1);
    flex-wrap: wrap;
    row-gap: 2px;
  }
  .fn-chat__title { flex: 1 0 100%; order: 1; }
  .fn-chat__actions .topcoat-icon-button--quiet { min-inline-size: 30px; }

  .fn-bottomnav__item { padding-inline: 0; font-size: 9px; }
  .fn-stream { padding-inline: var(--fn-s1); }
  .fn-bubble { max-inline-size: 96%; }

  /* The stacked composer itself comes from the ≤480px tier above; here it
     only needs the tighter gutter. */
  .fn-composer { padding-inline: var(--fn-s2); }
}

/* --- A. Two panes (≥ 800px) ------------------------------------------------ */
@media (min-width: 800px) {
  .fn-panes[data-view] .fn-pane--list,
  .fn-panes[data-view] .fn-pane--detail { display: flex; }

  /* Stacked (`data-shell="vertical"`, Settings → Layout). The list becomes a
     shelf across the top and the conversation takes the rest — trading
     visible history depth for full-width rows, which is the arrangement the
     rack (§6) was drawn for: every poster gets the whole row to bleed
     across. The shelf owns its height and scrolls inside it; the clamp keeps
     it honest on both a short laptop (never more than a third of the screen)
     and a tall display (never a wasteland of empty shelf). Below 800px this
     attribute is ignored entirely — one column is one column. */
  .fn-panes[data-shell="vertical"] {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto minmax(0, 1fr);
  }
  .fn-panes[data-shell="vertical"] .fn-pane--list {
    block-size: clamp(180px, 32dvh, 380px);
    border-inline-end: 0;
  }
}

/* The two controls that pick the arrangement — the top-bar toggle and the
   Settings row — hidden together where there is nothing to arrange. */
@media (max-width: 799px) {
  .fn-picklist__row--wide-only,
  .fn-topbar__layout { display: none; }
}

@media (min-width: 1080px) {
  :root { --fn-list-w: 340px; }
}

@media (min-width: 1400px) {
  :root { --fn-list-w: 380px; }
  .fn-stream { padding-inline: var(--fn-s6); }
}

/* Ultrawide: cap the *measure*, not the shell. The panes used to stop at
   1680px and centre, which read as two dead columns on a maximised desktop
   window — the Tauri app made that painfully visible. The shell now fills
   whatever it is given; what stays capped is the text column inside the
   stream, which is the thing measure rules are actually about. */
@media (min-width: 1700px) {
  :root {
    --fn-stream-max: 1100px;
    --fn-list-w: 420px;
  }
}

/* --- B. Short height (landscape phones, split-screen tablets) --------------
   ~390px of viewport with 250px of chrome left 110px for the conversation.
   Everything vertical shrinks; nothing horizontal does, because width is
   the one thing this orientation is not short of. */
@media (orientation: landscape) and (max-height: 500px) {
  :root {
    --fn-topbar-h: 40px;
    --fn-navbar-h: 44px;
    --fn-list-w: 264px;
  }

  /* The ribbon states a fact about the environment; in 390px of height it
     states it in one thin line. */
  .fn-ribbon {
    padding-block: 1px;
    padding-block-start: calc(1px + var(--fn-safe-top));
    font-size: 10px;
    letter-spacing: 0.08em;
  }

  .fn-topbar { padding-block: var(--fn-s1); }
  .fn-topbar .fn-ident { --fn-ident-size: 24px; }
  .fn-topbar__addr { display: none; }

  .fn-chat__head { padding-block: var(--fn-s1); }
  .fn-chat__head .fn-ident { --fn-ident-size: 24px; }
  .fn-chat__title { font-size: var(--fn-t-md); }

  .fn-banner { padding-block: 2px; }

  .fn-stream { padding-block: var(--fn-s2) var(--fn-s1); gap: 0; }
  .fn-msg { margin-block-start: var(--fn-s2); }
  .fn-msg--grouped { margin-block-start: 1px; }
  /* `:not(--own)` matters: an own message is a *single* column, and
     re-declaring two here would drop its bubble into the 28px avatar
     gutter and wrap the text one character wide. */
  .fn-msg:not(.fn-msg--own) { grid-template-columns: 28px minmax(0, 1fr); }
  .fn-msg__avatar.fn-ident { --fn-ident-size: 28px; }
  .fn-daymark { margin-block: var(--fn-s2) var(--fn-s1); }

  /* 22px of "nobody is typing" is 6% of the viewport. */
  .fn-typing { min-block-size: 0; padding-block: 0; }
  .fn-typing:empty { display: none; }

  .fn-composer {
    padding-block: var(--fn-s2);
    padding-block-end: calc(var(--fn-s2) + var(--fn-safe-bottom));
  }
  .fn-composer__input.topcoat-textarea { min-block-size: 32px; max-block-size: 4.5em; }
  .fn-composer > .topcoat-icon-button--quiet,
  .fn-composer > .topcoat-button--cta { min-block-size: 32px; }
  /* The keyboard contract is worth a line of prose on a tall screen and a
     line of conversation on a short one. It stays in the aria-label. */
  .fn-composer__hint { display: none; }

  .fn-room-row { padding-block: var(--fn-s2); }
  .fn-room-row .fn-ident { --fn-ident-size: 30px; }
  .fn-person { padding-block: var(--fn-s2); }
  .fn-person .fn-ident { --fn-ident-size: 32px; }

  .fn-bottomnav__item { min-block-size: 44px; padding-block: 2px; font-size: 10px; }

  .fn-modal { max-block-size: 94dvh; }
  .fn-modal__head { padding-block: var(--fn-s3) var(--fn-s2); }
  .fn-modal__body { padding-block: var(--fn-s3); gap: var(--fn-s3); }
  .fn-modal__foot { padding-block: var(--fn-s2) var(--fn-s3); }

  .fn-empty { padding-block: var(--fn-s4); gap: var(--fn-s2); }
  .fn-empty__art { inline-size: 56px; block-size: 56px; font-size: 26px; }
  .fn-empty__art.fn-art { inline-size: min(200px, 34vw); max-block-size: 34dvh; }

  /* The login card's own short-viewport handling lives in §14b, which is
     keyed on height and so already covers everything this block can match.
     What is left here is only what a phone in landscape needs beyond it:
     a tighter card, and a textarea that gives its height back to the fields
     above it. */
  .fn-login__card { --fn-login-pad: var(--fn-s4); }
  .fn-mnemonic .topcoat-textarea { min-block-size: 64px; }
}

/* Landscape phone that is also wide enough for two panes: prefer the
   side-by-side layout — 844px of width can hold a list and a conversation,
   and the alternative is a 390px-tall single column with a bottom nav
   eating an eighth of it. */
@media (orientation: landscape) and (max-height: 500px) and (min-width: 800px) {
  .fn-bottomnav { display: none; }
  /* No nav here, so the rail only clears the safe area. */
  :root { --fn-nav-h: 0px; }
  .fn-back { display: none; }
}

/* Coarse pointers get 44px minimum hit targets — except where the viewport
   is too short to afford them, which the short-height block above handles. */
@media (pointer: coarse) {
  .topcoat-button,
  .topcoat-button--quiet,
  .topcoat-icon-button,
  .topcoat-icon-button--quiet,
  .fn-bottomnav__item { min-block-size: 44px; }
  .fn-room-row, .fn-person { padding-block: var(--fn-s4); }

  /* Dialog buttons stack on touch — the primary on top, full width, the way
     every platform sheet does it. Side-by-side half-slabs made the two
     choices read as equals, which a destructive confirm must never do. */
  .fn-modal__foot { flex-direction: column-reverse; }
  .fn-modal__foot > * { inline-size: 100%; min-block-size: 48px; }

  /* On touch, every tab tap and every room open replays the view entrance
     (the single column remounts `.fn-view` on each section change), and
     220ms of fading in from transparent reads as *lag*, not as motion — the
     route answers in ~15ms and then spends fifteen times that becoming
     visible. Half the duration, no travel: still an entrance, but the
     content is effectively there the frame you tap. Pointer devices keep
     the full treatment; reduced-motion already removes it entirely (§17). */
  .fn-view,
  .fn-empty {
    animation-duration: 110ms;
    animation-name: fn-view-in-still;
  }
}
@keyframes fn-view-in-still {
  from { opacity: 0.35; }
  to   { opacity: 1; }
}
@media (pointer: coarse) and (orientation: landscape) and (max-height: 500px) {
  .topcoat-button,
  .topcoat-button--quiet,
  .topcoat-icon-button,
  .topcoat-icon-button--quiet { min-block-size: 36px; }
  .fn-room-row, .fn-person { padding-block: var(--fn-s2); }
}


/* ==========================================================================
   17. REDUCED MOTION, FORCED COLOURS & PRINT
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    /* Without this the staggered rows still wait up to 320ms before
       appearing, which is the part people actually notice. */
    animation-delay: 0ms !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !important;
    scroll-behavior: auto !important;
  }
  html, .fn-scroll { scroll-behavior: auto !important; }

  /* Anything whose resting state is expressed as a transform must be
     pinned, or a cancelled transition can leave it mid-scale. */
  .topcoat-button:hover,
  .topcoat-button--large:hover,
  .topcoat-button--cta:hover,
  .topcoat-button--large--cta:hover,
  .topcoat-icon-button:hover,
  .topcoat-button:active,
  .topcoat-button--cta:active,
  .topcoat-icon-button:active,
  .topcoat-icon-button--quiet:active,
  .fn-tap:hover,
  .fn-tap:active,
  .fn-conn:hover,
  .fn-conn:active,
  .fn-lang:hover,
  .fn-lang:active,
  .fn-reaction:hover,
  .fn-reaction:active,
  .fn-picker__cell:hover,
  .fn-picker__cell:active,
  .fn-topbar__identity:active,
  .fn-room-row:active,
  .fn-room-row:active .fn-room-row__avatar,
  .fn-hero-btn:hover::after,
  .fn-bottomnav__item:active:not([disabled]) > svg { transform: none !important; }

  .fn-msg__tools { transform: none !important; }
  .fn-typing__dots i { opacity: 0.7; }
  .fn-spinner { border-top-color: var(--fn-primary); opacity: 0.8; }
  .fn-msg--pending .fn-bubble { opacity: 0.62; }
  .fn-skel { background: var(--fn-surface-3); }
}

@media (forced-colors: active) {
  /* Everything whose shape is carried by a fill rather than a line needs a
     line back, because a forced palette throws the fill away. The identity
     tile is the clearest case: without this it is white text on Canvas. */
  .fn-bubble,
  .fn-room-row[aria-selected="true"],
  .fn-unread,
  .fn-conn,
  .fn-picker,
  .fn-toast,
  .fn-ident,
  .fn-badge { border: 1px solid CanvasText; }
  .fn-msg--own .fn-bubble { forced-color-adjust: none; }
  .fn-empty__art.fn-art { forced-color-adjust: none; }
  :focus-visible { outline: 2px solid Highlight; box-shadow: none; }
}

@media print {
  .fn-topbar,
  .fn-bottomnav,
  .fn-composer,
  .fn-toasts,
  .fn-ribbon,
  .fn-msg__tools,
  .fn-pane--list { display: none !important; }
  .fn-app { block-size: auto; display: block; overflow: visible; }
  .fn-panes, .fn-pane, .fn-view, .fn-chat { display: block; overflow: visible; }
  .fn-bubble { box-shadow: none; border-color: #999; }
  .fn-msg, .fn-room-row, .fn-person, .fn-empty { animation: none !important; }
}


/* ==========================================================================
   18. ADDENDA
   Rules the Yew implementation needed that §1–§17 did not cover.
   ========================================================================== */

/* Pre-mount placeholder from index.html. Removed by the client on first
   render, so this only ever paints during the WASM fetch — but without it the
   boot screen is an unstyled image in the top-left corner, which reads as a
   broken page rather than a loading one.

   The logo has no dark variant by design (a brand mark should not change
   plate colour between themes) but it does ship with a light margin, so it
   is clipped to its plate here rather than showing a halo on the dark page. */
.ps-boot {
  display: grid;
  place-content: center;
  justify-items: center;
  gap: var(--fn-s3);
  block-size: 100dvh;
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-sm);
  text-align: center;
}
/* Painted from `--img-logo` rather than being an `<img src>`, so the mark the
   loading screen shows is the one belonging to the skin stamped on `<html>`
   by the pre-paint script in index.html. The `clip-path` trims the generated
   art's own plate edge; `border-radius` alone would leave it visible under
   the corners. */
.ps-boot__mark {
  inline-size: 72px;
  block-size: 72px;
  background: var(--img-logo) center / cover no-repeat;
  opacity: 0.9;
  border-radius: 22%;
  clip-path: inset(6% round 22%);
  animation: fn-float var(--fn-dur-float) var(--fn-ease-cosine) infinite;
}

/* Room chips carry the room name's first letter as a corner badge (DESIGN.md
   §2.6). People never get one — the fruit alone identifies a wallet, and a
   letter there would compete with the username beside it. */
.fn-ident__corner {
  position: absolute;
  /* Bottom-*end*, not bottom-start: at the start edge it collides with the
     row's own inline padding and reads as a stray letter floating beside the
     avatar rather than a badge attached to it. Overlapping inward by 2px keeps
     it visually anchored to the circle. */
  inset-block-end: -2px;
  inset-inline-end: -2px;
  min-inline-size: 14px;
  block-size: 14px;
  padding-inline: 3px;
  display: grid;
  place-items: center;
  border-radius: var(--fn-r-pill);
  background: var(--fn-surface);
  box-shadow: 0 0 0 1px var(--fn-border);
  color: var(--fn-fg-muted);
  font-family: var(--fn-font-ui);
  font-size: 9px;
  font-weight: 700;
  line-height: 1;
}

/* The reveal toggle for a recovery phrase.
   Labelled rather than icon-only: the phrase is masked by default, so the way
   to see it must be readable, not a glyph to be recognised. */
.fn-reveal {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-block-size: 30px;
  padding-inline: 10px;
  font-size: var(--fn-t-xs);
  font-weight: 600;
  letter-spacing: var(--fn-tr-label);
}


/* --------------------------------------------------------------------------
   18.1  SKYNET LAYER — the wallet, the assistant, and the HUD theatre.
   Everything here is additive: new class names, GPU-cheap properties only
   (opacity, transform, box-shadow, filter), and every animation is covered
   by the §17 reduced-motion kill switch.
   -------------------------------------------------------------------------- */

/* The ambient command-center backdrop. Painted on the shell root behind the
   panes, screened down hard so it registers as texture, never as content.
   Light mode gets a whisper of it; dark mode gets the full grid. */
.fn-app {
  position: relative;
  isolation: isolate;
}
.fn-app::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  background: var(--img-skynet-grid) center / cover no-repeat;
  opacity: var(--fn-mark-grid);
  pointer-events: none;
}

/* --- Wallet ---------------------------------------------------------------- */

.fn-wallet { position: relative; display: grid; gap: var(--fn-s3); }

/* --- The Vault Warden -------------------------------------------------------
   The wallet's presiding avatar (wallet-warden.png): a sentinel on duty, not
   a mascot, and it replaced the vault-crystal watermark rather than joining
   it — one face per dialog. The header carries the whole dialog's state: the
   duty readout speaks the phase, and the optic ring scans while idle, hurries
   while relaying, and locks solid in the verdict's colour. The effects budget
   is spent here and nowhere else. */

.fn-warden {
  position: relative;
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: var(--fn-s3);
  padding: var(--fn-s3);
  border: 1px solid var(--fn-border);
  border-radius: var(--fn-r-sm, 8px);
  background:
    radial-gradient(140% 220% at 0% 0%, var(--fn-glow) -70%, transparent 55%),
    linear-gradient(180deg, var(--fn-raise-a), var(--fn-raise-b));
  overflow: hidden;
  isolation: isolate;
}

/* One boot sweep when the dialog opens — the warden coming on shift. */
.fn-warden::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(105deg,
      transparent 30%, var(--fn-sweep) 48%, transparent 64%);
  transform: translateX(-110%);
  animation: fn-warden-sweep 1100ms var(--fn-ease-expo) 180ms forwards;
  pointer-events: none;
}
@keyframes fn-warden-sweep { to { transform: translateX(110%); } }

/* A spotlight opener (a real button) since the warden joined the tap-a-face
   club; the circle overrides the opener's card radius so the hover scale and
   focus ring stay round. */
.fn-warden__portrait {
  position: relative;
  display: block;
  inline-size: 64px;
  block-size: 64px;
  border-radius: 50%;
}
.fn-warden__portrait img {
  inline-size: 100%;
  block-size: 100%;
  border-radius: 50%;
  object-fit: cover;
  /* The source is a bust; the face sits in the upper third of the frame. */
  object-position: 50% 22%;
  border: 1px solid var(--fn-primary-line);
  animation: fn-warden-breathe 4.5s var(--fn-ease-cosine) infinite;
}
/* The scanning optic: a comet-tail arc orbiting the portrait. */
.fn-warden__ring {
  position: absolute;
  inset: -5px;
  border-radius: 50%;
  background: conic-gradient(from 0turn,
      transparent 0 72%, var(--fn-primary) 94%, transparent 100%);
  -webkit-mask: radial-gradient(farthest-side,
      transparent calc(100% - 3.5px), #000 calc(100% - 2.5px));
  mask: radial-gradient(farthest-side,
      transparent calc(100% - 3.5px), #000 calc(100% - 2.5px));
  animation: fn-warden-orbit 3.4s linear infinite;
}
@keyframes fn-warden-orbit { to { transform: rotate(1turn); } }
@keyframes fn-warden-breathe {
  0%, 100% { box-shadow: 0 0 10px var(--fn-glow); }
  50%      { box-shadow: 0 0 26px var(--fn-glow); }
}

/* Duty states. Relay hurries the orbit; the verdicts stop the scan and hold
   a full ring in the outcome's colour — the machine standing down. */
.fn-warden[data-state="relay"] .fn-warden__ring { animation-duration: 0.8s; }
.fn-warden[data-state="relay"] .fn-warden__portrait img { animation-duration: 1.2s; }
.fn-warden[data-state="ok"] .fn-warden__ring,
.fn-warden[data-state="fail"] .fn-warden__ring {
  animation: none;
  box-shadow: 0 0 18px -2px currentColor;
}
.fn-warden[data-state="ok"] .fn-warden__ring {
  background: var(--fn-encrypt);
  color: var(--fn-encrypt);
}
.fn-warden[data-state="fail"] .fn-warden__ring {
  background: var(--fn-danger);
  color: var(--fn-danger);
}

.fn-warden__brief { display: grid; gap: 2px; min-inline-size: 0; }
.fn-warden__name {
  font-size: var(--fn-t-xs);
  font-weight: 800;
  letter-spacing: var(--fn-tr-label);
  text-transform: uppercase;
  color: var(--fn-primary-text);
}
/* The duty readout. Keyed on the state in wallet.rs, so a phase change
   re-mounts the element and re-runs the flicker-in — a report arriving,
   not a label editing itself. */
.fn-warden__line {
  margin: 0;
  font-size: var(--fn-t-sm);
  font-weight: 600;
  color: var(--fn-fg);
  animation: fn-warden-report 480ms var(--fn-ease-expo);
}
@keyframes fn-warden-report {
  0%       { opacity: 0; transform: translateY(3px); }
  30%      { opacity: 1; transform: none; }
  45%      { opacity: 0.35; }
  60%, 100% { opacity: 1; }
}
.fn-warden__line::after {
  content: "▍";
  margin-inline-start: 2px;
  color: var(--fn-primary-text);
  animation: fn-warden-caret 1.1s steps(2, jump-none) infinite;
}
@keyframes fn-warden-caret { 50% { opacity: 0; } }

/* The chain as a lit pill: a breathing status dot and the network's name.
   Stated, not offered — there is nothing to press. */
.fn-warden__net {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 12px;
  border: 1px solid var(--fn-field-line);
  border-radius: 999px;
  background: linear-gradient(180deg, var(--fn-sunk-a), var(--fn-sunk-b));
  font-size: var(--fn-t-xs);
  font-weight: 700;
  white-space: nowrap;
}
.fn-warden__net::before {
  content: "";
  inline-size: 7px;
  block-size: 7px;
  border-radius: 50%;
  background: var(--fn-encrypt);
  box-shadow: 0 0 8px var(--fn-encrypt);
  animation: fn-warden-dot 2.4s var(--fn-ease-cosine) infinite;
}
@keyframes fn-warden-dot { 50% { opacity: 0.55; } }

/* Narrow phones: the pill drops under the readout rather than crushing it. */
@media (max-width: 520px) {
  .fn-warden { grid-template-columns: auto 1fr; }
  .fn-warden__net { grid-column: 1 / -1; justify-self: start; }
}

@media (prefers-reduced-motion: reduce) {
  /* §17 flattens durations, but the orbit, caret and sweep are ambient
     loops — they go entirely, and the readout just appears. */
  .fn-warden::after,
  .fn-warden__line::after { display: none; }
  .fn-warden__ring,
  .fn-warden__portrait img,
  .fn-warden__net::before { animation: none; }
}

.fn-wallet__id { display: flex; align-items: center; gap: var(--fn-s2); }
.fn-wallet__id .fn-wallet__addr { flex: 1; min-inline-size: 0; }

.fn-wallet__addr {
  margin: 0;
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-muted);
  word-break: break-all;
}

/* The network switcher: a select styled as HUD chrome. The glowing left rim
   is the "active reactor" tell — it breathes once on hover, not forever. */
.fn-wallet__network {
  inline-size: 100%;
  min-block-size: 40px;
  padding-inline: 12px;
  border: 1px solid var(--fn-field-line);
  border-inline-start: 3px solid var(--fn-primary);
  border-radius: var(--fn-r-sm, 8px);
  background: linear-gradient(180deg, var(--fn-raise-a), var(--fn-raise-b));
  color: var(--fn-fg);
  font: inherit;
  font-weight: 600;
  transition: box-shadow var(--fn-dur) var(--fn-ease-expo);
}
.fn-wallet__network:hover,
.fn-wallet__network:focus-visible {
  box-shadow: 0 0 0 1px var(--fn-primary-line), 0 0 18px var(--fn-glow);
}

.fn-wallet__balances {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  flex-wrap: wrap;
}

/* One balance card per asset, entering with the game-like lift the rest of
   the app uses, staggered so the vault "powers on" card by card. */
.fn-wallet__balance {
  position: relative;
  overflow: hidden;
  display: grid;
  grid-template-columns: auto 1fr;
  grid-template-areas: "badge symbol" "badge value";
  align-items: center;
  column-gap: var(--fn-s2);
  gap: 2px var(--fn-s2);
  min-inline-size: 140px;
  padding: var(--fn-s2) var(--fn-s3);
  border: 1px solid var(--fn-border);
  border-radius: var(--fn-r-sm, 8px);
  background: linear-gradient(180deg, var(--fn-raise-a), var(--fn-raise-b));
  box-shadow: var(--fn-hi);
  animation: fn-list-in var(--fn-dur-snappy) var(--fn-ease-expo) backwards;
  transition:
    transform var(--fn-dur) var(--fn-ease-expo),
    box-shadow var(--fn-dur) var(--fn-ease-expo);
}
/* Hover: the card lifts and a light passes over the metal — the same sweep
   the warden boots with, so the two read as one machine. */
.fn-wallet__balance:hover {
  transform: translateY(-2px);
  box-shadow: 0 0 0 1px var(--fn-primary-line), 0 8px 20px -8px var(--fn-glow);
}
.fn-wallet__balance::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(115deg,
      transparent 32%, hsl(0 0% 100% / 0.07) 48%, transparent 62%);
  transform: translateX(-110%);
  pointer-events: none;
}
.fn-wallet__balance:hover::after {
  animation: fn-warden-sweep 650ms var(--fn-ease-expo) forwards;
}
.fn-wallet__balance:nth-child(2) { animation-delay: 60ms; }
.fn-wallet__balance:nth-child(3) { animation-delay: 120ms; }
.fn-wallet__balance[data-token="true"] { border-inline-start: 2px solid var(--fn-encrypt); }
.fn-wallet__balance > .fn-bank__badge { grid-area: badge; }
.fn-wallet__balance > .fn-wallet__symbol { grid-area: symbol; }
.fn-wallet__balance > .fn-wallet__value { grid-area: value; }

.fn-wallet__symbol {
  font-size: var(--fn-t-xs);
  font-weight: 700;
  letter-spacing: var(--fn-tr-label);
  color: var(--fn-primary-text);
}
.fn-wallet__value { font-size: var(--fn-t-lg); font-weight: 700; }

.fn-wallet__advanced summary {
  cursor: pointer;
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-sm);
  font-weight: 600;
}
.fn-wallet__advanced[open] summary { color: var(--fn-primary-text); }

.fn-wallet__summary {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 6px var(--fn-s3);
  margin: 0;
  padding: var(--fn-s3);
  border: 1px solid var(--fn-border);
  border-radius: var(--fn-r-sm, 8px);
  background: linear-gradient(180deg, var(--fn-sunk-a), var(--fn-sunk-b));
}
.fn-wallet__summary dt {
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-xs);
  font-weight: 700;
  letter-spacing: var(--fn-tr-label);
  text-transform: uppercase;
  align-self: center;
}
.fn-wallet__summary dd { margin: 0; word-break: break-all; }

/* Broadcasting: a reactor-core pulse. Two rings breathing on the cosine
   curve — the point is "the machine is working", not a spinner. */
.fn-wallet__sending {
  display: grid;
  justify-items: center;
  gap: var(--fn-s3);
  padding-block: var(--fn-s5, 32px);
  color: var(--fn-fg-muted);
}
.fn-wallet__pulse {
  inline-size: 56px;
  block-size: 56px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--fn-primary) 0%, transparent 65%);
  animation: fn-reactor 1.4s var(--fn-ease-cosine) infinite;
}
@keyframes fn-reactor {
  0%, 100% { transform: scale(0.82); opacity: 0.55; box-shadow: 0 0 0 0 var(--fn-glow); }
  50%      { transform: scale(1);    opacity: 1;    box-shadow: 0 0 34px 8px var(--fn-glow); }
}

.fn-wallet__receipt { display: grid; gap: var(--fn-s3); }
.fn-wallet__receipt[data-ok="true"] .fn-wallet__verdict { color: var(--fn-encrypt-text); }
.fn-wallet__receipt[data-ok="false"] .fn-wallet__verdict { color: var(--fn-danger-text); }

/* The verdict, stamped. Scales down onto the receipt like a seal pressed
   into it — one pop, then still. */
.fn-wallet__stamp {
  inline-size: 54px;
  block-size: 54px;
  display: grid;
  place-items: center;
  justify-self: center;
  border: 2px solid currentColor;
  border-radius: 50%;
  font-size: 24px;
  font-weight: 800;
  box-shadow: 0 0 24px -6px currentColor;
  animation: fn-warden-stamp 520ms var(--fn-ease-expo);
}
.fn-wallet__receipt[data-ok="true"] .fn-wallet__stamp { color: var(--fn-encrypt-text); }
.fn-wallet__receipt[data-ok="false"] .fn-wallet__stamp { color: var(--fn-danger-text); }
@keyframes fn-warden-stamp {
  0%   { transform: scale(1.7) rotate(-6deg); opacity: 0; }
  55%  { transform: scale(0.92); opacity: 1; }
  100% { transform: none; }
}
.fn-wallet__verdict {
  margin: 0;
  font-weight: 700;
  animation: fn-list-in var(--fn-dur-snappy) var(--fn-ease-expo);
}

/* --- AI assistant ----------------------------------------------------------- */

.fn-ai { position: relative; display: grid; gap: var(--fn-s3); }

/* The android sits behind the tab strip: presence, not decoration. */
.fn-ai::before {
  content: "";
  position: absolute;
  inset-block-start: 0;
  inset-inline-end: 0;
  inline-size: 120px;
  block-size: 120px;
  background: var(--img-skynet-avatar) center / cover no-repeat;
  border-radius: 0 var(--fn-r, 12px) 0 60%;
  opacity: var(--fn-mark-avatar);
  pointer-events: none;
  -webkit-mask-image: radial-gradient(closest-side at 70% 30%, #000 35%, transparent 75%);
  mask-image: radial-gradient(closest-side at 70% 30%, #000 35%, transparent 75%);
}

.fn-ai__tabs {
  display: flex;
  gap: 4px;
  padding: 3px;
  border: 1px solid var(--fn-border);
  border-radius: var(--fn-r-pill, 999px);
  background: linear-gradient(180deg, var(--fn-sunk-a), var(--fn-sunk-b));
  inline-size: fit-content;
}
.fn-ai__tab {
  min-block-size: 30px;
  padding-inline: 14px;
  border: 0;
  border-radius: var(--fn-r-pill, 999px);
  background: transparent;
  color: var(--fn-fg-muted);
  font: inherit;
  font-size: var(--fn-t-sm);
  font-weight: 600;
  cursor: pointer;
  transition: color var(--fn-dur) var(--fn-ease),
              background-color var(--fn-dur) var(--fn-ease),
              box-shadow var(--fn-dur-snappy) var(--fn-ease-expo);
}
.fn-ai__tab[aria-selected="true"] {
  background: var(--fn-primary);
  color: var(--fn-primary-ink);
  box-shadow: 0 0 14px var(--fn-glow);
}

.fn-ai__draft { animation: fn-list-in var(--fn-dur-snappy) var(--fn-ease-expo); }

.fn-ai__preview {
  max-inline-size: min(320px, 100%);
  border-radius: var(--fn-r-sm, 8px);
  border: 1px solid var(--fn-primary-line);
  box-shadow: 0 0 22px var(--fn-glow);
  animation: fn-list-in var(--fn-dur-snappy) var(--fn-ease-expo);
}

.fn-ai__keys { display: grid; gap: var(--fn-s2); }

/* --- The assistant's waiting row -------------------------------------------
   An AI call is the one thing in this app whose latency is measured in
   minutes rather than milliseconds, and a disabled button cannot say that.
   The row states what is being waited on and how long it has been: a spinner
   for "still alive", a shimmering label for "working", and a live seconds
   reading, which is the part that distinguishes slow from stuck.

   The sweep underneath is deliberately indeterminate — nothing here knows a
   percentage, and a bar that invented one would be lying. */
.fn-ai__wait {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  padding: var(--fn-s2) var(--fn-s3);
  border: 1px solid var(--fn-primary-line);
  border-radius: var(--fn-r-sm, 8px);
  background: var(--fn-surface-2);
  overflow: hidden;
  animation: fn-list-in var(--fn-dur-snappy) var(--fn-ease-expo);
}
/* The sweep: one line travelling the width of the row, on the same 1.3s
   cadence as the skeleton shimmer so the two never beat against each other. */
.fn-ai__wait::after {
  content: "";
  position: absolute;
  inset-block-end: 0;
  inset-inline-start: 0;
  block-size: 2px;
  inline-size: 40%;
  background: linear-gradient(90deg, transparent, var(--fn-primary), transparent);
  animation: fn-ai-sweep 1.3s var(--fn-ease-cosine) infinite;
}
@keyframes fn-ai-sweep {
  from { transform: translateX(-100%); }
  to   { transform: translateX(350%); }
}
.fn-ai__wait-text {
  flex: 1;
  min-inline-size: 0;
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-sm);
  background: linear-gradient(
    90deg,
    var(--fn-fg-muted) 0%,
    var(--fn-fg) 50%,
    var(--fn-fg-muted) 100%
  );
  background-size: 200% 100%;
  background-clip: text;
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  animation: fn-shimmer 1.3s linear infinite;
}
.fn-ai__wait-time {
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-sm);
  font-variant-numeric: tabular-nums;
}

/* With motion off the row still has to read as *working*, so the label keeps
   its ordinary colour instead of a frozen mid-shimmer gradient, and the sweep
   is dropped rather than parked somewhere arbitrary. */
@media (prefers-reduced-motion: reduce) {
  .fn-ai__wait::after { display: none; }
  .fn-ai__wait-text {
    background: none;
    -webkit-text-fill-color: currentColor;
    color: var(--fn-fg);
  }
}

/* --- HUD theatre ------------------------------------------------------------ */

/* Primary actions carry the optic glow. Hover charges it on the expo curve —
   fast attack, soft settle — which is what makes it feel game-like instead of
   merely animated. */
.topcoat-button--cta,
.topcoat-button--large--cta {
  transition: box-shadow var(--fn-dur-snappy) var(--fn-ease-expo),
              transform var(--fn-dur-fast) var(--fn-ease);
}
.topcoat-button--cta:hover:not(:disabled),
.topcoat-button--large--cta:hover:not(:disabled) {
  box-shadow: 0 0 20px var(--fn-glow), var(--fn-hi);
}

/* The testnet ribbon gets a slow scanline sweep: unmistakably alive,
   deliberately below the threshold of distraction. */
.fn-ribbon {
  position: relative;
  overflow: hidden;
}
.fn-ribbon::after {
  content: "";
  position: absolute;
  inset-block: 0;
  inline-size: 30%;
  background: linear-gradient(100deg, transparent, hsl(0 0% 100% / 0.14), transparent);
  animation: fn-scan 4.5s var(--fn-ease-cosine) infinite;
}
@keyframes fn-scan {
  from { transform: translateX(-160%); }
  to   { transform: translateX(460%);  }
}

/* The wallet button in the top bar breathes while a testnet is active — the
   same "which world am I in" signal as the ribbon, one glance closer. */
.fn-topbar__wallet { position: relative; }
.fn-ribbon ~ * .fn-topbar__wallet::after,
.fn-ribbon + * .fn-topbar__wallet::after {
  content: "";
  position: absolute;
  inset: 4px;
  border-radius: inherit;
  box-shadow: 0 0 12px var(--fn-glow);
  opacity: 0;
  animation: fn-breathe 3s var(--fn-ease-cosine) infinite;
  pointer-events: none;
}
@keyframes fn-breathe {
  0%, 100% { opacity: 0; }
  50%      { opacity: 1; }
}

/* Sign out sits at the end of the account row, tinted on hover/focus so it
   reads as distinct from Settings right next to it, not as a twin icon. */
.fn-topbar__signout:hover,
.fn-topbar__signout:focus-visible {
  color: var(--fn-danger-text);
}


/* --------------------------------------------------------------------------
   18.2  BOOT SEQUENCE — the sign-in cold open (components/boot.rs).
   One 4.7s timeline, four acts, driven entirely by `animation-delay` so the
   compositor owns it and Rust owns only the timer that ends it:

     0.00s  ARRIVAL      plasma sphere detonates in, sparks fly
     1.10s  FLASH        the discharge whites out the screen
     1.30s  MATERIALIZE  sphere collapses; the skull powers up, eyes ignite
     2.60s  BOOT LOG     title + terminal roll-call + progress bar
     4.30s  HANDOFF      cyan wipe, then boot.rs fires on_done

   Everything animates opacity/transform/filter only. §17's reduced-motion rule
   flattens all of it, which is why boot.rs *also* shortens its timer — a
   flattened cutscene is a still frame, and 4.7s of still frame is a hang.
   -------------------------------------------------------------------------- */

.fn-boot {
  position: fixed;
  inset: 0;
  z-index: 999;
  display: grid;
  place-items: center;
  grid-template-areas: "stack";
  background: #05070b;
  overflow: hidden;
  /* The final wipe. Runs last and leaves the overlay transparent so the app
     underneath is already painted when boot.rs unmounts this. */
  animation: fn-boot-out 400ms var(--fn-ease-expo) 4300ms backwards;
}
.fn-boot > * { grid-area: stack; }

/* Ambient depth: the same command-center grid the shell uses, so the cutscene
   and the app it opens into are visibly the same world. */
.fn-boot__sky {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(circle at 50% 46%, var(--fn-primary-wash), transparent 58%),
    var(--img-skynet-grid) center / cover no-repeat;
  opacity: 0;
  animation: fn-boot-sky 4300ms var(--fn-ease-cosine) forwards;
}
@keyframes fn-boot-sky {
  0%   { opacity: 0;    transform: scale(1.18); }
  28%  { opacity: 0.55; }
  100% { opacity: 0.30; transform: scale(1); }
}

.fn-boot__stage {
  position: relative;
  inline-size: min(460px, 76vw);
  aspect-ratio: 1;
  display: grid;
  place-items: center;
  grid-template-areas: "core";
}
.fn-boot__stage > * { grid-area: core; }

/* ---- Act 1: arrival ---- */

/* Overshoot then settle on the expo curve: the sphere does not *appear*, it
   punches through. `screen` blending drops the source's black background so
   the lightning sits on the page instead of in a box. */
.fn-boot__sphere {
  inline-size: 100%;
  aspect-ratio: 1;
  background: var(--img-boot-sphere) center / contain no-repeat;
  /* `screen` drops the source's black, but its background is near-black
     rather than pure, so the frame edge survives as a faint box. The radial
     mask removes it and tapers the outermost arcs into the dark, which is
     what they should have been doing anyway. */
  mix-blend-mode: screen;
  -webkit-mask-image: radial-gradient(circle at 50% 50%, #000 42%, transparent 76%);
  mask-image: radial-gradient(circle at 50% 50%, #000 42%, transparent 76%);
  opacity: 0;
  animation:
    fn-boot-sphere 2600ms var(--fn-ease-expo) forwards,
    fn-boot-crackle 110ms steps(2, end) 5 200ms;
}
@keyframes fn-boot-sphere {
  0%   { opacity: 0; transform: scale(0.05) rotate(-24deg); }
  14%  { opacity: 1; transform: scale(1.22) rotate(6deg);   }
  24%  {             transform: scale(0.94) rotate(0deg);   }
  42%  { opacity: 1; transform: scale(1.02);                }
  /* The collapse: it implodes into the skull rather than fading out. */
  58%  { opacity: 0.9; transform: scale(0.7);               }
  74%  { opacity: 0;   transform: scale(0.18);              }
  100% { opacity: 0;   transform: scale(0.18);              }
}
/* Electrical instability — steps(), not a smooth fade, because real arcing
   flickers rather than dims. */
@keyframes fn-boot-crackle {
  0%, 100% { filter: brightness(1)   saturate(1);   }
  50%      { filter: brightness(1.9) saturate(1.6); }
}

.fn-boot__sparks {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
}
/* Each spark is placed on the circle by index (`--a`) and thrown outward by
   `--r`; boot.rs varies `--d` so they do not leave as one ring. */
.fn-boot__sparks i {
  position: absolute;
  inline-size: 3px;
  block-size: 3px;
  border-radius: 50%;
  background: var(--fn-spark);
  box-shadow: 0 0 8px 2px var(--fn-spark-halo);
  opacity: 0;
  transform: rotate(var(--a)) translateX(0);
  animation: fn-boot-spark 900ms var(--fn-ease-expo) var(--d) forwards;
}
@keyframes fn-boot-spark {
  0%   { opacity: 0;   transform: rotate(var(--a)) translateX(0) scale(0.4); }
  12%  { opacity: 1;                                                          }
  100% { opacity: 0;   transform: rotate(var(--a)) translateX(var(--r)) scale(0.1); }
}

/* The discharge. Brief, and it peaks exactly as the sphere begins to collapse,
   so the cut to the skull is hidden inside the flash. */
.fn-boot__flash {
  position: absolute;
  inset: 0;
  background: radial-gradient(circle at 50% 46%, var(--fn-flare), var(--fn-flare-mid) 42%, transparent 72%);
  opacity: 0;
  pointer-events: none;
  animation: fn-boot-flash 620ms var(--fn-ease-exit) 1100ms forwards;
}
@keyframes fn-boot-flash {
  0%   { opacity: 0;    }
  18%  { opacity: 0.92; }
  100% { opacity: 0;    }
}

/* ---- Act 2: materialise ---- */

/* The eyes are already the brightest pixels in the source, so ramping
   `brightness`/`saturate` from a dead machine to a live one makes them bloom
   on their own — no overlay has to guess where the sockets are. */
.fn-boot__skull {
  inline-size: 86%;
  block-size: 100%;
  background: var(--img-boot-endoskull) center / contain no-repeat;
  /* Same reason as the sphere, shaped to the portrait crop: it has to look
     like it is standing *in* the dark, not pasted onto it. */
  /* A long, early falloff rather than a tight one: the source's lower half is
     pale smoke, and any short transition there reads as a seam. */
  -webkit-mask-image: radial-gradient(ellipse 60% 60% at 50% 40%, #000 26%, transparent 80%);
  mask-image: radial-gradient(ellipse 60% 60% at 50% 40%, #000 26%, transparent 80%);
  opacity: 0;
  animation: fn-boot-skull 3000ms var(--fn-ease-expo) 1300ms forwards;
}
/* `contrast` climbs alongside `brightness` on purpose: brightness alone lifts
   the source's near-black background to grey, which turns the radial mask into
   a visible glowing box. Holding contrast high keeps the blacks black, so the
   only thing that actually blooms is what was already bright — the eyes. */
@keyframes fn-boot-skull {
  0%   { opacity: 0;    transform: scale(1.14); filter: brightness(0.14) saturate(0)   contrast(1.5);  }
  22%  { opacity: 0.80; transform: scale(1.04); filter: brightness(0.34) saturate(0.4) contrast(1.4);  }
  /* Ignition — one overshoot, then it settles and holds. */
  38%  { opacity: 1;    transform: scale(1.00); filter: brightness(1.50) saturate(2.2) contrast(1.3);  }
  52%  {                                        filter: brightness(1.00) saturate(1.5) contrast(1.15); }
  100% { opacity: 1;    transform: scale(1);    filter: brightness(1.05) saturate(1.5) contrast(1.12); }
}

/* The optic glow thrown by the ignition, pinned to the eye line (46% down). */
.fn-boot__bloom {
  position: absolute;
  inset: 0;
  background: radial-gradient(ellipse 46% 12% at 50% 46%, var(--fn-bloom), transparent 70%);
  mix-blend-mode: screen;
  opacity: 0;
  animation: fn-boot-bloom 3000ms var(--fn-ease-cosine) 1300ms forwards;
}
/* The bloom, not the skull's own brightness, is what sells the ignition — it
   can blow out to white without dragging the chrome or the background with it. */
@keyframes fn-boot-bloom {
  0%, 30% { opacity: 0;    transform: scale(0.5);  }
  38%     { opacity: 1;    transform: scale(1.55); }
  60%     { opacity: 0.5;  transform: scale(1);    }
  /* Idles lit rather than going flat — it is watching. */
  100%    { opacity: 0.66; transform: scale(1.05); }
}

/* ---- Act 3: boot log ---- */

.fn-boot__panel {
  position: relative;
  align-self: end;
  inline-size: min(440px, 84vw);
  margin-block-end: max(6vh, var(--fn-safe-bottom));
  display: grid;
  gap: var(--fn-s2);
  opacity: 0;
  animation: fn-boot-panel 500ms var(--fn-ease-expo) 2600ms forwards;
}
@keyframes fn-boot-panel {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: translateY(0);    }
}

.fn-boot__title {
  margin: 0;
  text-align: center;
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-sm);
  font-weight: 700;
  letter-spacing: 0.34em;
  text-indent: 0.34em; /* balances the trailing letter-space */
  color: var(--fn-boot-title);
  text-shadow: 0 0 18px var(--fn-glow);
  animation: fn-boot-title 1.6s var(--fn-ease-cosine) 2600ms infinite;
}
@keyframes fn-boot-title {
  0%, 100% { opacity: 1;    }
  50%      { opacity: 0.55; }
}

.fn-boot__log {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 4px;
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-muted);
}
/* One line every 220ms, starting when the panel lands. */
.fn-boot__log li {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: baseline;
  gap: 8px;
  opacity: 0;
  animation: fn-boot-line 320ms var(--fn-ease-expo) calc(2800ms + var(--i) * 220ms) forwards;
}
@keyframes fn-boot-line {
  from { opacity: 0; transform: translateX(-8px); }
  to   { opacity: 1; transform: translateX(0);    }
}
/* A leader of dots rather than a fixed-width label column: the widths are
   whatever the strings are, and this fills the gap at any of them. */
.fn-boot__dots {
  block-size: 1px;
  border-block-end: 1px dotted var(--fn-hair);
  transform: translateY(-3px);
}
.fn-boot__status {
  color: var(--fn-encrypt-text);
  font-weight: 700;
}

.fn-boot__bar {
  block-size: 2px;
  border-radius: 2px;
  background: var(--fn-hair);
  overflow: hidden;
}
.fn-boot__bar span {
  display: block;
  block-size: 100%;
  inline-size: 0;
  background: linear-gradient(90deg, var(--fn-beam-deep), var(--fn-beam));
  box-shadow: 0 0 12px var(--fn-glow);
  animation: fn-boot-bar 1700ms var(--fn-ease-expo) 2600ms forwards;
}
@keyframes fn-boot-bar {
  from { inline-size: 0;    }
  to   { inline-size: 100%; }
}

.fn-boot__skip {
  position: absolute;
  inset-block-start: max(16px, var(--fn-safe-top));
  inset-inline-end: max(16px, var(--fn-safe-right));
  align-self: start;
  justify-self: end;
  min-block-size: 32px;
  padding-inline: 14px;
  border: 1px solid var(--fn-border);
  border-radius: var(--fn-r-pill, 999px);
  background: transparent;
  color: var(--fn-fg-faint);
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-xs);
  letter-spacing: 0.18em;
  cursor: pointer;
  transition: color var(--fn-dur) var(--fn-ease), border-color var(--fn-dur) var(--fn-ease);
}
.fn-boot__skip:hover,
.fn-boot__skip:focus-visible {
  color: var(--fn-primary-text);
  border-color: var(--fn-primary-line);
}

@keyframes fn-boot-out {
  from { opacity: 1; }
  to   { opacity: 0; }
}

/* Reduced motion: no arrival, no ignition, no wipe — just the title and the
   roll-call, held long enough to read. §17 already zeroes the durations; this
   removes the pieces whose *meaning* was the movement. */
@media (prefers-reduced-motion: reduce) {
  .fn-boot__sphere,
  .fn-boot__sparks,
  .fn-boot__flash,
  .fn-boot__bloom { display: none; }
  .fn-boot__skull { opacity: 1; filter: brightness(1.1) saturate(1.3); }
  .fn-boot__sky   { opacity: 0.3; }
}

/* === Knowledge (docs/SEARCH.md §5) ======================================== */
/* The search-everything / teach page, plus the #tag chips that appear inside
   chat bubbles. Cards, not bubbles: results are quotations with provenance,
   and drawing them as chat would misclaim who said what where. */

.fn-knowledge {
  display: flex;
  flex-direction: column;
  gap: var(--fn-s3);
  padding: var(--fn-s4);
  max-inline-size: 720px;
  margin-inline: auto;
  inline-size: 100%;
}
.fn-knowledge__tagline { margin: 0; }
.fn-knowledge__tabs { align-self: start; }
.fn-knowledge__searchrow {
  display: flex;
  gap: var(--fn-s2);
  align-items: stretch;
}
.fn-knowledge__busy { display: grid; place-items: center; padding: var(--fn-s5); }
.fn-knowledge__teach { display: flex; flex-direction: column; gap: var(--fn-s3); }
.fn-knowledge__teach > .topcoat-button--cta { align-self: start; }
.fn-knowledge__draft { min-block-size: 96px; resize: vertical; }
.fn-knowledge__hint {
  margin: 0;
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-sm);
}

/* Tag cloud + inline tag chips. One vocabulary everywhere: a #tag looks the
   same in the cloud, in a result card, and inside a chat bubble — and always
   does the same thing (opens Knowledge filtered by it). */
.fn-knowledge__tags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--fn-s1);
}
.fn-tagchip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  border: 0;
  border-radius: var(--fn-r-pill);
  padding: 3px 10px;
  background: var(--fn-primary-soft);
  color: var(--fn-primary-text);
  font: inherit;
  font-size: var(--fn-t-sm);
  cursor: pointer;
}
.fn-tagchip:hover { filter: brightness(1.08); }
.fn-tagchip__count { opacity: 0.7; }
.fn-taglink {
  display: inline;
  border: 0;
  padding: 0;
  background: none;
  color: var(--fn-primary-text);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  text-decoration: underline;
  text-decoration-style: dotted;
  text-underline-offset: 2px;
}
.fn-taglink:hover { text-decoration-style: solid; }

/* Result cards */
.fn-knowledge__results {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--fn-s2);
}
.fn-hitcard {
  border-radius: var(--fn-r-card);
  background: var(--fn-surface-2);
  box-shadow: 0 0 0 1px var(--fn-rim);
  padding: var(--fn-s3);
  display: flex;
  flex-direction: column;
  gap: var(--fn-s1);
}
.fn-hitcard__meta {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  font-size: var(--fn-t-sm);
  color: var(--fn-fg-muted);
  flex-wrap: wrap;
}
.fn-hitcard__kind {
  border: 0;
  padding: 0;
  background: none;
  font: inherit;
  font-weight: 600;
  color: var(--fn-fg);
  cursor: pointer;
}
.fn-hitcard__kind--knowledge { cursor: default; }
.fn-hitcard__time { margin-inline-start: auto; }
.fn-hitcard__text {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  line-height: var(--fn-lh-body);
}
.fn-hitcard__forget { align-self: start; }

/* The consent-gated AI rail. The consent card is deliberately louder than
   the button that summons it: crossing from LAN to cloud is the one action
   on this page that deserves friction. */
.fn-askrail {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  flex-wrap: wrap;
}
.fn-askrail--consent {
  display: block;
  border-radius: var(--fn-r-card);
  background: var(--fn-crown-soft);
  box-shadow: 0 0 0 1px var(--fn-rim);
  padding: var(--fn-s3);
}
.fn-askrail--consent p { margin: var(--fn-s1) 0 var(--fn-s2); }
.fn-askrail--answer {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  border-radius: var(--fn-r-card);
  background: var(--fn-surface-2);
  box-shadow: 0 0 0 1px var(--fn-rim);
  padding: var(--fn-s3);
  gap: var(--fn-s2);
}
.fn-askrail__meta {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: var(--fn-t-sm);
  color: var(--fn-fg-muted);
}
.fn-askrail__text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.fn-askrail--answer > .topcoat-button--quiet { align-self: start; }
.fn-askrail--error { color: var(--fn-danger-text); }

/* === Bank (wallet third menu: swap / tokens / greeter) ==================== */
.fn-bank { display: flex; flex-direction: column; gap: var(--fn-s3); }
.fn-bank__pane { gap: var(--fn-s3); }
.fn-bank__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--fn-s2);
}
.fn-bank__row {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  padding: var(--fn-s2) var(--fn-s3);
  border-radius: var(--fn-r-ctl);
  background: var(--fn-surface-3);
}
.fn-bank__row--stack { flex-direction: column; align-items: stretch; }
.fn-bank__addr {
  font-size: var(--fn-t-sm);
  overflow-wrap: anywhere;
}
.fn-bank__greeting {
  margin: 0;
  padding: var(--fn-s2) var(--fn-s3);
  border-inline-start: 3px solid var(--fn-primary);
  border-radius: var(--fn-r-ctl);
  background: var(--fn-surface-2);
  overflow-wrap: anywhere;
}
.fn-bank__quote {
  margin: 0;
  padding: var(--fn-s2) var(--fn-s3);
  border-radius: var(--fn-r-ctl);
  background: var(--fn-primary-soft);
  color: var(--fn-primary-text);
}
/* Bank dialog additions: network toggle, portfolio hero, banker chat. */
.fn-bank__nets { align-self: start; }
.fn-bank__hero {
  position: relative;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: var(--fn-s3);
  padding: var(--fn-s5) var(--fn-s4);
  min-block-size: 176px;
  border-radius: var(--fn-r-card);
  /* The vault hall (tools/genart.py `bank-vault-hall`) behind a left-heavy
     scrim, so the balance figure sits on near-solid ground and the door
     glows out of the right edge. Deliberately the same image in both
     themes — a bank vault is not a daytime object. */
  background-image:
    linear-gradient(100deg,
      hsl(214 45% 7% / 0.92) 32%,
      hsl(214 45% 7% / 0.55) 62%,
      hsl(214 45% 7% / 0.15)),
    var(--img-bank-vault-hall);
  background-size: cover;
  background-position: center;
  background-color: var(--fn-surface-2);
  color: hsl(210 30% 96%);
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--fn-primary) 30%, transparent),
    var(--fn-sh-2);
}
.fn-bank__hero .fn-muted { color: hsl(210 20% 78%); }
/* One thin scanline drifting down the vault — the cheapest possible sign of
   life, and it never touches layout. Skipped under reduced motion by §17's
   duration flattening. */
.fn-bank__hero::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  block-size: 2px;
  background: linear-gradient(90deg, transparent,
      color-mix(in srgb, var(--fn-primary) 55%, transparent) 50%, transparent);
  opacity: 0.7;
  animation: fn-hero-scan 5.5s linear infinite;
  pointer-events: none;
}
@keyframes fn-hero-scan {
  from { inset-block-start: -2px; }
  to   { inset-block-start: 100%; }
}
.fn-bank__hero-amount {
  font-family: var(--fn-font-display);
  font-size: 28px;
  font-weight: 700;
}
.fn-bank__chat {
  display: flex;
  flex-direction: column;
  gap: var(--fn-s2);
  max-block-size: 300px;
  overflow-y: auto;
}
.fn-bank__msg {
  margin: 0;
  padding: var(--fn-s2) var(--fn-s3);
  border-radius: var(--fn-r-card);
  background: var(--fn-surface-3);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  align-self: flex-start;
  max-inline-size: 90%;
}
.fn-bank__msg--user {
  align-self: flex-end;
  background: var(--fn-primary-soft);
  color: var(--fn-primary-text);
}

.fn-bank__hero-body { display: flex; flex-direction: column; gap: var(--fn-s2); min-inline-size: 0; }
.fn-bank__hero-art {
  flex: none;
  inline-size: 96px;
  block-size: 96px;
  border-radius: var(--fn-r-card);
}
.fn-bank__banker-art {
  inline-size: 88px;
  block-size: 88px;
  border-radius: 50%;
  margin-inline: auto;
}

/* The Bank's network choice is a radio group, not a tab bar — `aria-checked`,
   which the tab styling above never matches, so the chosen network looked
   identical to the other one. And it deserves *louder* than a tab: mainnet
   vs testnet decides whether the next click spends real money. The accent
   fill makes the active chain readable from across the room. */
.fn-bank__nets .fn-tab[aria-checked="true"] {
  background-color: var(--fn-primary);
  background-image: none;
  color: var(--fn-primary-ink);
  font-weight: 700;
  box-shadow: var(--fn-elev-1);
}
.fn-bank__nets .fn-tab[aria-checked="false"] { color: var(--fn-fg-muted); }

/* --- Bank page (route /bank) ---------------------------------------------
   The Bank left its dialog in 2026-07: six tabs, a hero and an agent chat
   need a screen, not a modal. Single column on a phone; from 980px the tabs
   become a left rail and the content column sits beside it, capped at a
   readable measure while the page itself fills the pane. */
.fn-bankpage {
  flex: 1;
  min-block-size: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--fn-s4);
  padding: var(--fn-s4);
  padding-block-end: calc(var(--fn-s6) + var(--fn-safe-bottom));
}
.fn-bankpage__head {
  display: flex;
  align-items: center;
  gap: var(--fn-s3);
  flex-wrap: wrap;
}
.fn-bankpage__art {
  flex: none;
  inline-size: 56px;
  block-size: 56px;
  border-radius: var(--fn-r-card);
  box-shadow: 0 0 0 1px var(--fn-rim);
}
.fn-bankpage__title { min-inline-size: 0; flex: 1 1 240px; }
.fn-bankpage__h1 {
  margin: 0;
  font-family: var(--fn-font-display);
  font-size: var(--fn-t-xl);
  letter-spacing: 0.02em;
}
.fn-bankpage__hint { margin: 0; font-size: var(--fn-t-sm); }
.fn-bankpage__body {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--fn-s4);
  align-items: start;
}
.fn-bankpage__rail {
  /* Phone: the same horizontal, scrollable tab strip every tab bar is. */
  overflow-x: auto;
  scrollbar-width: none;
}
.fn-bankpage__rail::-webkit-scrollbar { display: none; }
.fn-bankpage__tab {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  white-space: nowrap;
}
.fn-bankpage__content {
  min-inline-size: 0;
  inline-size: 100%;
  max-inline-size: 880px;
}
@media (min-width: 980px) {
  .fn-bankpage { padding-inline: var(--fn-s6); }
  .fn-bankpage__body { grid-template-columns: 200px minmax(0, 1fr); }
  .fn-bankpage__rail {
    /* The base .fn-tabs is a column-flow grid (a horizontal strip); on the
       wide layout the strip turns on its side and becomes a rail. */
    grid-auto-flow: row;
    grid-auto-columns: unset;
    grid-auto-rows: min-content;
    overflow: visible;
    position: sticky;
    inset-block-start: 0;
  }
  .fn-bankpage__tab { justify-content: flex-start; }
}

/* Portfolio hero: the number is the screen. Scale it with the viewport and
   let the symbol step back so the amount carries the line. */
.fn-bank__hero-amount { font-size: clamp(28px, 24px + 1.6vw, 44px); }
.fn-bank__hero-symbol {
  font-size: 0.55em;
  color: var(--fn-fg-muted);
  font-weight: 600;
}
.fn-bank__hero-amount[data-loading="true"] { opacity: 0.45; }
[data-spinning="true"] svg { animation: fn-spin 900ms linear infinite; }

/* Quick actions: the four verbs, one tap each, drawn as raised chips. */
.fn-bank__quickrow {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--fn-s2);
}
.fn-bank__quick {
  appearance: none;
  border: 0;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: var(--fn-s3) var(--fn-s2);
  border-radius: var(--fn-r-card);
  background: var(--fn-surface-3);
  color: var(--fn-fg);
  font: inherit;
  font-size: var(--fn-t-sm);
  box-shadow: 0 0 0 1px var(--fn-rim);
  transition: transform var(--fn-dur-fast) var(--fn-ease),
              background-color var(--fn-dur-fast) var(--fn-ease);
}
.fn-bank__quick:hover { background: var(--fn-surface-4); transform: translateY(-1px); }
.fn-bank__quick:active { transform: scale(0.97); }
.fn-bank__quick-icon {
  display: grid;
  place-items: center;
  inline-size: 36px;
  block-size: 36px;
  border-radius: 50%;
  background: var(--fn-primary-soft);
  color: var(--fn-primary-text);
}

/* Receive: the full address, big and copyable — the one moment truncation
   would be actively dangerous. */
.fn-bank__receive {
  display: flex;
  flex-direction: column;
  gap: var(--fn-s2);
  padding: var(--fn-s3);
  border-radius: var(--fn-r-card);
  background: var(--fn-surface-2);
  box-shadow: 0 0 0 1px var(--fn-rim);
}
.fn-bank__receive-addr {
  font-size: var(--fn-t-md);
  overflow-wrap: anywhere;
  user-select: all;
}
.fn-bank__receive > .topcoat-button { align-self: start; }

/* Token rows are buttons now — tap to send. Reset the button chrome and
   keep the row look. */
.fn-bank__row--press {
  appearance: none;
  border: 0;
  font: inherit;
  color: inherit;
  text-align: start;
  cursor: pointer;
  inline-size: 100%;
  transition: background-color var(--fn-dur-fast) var(--fn-ease),
              transform var(--fn-dur-fast) var(--fn-ease);
}
.fn-bank__row--press:hover { background: var(--fn-surface-4); }
.fn-bank__row--press:active { transform: scale(0.995); }
.fn-bank__badge {
  flex: none;
  display: grid;
  place-items: center;
  inline-size: 34px;
  block-size: 34px;
  border-radius: 50%;
  font-size: var(--fn-t-xs);
  font-weight: 700;
  letter-spacing: 0.04em;
  color: hsl(var(--tok-h) 70% 88%);
  background: linear-gradient(135deg,
      hsl(var(--tok-h) 55% 32%),
      hsl(calc(var(--tok-h) + 28) 60% 22%));
  box-shadow: inset 0 0 0 1px hsl(var(--tok-h) 60% 50% / 0.45);
}
.fn-bank__row-name {
  display: flex;
  flex-direction: column;
  min-inline-size: 0;
}
.fn-bank__row-name .fn-muted { font-size: var(--fn-t-sm); }
.fn-bank__footnote { font-size: var(--fn-t-sm); margin: 0; }

/* --- AI Banker (executing agent) ----------------------------------------- */
.fn-banker__toolbar { align-items: center; }
.fn-banker__face {
  inline-size: 128px;
  block-size: 128px;
  border-radius: 50%;
  margin-inline: auto;
  background-position: top center;
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--fn-primary) 45%, transparent),
    0 0 28px 0 color-mix(in srgb, var(--fn-primary) 30%, transparent);
  animation: fn-bob var(--fn-dur-float) var(--fn-ease) infinite;
}
.fn-banker__chat {
  max-block-size: min(52vh, 560px);
  min-block-size: 240px;
  padding: var(--fn-s2);
  border-radius: var(--fn-r-card);
  background: var(--fn-surface-2);
  box-shadow: inset 0 0 0 1px var(--fn-rim);
}
.fn-banker__msg { display: flex; flex-direction: column; gap: var(--fn-s2); }
.fn-banker__text { white-space: pre-wrap; overflow-wrap: anywhere; }
.fn-banker__imgs { display: flex; flex-wrap: wrap; gap: var(--fn-s2); }
.fn-banker__img {
  max-inline-size: min(320px, 100%);
  border-radius: var(--fn-r-ctl);
  box-shadow: 0 0 0 1px var(--fn-rim);
}
.fn-banker__chips { display: flex; flex-wrap: wrap; gap: 4px; }
.fn-banker__chip {
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-xs);
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--fn-primary-soft);
  color: var(--fn-primary-text);
  animation: fn-fade-in var(--fn-dur-soft) var(--fn-ease) both;
}
/* The turn-in-progress bubble: a stage label over an indeterminate sweep. */
.fn-banker__progress {
  display: flex;
  flex-direction: column;
  gap: var(--fn-s2);
  min-inline-size: min(260px, 80%);
}
.fn-banker__bar {
  display: block;
  block-size: 3px;
  border-radius: 999px;
  overflow: hidden;
  background: var(--fn-sunk-a);
  position: relative;
}
.fn-banker__bar::after {
  content: "";
  position: absolute;
  inset-block: 0;
  inline-size: 40%;
  border-radius: inherit;
  background: linear-gradient(90deg, transparent, var(--fn-primary), transparent);
  animation: fn-banker-sweep 1.1s var(--fn-ease) infinite;
}
@keyframes fn-banker-sweep {
  from { inset-inline-start: -40%; }
  to   { inset-inline-start: 100%; }
}
/* Suggestion chips on the empty state. */
.fn-banker__sugs {
  display: flex;
  flex-wrap: wrap;
  gap: var(--fn-s2);
  justify-content: center;
}
.fn-banker__sug {
  appearance: none;
  border: 0;
  cursor: pointer;
  font: inherit;
  font-size: var(--fn-t-sm);
  padding: 6px 12px;
  border-radius: 999px;
  background: var(--fn-surface-3);
  color: var(--fn-fg);
  box-shadow: 0 0 0 1px var(--fn-rim);
  transition: background-color var(--fn-dur-fast) var(--fn-ease);
}
.fn-banker__sug:hover { background: var(--fn-surface-4); }
/* The approval dialog's body. */
.fn-banker__approve-title {
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-sm);
  padding: var(--fn-s2) var(--fn-s3);
  border-radius: var(--fn-r-ctl);
  background: var(--fn-surface-3);
  overflow-wrap: anywhere;
}
.fn-banker__approve-lines {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: var(--fn-s1) var(--fn-s3);
  margin: 0;
}
.fn-banker__approve-lines dt { color: var(--fn-fg-muted); }
.fn-banker__approve-lines dd { margin: 0; overflow-wrap: anywhere; }

/* === Quick search (main page) ============================================ */
/* One glowing field above the room list — the front door to the knowledge
   index. Deliberately the loudest chrome on the main page: "everything you
   write here is findable" is the product's promise, and this is where it is
   kept. The chip states the contract: AI Search answers (a provider key is
   configured and will be used); Search only retrieves. */
.fn-quicksearch {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  margin: var(--fn-s3) var(--fn-s3) 0;
  padding: var(--fn-s1) var(--fn-s2) var(--fn-s1) var(--fn-s2);
  border-radius: var(--fn-r-pill);
  background: var(--fn-surface-2);
  box-shadow:
    0 0 0 1.5px var(--fn-primary),
    0 0 20px color-mix(in srgb, var(--fn-primary) 30%, transparent);
}
.fn-quicksearch:focus-within {
  box-shadow:
    0 0 0 2px var(--fn-primary),
    0 0 28px color-mix(in srgb, var(--fn-primary) 45%, transparent);
}
.fn-quicksearch__chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 3px 9px;
  border-radius: var(--fn-r-pill);
  background: var(--fn-primary);
  color: var(--fn-primary-ink);
  font-size: 11px;
  font-weight: 800;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  white-space: nowrap;
  flex: none;
}
.fn-quicksearch__input {
  flex: 1;
  min-inline-size: 0;
  border: 0;
  background: none;
  color: var(--fn-fg);
  font: inherit;
  outline: none;
}
.fn-quicksearch__input::placeholder { color: var(--fn-fg-muted); }
.fn-quicksearch__go { flex: none; }
/* The Settings page's AI-keys section: the list container is built for
   rows, so the embedded key editor brings its own breathing room. */
.fn-settings__ai { padding: var(--fn-s3) var(--fn-s3) var(--fn-s4); gap: var(--fn-s3); }

/* ==========================================================================
   18.2  ATTACHMENTS — the Files drawer and the composer's attach pair.
   Additive, like the rest of §18: new class names only, and every animation
   is already covered by §17's reduced-motion kill switch.
   ========================================================================== */

/* The empty state's artwork, generated to match the family (tools/genart.py). */
.fn-art--files {
  --fn-art-light: var(--img-empty-files);
  --fn-art-dark: var(--img-empty-files-dark);
}

/* THE TAG RAIL. A horizontal scroller rather than a wrapping cloud: a room
   with thirty tags would otherwise push the file list off the screen, and the
   rail is a filter, not the content. */
.fn-files__rail {
  display: flex;
  gap: var(--fn-s1);
  overflow-x: auto;
  overscroll-behavior-x: contain;
  padding-block-end: var(--fn-s2);
  margin-block-end: var(--fn-s3);
  border-block-end: 1px solid var(--fn-hair);
  scrollbar-width: thin;
}
.fn-files__tag {
  flex: 0 0 auto;
  padding: 3px 10px;
  border-radius: var(--fn-r-pill);
  border: 1px solid var(--fn-border);
  background: var(--fn-surface-3);
  color: var(--fn-fg-muted);
  font-size: var(--fn-t-xs);
  font-weight: 700;
  letter-spacing: var(--fn-tr-label);
  cursor: pointer;
  transition:
    background-color var(--fn-dur-fast) var(--fn-ease),
    border-color var(--fn-dur-fast) var(--fn-ease),
    color var(--fn-dur-fast) var(--fn-ease),
    transform var(--fn-dur-snappy) var(--fn-spring-snappy);
}
@media (hover: hover) {
  .fn-files__tag:hover { background: var(--fn-surface-4); transform: scale(var(--fn-tap-hover)); }
}
.fn-files__tag:active { transform: scale(var(--fn-tap-press)); }
.fn-files__tag[aria-pressed="true"] {
  background: var(--fn-primary-soft);
  border-color: var(--fn-primary-line);
  color: var(--fn-primary-text);
  box-shadow: 0 0 12px var(--fn-glow);
}

/* THE LIST. */
.fn-files {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--fn-s2);
}

.fn-file {
  display: grid;
  /* plate · body · tools. The body is the only flexible column, which is what
     lets a 60-character filename truncate instead of pushing the tools out. */
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--fn-s3);
  padding: var(--fn-s2) var(--fn-s3);
  border: 1px solid var(--fn-border);
  border-radius: var(--fn-r-card);
  background: linear-gradient(180deg, var(--fn-raise-a), var(--fn-raise-b));
  transition:
    border-color var(--fn-dur-fast) var(--fn-ease),
    box-shadow var(--fn-dur) var(--fn-ease),
    transform var(--fn-dur-snappy) var(--fn-spring-snappy);
  /* Staggered entrance, driven by the inline `--i` the row carries. */
  animation: fn-list-in var(--fn-dur-snappy) var(--fn-ease-expo) both;
  animation-delay: min(calc(var(--i, 0) * var(--fn-stagger)), var(--fn-stagger-max));
}
@media (hover: hover) {
  .fn-file:hover {
    border-color: var(--fn-primary-line);
    box-shadow: 0 0 18px color-mix(in srgb, var(--fn-glow) 60%, transparent);
  }
}

/* THE PLATE. A square that holds either a thumbnail or the extension. The
   extension *is* the icon: "PDF" in tabular caps identifies a file faster than
   any glyph, and it needs no artwork per format. */
.fn-file__plate {
  position: relative;
  display: grid;
  place-items: center;
  inline-size: 42px;
  block-size: 42px;
  border-radius: var(--fn-r-ctl);
  border: 1px solid var(--fn-primary-line);
  background: var(--fn-primary-soft);
  color: var(--fn-primary-text);
  overflow: hidden;
  flex: 0 0 auto;
}
.fn-file__ext {
  font-family: var(--fn-font-mono);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.06em;
  /* A five-character extension in a 42px box needs the squeeze. */
  max-inline-size: 100%;
  padding-inline: 2px;
  overflow: hidden;
}
.fn-file__thumb {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
  display: block;
}

.fn-file__body {
  display: grid;
  gap: 2px;
  min-inline-size: 0;
}
.fn-file__name {
  font-size: var(--fn-t-sm);
  font-weight: 600;
  color: var(--fn-fg);
}
.fn-file__meta {
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-faint);
}
.fn-file__tags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--fn-s1);
  margin-block-start: 2px;
}
.fn-file__tag {
  font-size: var(--fn-t-xs);
  font-weight: 700;
  color: var(--fn-primary-text);
  /* Not a button: these are labels on the row, and the rail above is where
     filtering happens. Two ways to filter in one dialog is one too many. */
  opacity: 0.9;
}

.fn-file__tools {
  display: flex;
  gap: var(--fn-s1);
  flex: 0 0 auto;
}
.fn-file__danger:hover { color: var(--fn-danger-text); }

/* The attach pair carries the same glow the AI button does, so the composer
   row reads as one family of controls rather than two generations of it. */
.fn-composer__attach:hover:not([disabled]),
.fn-composer__files:hover:not([disabled]) {
  color: var(--fn-primary-text);
}

@media (max-width: 480px) {
  /* The plate is the first thing to give up space on a phone. */
  .fn-file__plate { inline-size: 36px; block-size: 36px; }
  .fn-file { gap: var(--fn-s2); }
}

/* --- Attachments inside a message (message.rs::AttachmentEmbed) -----------
   An attachment posted to a room renders as a card in the bubble rather than as
   the bare path it literally is. The path stays visible inside the card: it is
   the one piece of an attachment that is stable, quotable, and the same for
   everyone, and hiding it would make the feature feel like magic rather than
   like a file.

   Media is `<img>`/`<video>` on an object URL, because the download is
   authenticated and there is no src a browser can be pointed at. */
.fn-attach {
  display: block;
  position: relative;
  margin-block: var(--fn-s1);
  max-inline-size: min(420px, 100%);
}
.fn-attach--loading {
  display: grid;
  place-items: center;
  min-inline-size: 140px;
  min-block-size: 72px;
  border-radius: var(--fn-r-card);
  background: var(--fn-surface-3);
}

/* `cover`, not `contain`. `contain` letterboxes: a portrait photo in a
   landscape box gets black bars down both sides and the card is mostly not the
   picture. `cover` scales on whichever axis is short and crops the other, so
   the frame is always full. What that costs — the edges of an off-ratio image
   — costs nothing here, because the whole picture is a zoom target and one tap
   shows it whole. A preview is a preview; the original is one tap away. */
.fn-attach__media {
  display: block;
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
  background: var(--fn-surface-3);
}
/* The image sits in a button so the whole picture is the "open" target — a
   16px icon is a poor hit area for something 420px wide.
   The fixed ratio is what gives `cover` something to fill: without it the box
   is the image's own shape and there is nothing to crop to. Square, because a
   chat carries both photographs and screenshots and it is the only ratio that
   is not actively wrong for one of them — 4:3 reduces a portrait of a person
   to a horizontal band across their middle, which is the common case here and
   the worst thing this could do. A wide screenshot loses a little off each
   side instead, and one tap still opens it whole. */
.fn-attach__shot {
  display: block;
  position: relative;
  inline-size: 100%;
  aspect-ratio: 1 / 1;
  max-block-size: 380px;
  padding: 0;
  border: 0;
  overflow: hidden;
  border-radius: var(--fn-r-card);
  background: var(--fn-surface-3);
  cursor: zoom-in;
  transition: filter var(--fn-dur-fast) var(--fn-ease);
}
@media (hover: hover) {
  .fn-attach__shot:hover .fn-attach__media { filter: brightness(1.08); }
}
/* A *playing* video is a bare media element with no `__shot` around it, so it
   has no ratio box to fill and must size itself. It also keeps `contain`:
   cropping a still is a preview decision, but cropping a video someone is
   watching cuts the frame they are watching. */
.fn-attach > .fn-attach__media {
  block-size: auto;
  max-block-size: 380px;
  object-fit: contain;
  border-radius: var(--fn-r-card);
  background: #000;
}

.fn-attach__row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--fn-s2);
  padding: var(--fn-s2);
  border: 1px solid var(--fn-border);
  border-radius: var(--fn-r-card);
  background: linear-gradient(180deg, var(--fn-raise-a), var(--fn-raise-b));
}
/* With media above it the row stops being a card of its own and becomes a
   caption *on* the picture. Stacked, it doubled the height of every image
   message to carry a filename and a path — for a photo, the photo is the
   content and the metadata is a footnote, so the footnote goes over it.
   Absolute rather than a last flex row: the row must not take space from the
   image, or the ratio above stops being the ratio. */
.fn-attach__media + .fn-attach__row,
.fn-attach__shot + .fn-attach__row {
  position: absolute;
  inset-inline: 0;
  inset-block-end: 0;
  border: 0;
  border-radius: 0 0 var(--fn-r-card) var(--fn-r-card);
  padding: var(--fn-s3) var(--fn-s2) var(--fn-s2);
  /* A scrim, not a solid bar: the top of it has to be invisible against an
     arbitrary photograph, and a hard edge across someone's face is worse than
     the wasted space this replaces. */
  background: linear-gradient(
    to top,
    rgb(0 0 0 / 0.78) 0%,
    rgb(0 0 0 / 0.55) 45%,
    rgb(0 0 0 / 0) 100%
  );
  /* Text over an unknown image cannot inherit theme colours — a dark filename
     on a dark photo is invisible in either theme. */
  color: #fff;
  text-shadow: 0 1px 3px rgb(0 0 0 / 0.65);
  pointer-events: none;
}
/* …but the buttons in it still have to be clickable. */
.fn-attach__media + .fn-attach__row .fn-attach__tools,
.fn-attach__shot + .fn-attach__row .fn-attach__tools {
  pointer-events: auto;
}
.fn-attach__media + .fn-attach__row .fn-attach__name,
.fn-attach__shot + .fn-attach__row .fn-attach__name,
.fn-attach__media + .fn-attach__row .fn-attach__meta,
.fn-attach__shot + .fn-attach__row .fn-attach__meta {
  color: #fff;
}
.fn-attach__media + .fn-attach__row .topcoat-icon-button--quiet,
.fn-attach__shot + .fn-attach__row .topcoat-icon-button--quiet {
  color: #fff;
}
/* The extension plate and the raw path are the two things worth dropping when
   the picture is visible: the plate says "JPG" about something you can see is
   a photograph, and the path is two lines of URL that the download button
   makes unnecessary. Both stay for a non-image attachment, where the row is
   the whole card and they are all there is to go on. */
.fn-attach__media + .fn-attach__row .fn-attach__plate,
.fn-attach__shot + .fn-attach__row .fn-attach__plate,
.fn-attach__media + .fn-attach__row .fn-attach__path,
.fn-attach__shot + .fn-attach__row .fn-attach__path {
  display: none;
}
.fn-attach__media + .fn-attach__row,
.fn-attach__shot + .fn-attach__row {
  grid-template-columns: minmax(0, 1fr) auto;
}

.fn-attach__plate {
  display: grid;
  place-items: center;
  inline-size: 34px;
  block-size: 34px;
  border-radius: var(--fn-r-xs);
  border: 1px solid var(--fn-primary-line);
  background: var(--fn-primary-soft);
  color: var(--fn-primary-text);
  overflow: hidden;
}
.fn-attach__ext {
  font-family: var(--fn-font-mono);
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.04em;
  padding-inline: 2px;
  overflow: hidden;
}

.fn-attach__body {
  display: grid;
  gap: 1px;
  min-inline-size: 0;
}
.fn-attach__name {
  font-size: var(--fn-t-sm);
  font-weight: 600;
  color: var(--fn-fg);
}
.fn-attach__meta {
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-faint);
}
/* The full path. Selectable, wrapping, and deliberately not an anchor: it needs
   a bearer token, so a link would 401 and teach the wrong thing. */
.fn-attach__path {
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-muted);
  overflow-wrap: anywhere;
  user-select: all;
}
.fn-attach__tools {
  display: flex;
  gap: var(--fn-s1);
}

@media (max-width: 480px) {
  .fn-attach { max-inline-size: 100%; }
  .fn-attach__shot { max-block-size: 300px; }
}

/* ==========================================================================
   18.3  THE SIGN-IN BACKDROP, ALIVE
   The guardian portrait was a static PNG: correct, cinematic, and completely
   still, which on a screen you sit and look at for thirty seconds reads as a
   wallpaper rather than as a machine that is running.

   Three additive layers, all on pseudo-elements of `.fn-login__aside` so the
   artwork itself and its five per-layout scrim rules are untouched. Only
   `transform` and `opacity` animate — the §18 rule — so this is compositor
   work regardless of how large the photograph is.

   Everything is biased to the *far* side of the frame and masked away from the
   form. The scrims darken toward the card precisely so the text has contrast,
   and a glow painted over that would spend it.
   ========================================================================== */

/* Both layers ride inside the aside, which is already behind the form, so
   nothing here can land on top of an input. */
.fn-login__aside::before,
.fn-login__aside::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* Fade out toward the form. Without this the sweep crosses the card's
     backdrop and the "one dark plate under the text" illusion breaks. */
  -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 34%, #000 100%);
  mask-image: linear-gradient(90deg, transparent 0%, #000 34%, #000 100%);
}

/* THE SCAN. A targeting pass: a hairline with a soft band behind it, sweeping
   top to bottom and then absent for most of the cycle. The gap is the point —
   a beam that never stops reads as a screensaver, one that passes every seven
   seconds reads as something checking. */
.fn-login__aside::before {
  background:
    linear-gradient(
      to bottom,
      transparent 0%,
      color-mix(in srgb, var(--fn-primary) 8%, transparent) 42%,
      color-mix(in srgb, var(--fn-primary) 26%, transparent) 49%,
      color-mix(in srgb, #fff 30%, transparent) 50%,
      color-mix(in srgb, var(--fn-primary) 26%, transparent) 51%,
      color-mix(in srgb, var(--fn-primary) 8%, transparent) 58%,
      transparent 100%);
  /* Two frames tall, so 50% of the gradient is one screen height of travel. */
  block-size: 200%;
  will-change: transform, opacity;
  animation: fn-login-scan 7s var(--fn-ease-cosine) infinite;
}
@keyframes fn-login-scan {
  0%   { opacity: 0;    transform: translateY(-52%); }
  6%   { opacity: 0.85; }
  46%  { opacity: 0.85; }
  56%  { opacity: 0;    transform: translateY(2%); }
  100% { opacity: 0;    transform: translateY(2%); }
}

/* THE OPTIC. A cyan bloom over the machine half's eye, flickering on `steps`
   so it reads as a lit element rather than a soft light. Two radials: the
   tight core on the eye, and a broad atmospheric wash that drifts, which is
   what keeps the whole frame from being visibly static between scans.

   The position follows the artwork's own crop bias (the head sits right of
   centre, the optic a little above the middle), so it stays on the eye as the
   frame narrows. */
/* The position and the core's strength are tokens, and that is not tidiness.
   `74% 46%` is *the base skin's machine eye* — a coordinate measured off one
   photograph. Every skin inherits the coordinate, so on a skin whose subject is
   a human face the bloom lands on a cheekbone and paints a glowing optic onto
   somebody who is not supposed to have one. On the human skin that is not a
   cosmetic mismatch; it is the interface giving away the exact thing the
   artwork spends sixty-two images withholding. §1c moves it onto the screens
   behind them and turns the core down to nothing. */
.fn-login__aside::after {
  background:
    radial-gradient(
      circle 12vmin at var(--fn-login-optic-pos, 74% 46%),
      color-mix(in srgb, var(--fn-primary) var(--fn-login-optic-core, 34%), transparent) 0%,
      transparent 70%),
    radial-gradient(
      ellipse 60vmin 46vmin at var(--fn-login-wash-pos, 78% 52%),
      color-mix(in srgb, var(--fn-primary) var(--fn-login-wash, 9%), transparent) 0%,
      transparent 72%);
  mix-blend-mode: screen;
  will-change: transform, opacity;
  animation:
    fn-login-optic 2.6s steps(1, end) infinite,
    fn-login-drift 19s var(--fn-ease-cosine) infinite alternate;
}
/* Stepped, uneven, and never fully dark: a machine's indicator does not
   breathe, and an optic that switches off entirely reads as broken. */
@keyframes fn-login-optic {
  0%   { opacity: 0.9; }
  38%  { opacity: 0.55; }
  46%  { opacity: 1; }
  62%  { opacity: 0.7; }
  78%  { opacity: 0.95; }
  100% { opacity: 0.9; }
}
/* The only movement in the frame. Small on purpose — this is atmosphere, and
   a backdrop that visibly slides pulls the eye off the form. */
@keyframes fn-login-drift {
  from { transform: translate3d(0, 0, 0) scale(1); }
  to   { transform: translate3d(-1.6%, -1.1%, 0) scale(1.06); }
}

/* §17 kills durations globally, but these three are *loops*: flattened to 1ms
   they would sit at a final keyframe forever — the scan frozen mid-frame as a
   bright band across the portrait. They have to be removed, not shortened. */
@media (prefers-reduced-motion: reduce) {
  .fn-login__aside::before { display: none; }
  .fn-login__aside::after { animation: none; opacity: 0.9; }
}

/* THE SLOW PUSH. The portrait itself, drifting — 24 seconds from rest to a 5%
   scale, and back. Without this the photograph is the one motionless thing on
   a screen you sit in front of for half a minute, and the scan reads as an
   effect playing *over* a picture rather than the picture being alive.

   Applied to the element, so the artwork and its scrim move together and the
   fade stays welded to the image edge. Scaling only ever *up*, so a viewport
   filling layer cannot expose a gap at any point in the cycle.

   `transform` on a `position: fixed` element makes it a containing block for
   fixed descendants; it has none, so this is free. */
.fn-login__aside {
  will-change: transform;
  animation: fn-login-push 24s var(--fn-ease-cosine) infinite alternate;
}
@keyframes fn-login-push {
  from { transform: scale(1) translate3d(0, 0, 0); }
  to   { transform: scale(1.05) translate3d(-1.2%, 0.8%, 0); }
}

/* ...but **not** when the aside is a banner rather than a full-bleed layer.
   In the two rules below it is `position: absolute` with a fixed `block-size`,
   and scaling a box up while its own background paints past the edge would push
   the artwork down over the form. There is nothing to clip it against: a
   transform is applied after `overflow` and `clip-path` have had their say, so
   the only reliable fix is not to transform it. The scan and the optic still
   run — those are inset children and stay inside. */
@media (max-width: 700px) and (orientation: portrait) {
  .fn-login__aside { animation: none; }
}
.fn-login[data-layout="vertical"] .fn-login__aside { animation: none; }

@media (prefers-reduced-motion: reduce) {
  .fn-login__aside { animation: none; }
}

/* THE GPU LAYER (components/backdrop.rs).
   Sits inside the aside, so it inherits the aside's box in every one of the five
   layouts without repeating any of them, and stays behind the form because the
   aside already does.

   `data-live` is false until the artwork has decoded and the first frame is
   drawn. Without that gate a slow decode shows as a black rectangle where the
   portrait was — worse than the static image it is replacing. The fade is long
   because the two pictures are the same photograph: the handover should be
   imperceptible rather than a visible swap. */
.fn-login__gl {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  display: block;
  pointer-events: none;
  opacity: 0;
  transition: opacity 900ms var(--fn-ease-cosine);
}
.fn-login__gl[data-live="true"] { opacity: 1; }

/* With the shader live, the CSS scan and optic would double up on effects the
   fragment stage is already drawing per-pixel. The slow push stays: it moves the
   *element*, which the canvas rides along with. */
.fn-login__aside:has(.fn-login__gl[data-live="true"])::before,
.fn-login__aside:has(.fn-login__gl[data-live="true"])::after {
  display: none;
}

@media (prefers-reduced-motion: reduce) {
  /* backdrop.rs declines to start under this preference, so the canvas is
     already blank; hiding it keeps it from compositing an empty layer. */
  .fn-login__gl { display: none; }
}

/* The GPU layer is a **full-bleed-only** enhancement.
   In the two banner layouts the aside is a short strip with its own tuned crop
   (`58% 18%`) and a bottom fade into the page, and the canvas defeated both: it
   painted over the fade, leaving a hard cut where the artwork met the form, and
   its own `cover` maths framed the crown of the head instead of the face.

   Reproducing the CSS crop and the scrim inside the shader for a layout that is
   a 376px decorative strip is not worth the two places it would then have to be
   kept in step. The CSS backdrop is complete on its own here, and skipping the
   shader also spares a phone the GPU work. `backdrop.rs` declines to start when
   the canvas has no layout box, so this turns the render loop off too. */
@media (max-width: 700px) and (orientation: portrait) {
  .fn-login__gl { display: none; }
}
.fn-login[data-layout="vertical"] .fn-login__gl { display: none; }

/* ...and with the shader gone, the CSS scan and optic must come back. The
   `:has()` rule above only hides them while a *live* canvas is present. */
.fn-login[data-layout="vertical"] .fn-login__aside::before,
.fn-login[data-layout="vertical"] .fn-login__aside::after {
  display: block;
}
@media (max-width: 700px) and (orientation: portrait) {
  .fn-login__aside::before,
  .fn-login__aside::after { display: block; }
}

/* --- Browser-wallet sign-in (eip1193.rs) ---------------------------------
   A second hero button under "create a wallet". Deliberately *not* a `--cta`:
   creating a wallet is the path this product recommends, and two equally loud
   buttons would make the choice look arbitrary. This one is present, legible
   and obviously secondary. */
.fn-hero-btn--wallet {
  margin-block-start: var(--fn-s2);
  inline-size: 100%;
  border-color: var(--fn-border);
  background: linear-gradient(180deg, var(--fn-raise-a), var(--fn-raise-b));
}
@media (hover: hover) {
  .fn-hero-btn--wallet:hover:not([disabled]) {
    border-color: var(--fn-primary-line);
    box-shadow: 0 0 16px color-mix(in srgb, var(--fn-glow) 55%, transparent);
  }
}
.fn-hero-btn--wallet[disabled] { opacity: 0.7; }

/* --- "Trust this server" (login.rs) --------------------------------------
   Collapsed by default. Most people never need it — a desktop browser lets you
   click through the warning once and forget it — but the person who *does* need
   it needs it badly, and cannot be detected: they are on a phone in MetaMask's
   in-app browser, looking at a certificate warning with no bypass, so they never
   reach this page. The link lives here for the browser that did get through, to
   fix the one that did not. */
.fn-trust {
  margin-block-start: var(--fn-s3);
  padding: var(--fn-s2) var(--fn-s3);
  border: 1px solid var(--fn-border);
  border-radius: var(--fn-r-card);
  background: var(--fn-surface-2);
  font-size: var(--fn-t-xs);
}
.fn-trust > summary {
  cursor: pointer;
  font-weight: 700;
  color: var(--fn-fg-muted);
  letter-spacing: var(--fn-tr-label);
  list-style: revert;
}
.fn-trust[open] > summary { color: var(--fn-fg); margin-block-end: var(--fn-s2); }
.fn-trust__why,
.fn-trust__steps {
  margin: 0;
  color: var(--fn-fg-faint);
  line-height: var(--fn-lh-body);
}
.fn-trust__steps { margin-block-start: var(--fn-s2); }
.fn-trust__get {
  display: inline-flex;
  align-items: center;
  gap: var(--fn-s1);
  margin-block-start: var(--fn-s2);
  padding: 5px 10px;
  border-radius: var(--fn-r-ctl);
  border: 1px solid var(--fn-primary-line);
  background: var(--fn-primary-soft);
  color: var(--fn-primary-text);
  font-weight: 700;
  text-decoration: none;
}
@media (hover: hover) {
  .fn-trust__get:hover { box-shadow: 0 0 14px var(--fn-glow); }
}
/* The refresh step is a <button>, so strip the UA chrome the <a> never had
   and keep the pair visually identical; block display stacks it under the
   iOS instructions as step 2. */
.fn-trust__reload {
  display: flex;
  font: inherit;
  cursor: pointer;
}

/* === Profile-image picker (Settings → Profile image) ====================== */
/* The gallery is a grid of square portraits; the selected one wears the
   primary ring the way the "this is you" avatar does. Additive: new class
   names only, no animation beyond a hover glow (§17-safe). */

.fn-avatar__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(76px, 1fr));
  gap: var(--fn-s2);
}
.fn-avatar__option {
  padding: 0;
  border: 2px solid var(--fn-line);
  border-radius: 18%;
  background: var(--fn-bg-inset);
  cursor: pointer;
  overflow: hidden;
  aspect-ratio: 1;
  line-height: 0;
}
.fn-avatar__option img {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
  display: block;
}
.fn-avatar__option[aria-selected="true"] {
  border-color: var(--fn-primary);
  box-shadow: 0 0 10px var(--fn-glow);
}
.fn-avatar__option:disabled { opacity: 0.6; cursor: default; }
@media (hover: hover) {
  .fn-avatar__option:hover:not(:disabled) { border-color: var(--fn-primary-line); }
}

/* ==========================================================================
   SHOUT — the paid broadcast (docs/API.md §16.1, components/shout.rs)

   Someone paid real CRO to put one line on every connected screen for a
   minute. The banner behaves accordingly: it POWERS ON rather than fades in
   (expo attack, §14's house curve), carries a scanline sweep and a breathing
   glow while it burns, and drains a meter so everyone can see how much of
   the minute is left. The ✕ dismisses it on this screen only.

   Layered at 250: above the toasts (200) — a paid interruption outranks a
   free one — but below the modal backdrop's focus trap territory is fine,
   because a shout must never block input: pointer-events pass through the
   layer and only the banner itself is interactive.
   ========================================================================== */

.fn-shout-layer {
  position: fixed;
  z-index: 250;
  inset-block-start: calc(var(--fn-s3) + var(--fn-safe-top, 0px));
  inset-inline: max(var(--fn-s3), var(--fn-safe-left, 0px)) max(var(--fn-s3), var(--fn-safe-right, 0px));
  display: grid;
  justify-items: center;
  gap: var(--fn-s2);
  pointer-events: none;
}

.fn-shout {
  pointer-events: auto;
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--fn-s3);
  inline-size: min(100%, 640px);
  padding: var(--fn-s2) var(--fn-s3);
  border: 1px solid var(--fn-primary-line);
  border-radius: 14px;
  background:
    linear-gradient(180deg, var(--fn-surface-2), var(--fn-surface));
  overflow: hidden;
  box-shadow:
    0 12px 40px rgb(0 0 0 / 0.45),
    0 0 0 1px var(--fn-primary-soft),
    0 0 24px var(--fn-glow);
  animation:
    fn-shout-on var(--fn-dur-expo) var(--fn-ease-expo) backwards,
    fn-shout-breathe 2.4s var(--fn-ease-cosine) calc(var(--fn-dur-expo)) infinite;
  animation-delay: calc(var(--i, 0) * 90ms), calc(var(--fn-dur-expo) + var(--i, 0) * 90ms);
}

/* Power-on: a thin bright line that snaps open vertically, with one
   overshoot flash — the same grammar as the boot cutscene, one size down. */
@keyframes fn-shout-on {
  0%   { transform: scaleY(0.06); opacity: 0; filter: brightness(3); }
  45%  { transform: scaleY(1.04); opacity: 1; filter: brightness(1.6); }
  100% { transform: scaleY(1); opacity: 1; filter: brightness(1); }
}

/* While it burns, the border glow breathes. Compositor-only. */
@keyframes fn-shout-breathe {
  0%, 100% { box-shadow: 0 12px 40px rgb(0 0 0 / 0.45), 0 0 0 1px var(--fn-primary-soft), 0 0 18px var(--fn-glow); }
  50%      { box-shadow: 0 12px 40px rgb(0 0 0 / 0.45), 0 0 0 1px var(--fn-primary-soft), 0 0 34px var(--fn-glow); }
}

/* The scanline: one bright band sweeping the banner every few seconds, the
   HUD idiom that says "live transmission" everywhere else in the shell. */
.fn-shout__scan {
  position: absolute;
  inset: 0;
  background: linear-gradient(
    105deg,
    transparent 40%,
    var(--fn-primary-wash) 50%,
    transparent 60%
  );
  transform: translateX(-100%);
  animation: fn-shout-scan 3.2s var(--fn-ease-cosine) infinite;
  pointer-events: none;
}

@keyframes fn-shout-scan {
  0%       { transform: translateX(-100%); }
  55%, 100% { transform: translateX(100%); }
}

.fn-shout__herald {
  inline-size: 44px;
  block-size: 44px;
  border-radius: 50%;
  object-fit: cover;
  border: 1px solid var(--fn-primary-line);
  box-shadow: 0 0 12px var(--fn-glow);
  flex-shrink: 0;
}

.fn-shout__body {
  min-inline-size: 0;
  flex: 1;
  display: grid;
  gap: 2px;
}

.fn-shout__from {
  display: inline-flex;
  align-items: center;
  gap: var(--fn-s1);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--fn-primary-text);
}

.fn-shout__text {
  margin: 0;
  font-size: 1rem;
  font-weight: 600;
  line-height: 1.35;
  color: var(--fn-fg);
  overflow-wrap: anywhere;
}

.fn-shout__close {
  flex-shrink: 0;
  display: grid;
  place-items: center;
  inline-size: 32px;
  block-size: 32px;
  border: 1px solid transparent;
  border-radius: 10px;
  background: transparent;
  color: var(--fn-fg-muted);
  cursor: pointer;
}
.fn-shout__close:hover {
  color: var(--fn-fg);
  border-color: var(--fn-border);
  background: var(--fn-surface-3);
}
.fn-shout__close:focus-visible {
  outline: 2px solid var(--fn-focus);
  outline-offset: 1px;
}

/* The minute, draining. Width is driven inline (`--pct`) from the countdown
   tick; the transition smooths the 250 ms steps into one continuous burn. */
.fn-shout__meter {
  position: absolute;
  inset-block-end: 0;
  inset-inline-start: 0;
  block-size: 3px;
  inline-size: var(--pct, 100%);
  background: linear-gradient(90deg, var(--fn-primary), var(--fn-primary-hover));
  box-shadow: 0 0 8px var(--fn-glow);
  transition: inline-size 260ms linear;
  pointer-events: none;
}

/* ---- the compose dialog ---- */

.fn-shoutdialog {
  display: grid;
  gap: var(--fn-s3);
}

.fn-shoutdialog__hero {
  display: flex;
  align-items: center;
  gap: var(--fn-s3);
}

.fn-shoutdialog__hero img {
  inline-size: 64px;
  block-size: 64px;
  border-radius: 16px;
  object-fit: cover;
  border: 1px solid var(--fn-primary-line);
  box-shadow: 0 0 16px var(--fn-glow);
  flex-shrink: 0;
}

.fn-shoutdialog__hero p {
  margin: 0;
  font-size: 0.85rem;
  line-height: 1.45;
  color: var(--fn-fg-muted);
}

.fn-shoutdialog__input {
  inline-size: 100%;
  resize: none;
}

.fn-shoutdialog__count {
  justify-self: end;
  font-size: 0.72rem;
  font-variant-numeric: tabular-nums;
  color: var(--fn-fg-faint);
}

.fn-shoutdialog__paid {
  margin: 0;
  font-size: 0.8rem;
  color: var(--fn-online, var(--fn-primary-text));
}

.fn-shoutdialog__error {
  margin: 0;
  font-size: 0.8rem;
  color: var(--fn-danger-text, #ff6b6b);
}

.fn-shoutdialog__actions {
  display: flex;
  justify-content: flex-end;
}

.fn-shoutdialog__cta {
  display: inline-flex;
  align-items: center;
  gap: var(--fn-s2);
}

/* Small screens: the banner spans edge to edge and the text tightens. */
@media (max-width: 640px) {
  .fn-shout { gap: var(--fn-s2); padding: var(--fn-s2); }
  .fn-shout__herald { inline-size: 36px; block-size: 36px; }
  .fn-shout__text { font-size: 0.9rem; }
}

/* ==========================================================================
   PUBLISH — paid web hosting (docs/API.md §16.2, components/publish.rs)

   The wall of hosted sites. Same design grammar as the Knowledge page — the
   hit-card family, the tab strip — plus a hero that names the price, because
   the price *is* the interface: it is what keeps the wall worth reading.
   ========================================================================== */

.fn-art--publish {
  --fn-art-light: var(--img-empty-publish);
  --fn-art-dark: var(--img-empty-publish-dark);
}

.fn-publish {
  display: grid;
  gap: var(--fn-s4);
  padding: var(--fn-s4);
  align-content: start;
}

.fn-publish__hero {
  display: flex;
  align-items: center;
  gap: var(--fn-s3);
}

.fn-publish__hero img {
  inline-size: 72px;
  block-size: 72px;
  border-radius: 18px;
  object-fit: cover;
  border: 1px solid var(--fn-primary-line);
  box-shadow: 0 0 18px var(--fn-glow);
  flex-shrink: 0;
}

.fn-publish__hero p {
  margin: 0;
  max-inline-size: 52ch;
}

.fn-publish__form {
  display: grid;
  gap: var(--fn-s3);
  padding: var(--fn-s3);
  border: 1px solid var(--fn-border);
  border-radius: 14px;
  background: var(--fn-surface-2);
}

.fn-publish__paste {
  inline-size: 100%;
  font-family: var(--fn-mono, monospace);
  font-size: 0.82rem;
  resize: vertical;
}

.fn-publish__pickrow {
  display: flex;
  align-items: center;
  gap: var(--fn-s3);
  flex-wrap: wrap;
}

.fn-publish__pickname {
  font-size: 0.8rem;
  color: var(--fn-fg-muted);
  overflow-wrap: anywhere;
}

.fn-publish__paid {
  margin: 0;
  font-size: 0.8rem;
  color: var(--fn-online, var(--fn-primary-text));
}

.fn-publish__error {
  margin: 0;
  font-size: 0.8rem;
  color: var(--fn-danger-text, #ff6b6b);
}

.fn-publish__actions {
  display: flex;
  justify-content: flex-end;
}
.fn-publish__actions .topcoat-button--large--cta {
  display: inline-flex;
  align-items: center;
  gap: var(--fn-s2);
}

.fn-publish__filterrow {
  display: flex;
}

.fn-publish__loading {
  display: grid;
  place-items: center;
  padding: var(--fn-s5) 0;
}

.fn-publish__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--fn-s3);
  /* Cards, not rows: a hosted site is a place, and places get doors. */
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}

.fn-sitecard {
  display: grid;
  gap: var(--fn-s2);
  align-content: start;
  /* The list entrance every rack row uses (§14). */
  animation: fn-list-in var(--fn-dur-soft) var(--fn-spring-soft) backwards;
}

.fn-sitecard__kind {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  color: var(--fn-primary-text);
}

.fn-sitecard__owner {
  font-size: 0.78rem;
  font-weight: 600;
  color: var(--fn-fg-muted);
}

.fn-sitecard__you {
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--fn-primary-text);
  border: 1px solid var(--fn-primary-line);
  border-radius: 999px;
  padding: 0 var(--fn-s2);
}

.fn-sitecard__title {
  font-size: 1.02rem;
  font-weight: 700;
  overflow-wrap: anywhere;
}

.fn-sitecard__meta2 {
  font-size: 0.75rem;
  color: var(--fn-fg-faint);
  font-variant-numeric: tabular-nums;
}

/* The shareable address, verbatim. Selectable on purpose — it is the
   fallback when the clipboard API is unavailable — and allowed to wrap,
   because a truncated URL is not an address. */
.fn-sitecard__url {
  font-family: var(--fn-mono, monospace);
  font-size: 0.72rem;
  color: var(--fn-primary-text);
  overflow-wrap: anywhere;
  user-select: all;
  padding: var(--fn-s1) var(--fn-s2);
  border: 1px dashed var(--fn-primary-line);
  border-radius: 8px;
  background: var(--fn-primary-wash);
}

.fn-sitecard__actions {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  margin-block-start: var(--fn-s1);
}

.fn-sitecard__open,
.fn-sitecard__copy {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  text-decoration: none;
}

/* The armed state: the same control, now stating a consequence. Colour is
   the whole message — no second dialog for a two-click confirm. */
.fn-sitecard__remove--armed {
  color: var(--fn-danger-text, #ff6b6b);
  border-color: var(--fn-danger-text, #ff6b6b);
}

@media (max-width: 640px) {
  .fn-publish { padding: var(--fn-s3); }
  .fn-publish__hero img { inline-size: 56px; block-size: 56px; }
  .fn-publish__list { grid-template-columns: 1fr; }
}

/* A zoomable identity tile (`Ident` with `zoom`): a real button now, and it
   should feel like one — pointer, a lift on hover, a visible focus ring.
   The lift is transform-only and tiny; the spotlight is the payoff. */
.fn-ident--zoom {
  cursor: pointer;
  transition: transform var(--fn-dur-snappy) var(--fn-spring-snappy);
}
@media (hover: hover) {
  .fn-ident--zoom:hover { transform: scale(1.08); }
}
.fn-ident--zoom:active { transform: scale(0.96); }
.fn-ident--zoom:focus-visible {
  outline: 2px solid var(--fn-focus);
  outline-offset: 2px;
}

/* ------------------------------------------------------------------ §22 --
   Server info dialog.

   Where this deployment is, and which transport is carrying this session.
   The protocol row is the reason the panel exists: a browser upgrades itself
   to HTTP/3 once it has seen `Alt-Svc`, silently, so the page cannot know
   without asking the end that terminated the connection.

   Addresses are monospaced and selectable — the whole point of showing them
   is that somebody copies one into another device. */

.fn-serverinfo {
  display: flex;
  flex-direction: column;
  gap: var(--fn-s5);
}

.fn-serverinfo__section {
  display: flex;
  flex-direction: column;
  gap: var(--fn-s2);
}

.fn-serverinfo__heading {
  margin: 0;
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-xs);
  letter-spacing: var(--fn-tr-caps);
  text-transform: uppercase;
  color: var(--fn-fg-muted);
}

.fn-serverinfo__rows {
  display: flex;
  flex-direction: column;
  gap: var(--fn-s2);
  margin: 0;
}

.fn-serverinfo__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--fn-s3);
}

.fn-serverinfo__row dt {
  font-size: var(--fn-t-sm);
  color: var(--fn-fg-muted);
}

.fn-serverinfo__row dd {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  margin: 0;
  font-size: var(--fn-t-sm);
}

/* The negotiated protocol. Neutral by default and lit only for HTTP/3, so
   the badge is reporting a fact rather than decorating every state. */
.fn-serverinfo__badge {
  padding: 2px var(--fn-s2);
  border: 1px solid var(--fn-border);
  border-radius: var(--fn-r-xs);
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-xs);
  font-weight: 700;
  color: var(--fn-fg-muted);
}

.fn-serverinfo__badge--live {
  border-color: var(--fn-primary-line);
  background: var(--fn-primary-soft);
  color: var(--fn-primary-text);
}

.fn-serverinfo__note {
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-faint);
}

.fn-serverinfo__urls {
  display: flex;
  flex-direction: column;
  gap: var(--fn-s1);
  margin: 0;
  padding: 0;
  list-style: none;
}

.fn-serverinfo__url {
  display: flex;
  align-items: baseline;
  gap: var(--fn-s2);
  min-inline-size: 0;
}

.fn-serverinfo__url code {
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-sm);
  color: var(--fn-fg);
  /* Long enough to overflow on a phone; scroll the address rather than the
     dialog, and keep it selectable so it can be copied. */
  overflow-x: auto;
  user-select: all;
}

/* `local` / `network` / `vpn` — how far away a client has to be. Fixed width
   so the addresses beside them line up into a column. */
.fn-serverinfo__reach {
  flex: none;
  inline-size: 4.5rem;
  font-family: var(--fn-font-mono);
  font-size: var(--fn-t-xs);
  letter-spacing: var(--fn-tr-caps);
  text-transform: uppercase;
  color: var(--fn-fg-faint);
}

.fn-modal__hint {
  margin: 0;
  font-size: var(--fn-t-sm);
  line-height: var(--fn-lh-body);
  color: var(--fn-fg-muted);
}

/* The "why am I not on HTTP/3" link. Inline in a hint paragraph, so it reads
   as a link without the weight of a button. */
.fn-modal__hint a {
  color: var(--fn-primary-text);
  text-underline-offset: 2px;
}
.fn-modal__hint a:hover { text-decoration-thickness: 2px; }

/* ==========================================================================
   §20 The operator's file
   --------------------------------------------------------------------------
   The game layer's one screen: clearance, standing orders, the trophy wall
   and the server's ladder. Everything here is a readout, so the type is
   tabular and the surfaces are flat — this is an instrument panel, not a
   dashboard of cards competing for attention.

   Every colour comes from the existing tokens rather than a new palette: the
   game layer is a lens on the same product, and inventing a second accent
   would make it look like a different app bolted on.
   ========================================================================== */

.fn-operator {
  display: grid;
  gap: 16px;
  padding: 16px;
  max-width: 720px;
  margin-inline: auto;
}

/* The panel surface. Its own class rather than a generic `.fn-card`: this
   stylesheet has never had one, and inventing a global name for four sections
   would be claiming a word the rest of the app might later want for something
   else. Built from the same tokens every other raised surface uses. */
.fn-op-card {
  padding: 16px;
  border-radius: 14px;
  background: var(--fn-surface);
  border: 1px solid var(--fn-border);
  box-shadow: var(--fn-elev-1);
}

/* --- dossier -------------------------------------------------------------- */

.fn-op-dossier { display: grid; justify-items: center; gap: 10px; text-align: center; }

/* The emblem plate. The iOS client ships ten painted emblems; on the web the
   tier is drawn from the same geometry the rest of the HUD uses, which keeps
   the bundle honest — no megabyte of art for a number. */
.fn-op-emblem {
  inline-size: 96px;
  block-size: 96px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background:
    radial-gradient(circle at 50% 45%, var(--fn-primary-soft), transparent 70%),
    var(--fn-surface-2);
  border: 1px solid var(--fn-primary-line);
  box-shadow: 0 0 24px -6px var(--fn-glow);
}

.fn-op-level {
  font-size: 40px;
  font-weight: 800;
  line-height: 1;
  color: var(--fn-primary-text);
  font-variant-numeric: tabular-nums;
}

.fn-op-designation {
  margin: 0;
  font-size: 18px;
  font-weight: 800;
  letter-spacing: 0.14em;
  color: var(--fn-fg);
}

.fn-op-mandate { margin: 0; font-size: 13px; color: var(--fn-fg-muted); max-inline-size: 44ch; }

/* --- bars ----------------------------------------------------------------- */

.fn-op-bar {
  inline-size: 100%;
  block-size: 6px;
  border-radius: 999px;
  background: var(--fn-surface-3);
  overflow: hidden;
}
.fn-op-bar--thin { block-size: 3px; }

.fn-op-bar-fill {
  block-size: 100%;
  border-radius: inherit;
  background: linear-gradient(90deg,
    color-mix(in srgb, var(--fn-primary) 55%, transparent), var(--fn-primary));
  transition: inline-size 240ms ease;
}
/* §17 kills durations globally; this one is a value readout, not decoration,
   so flatten it rather than let it animate under reduced motion. */
@media (prefers-reduced-motion: reduce) { .fn-op-bar-fill { transition-duration: 1ms; } }

/* --- stats ---------------------------------------------------------------- */

.fn-op-stats {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 4px;
  inline-size: 100%;
  margin: 4px 0 0;
}
.fn-op-stat { display: grid; gap: 2px; text-align: center; }
.fn-op-stat dd {
  margin: 0;
  font-size: 17px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--fn-primary-text);
}
.fn-op-stat dt {
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--fn-fg-faint);
}

/* --- section headers ------------------------------------------------------ */

.fn-op-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  margin-block-end: 10px;
}
.fn-op-head h3 {
  margin: 0;
  font-size: 11px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--fn-fg-faint);
}
.fn-op-count {
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  color: var(--fn-fg-muted);
}
.fn-op-note { margin: 10px 0 0; font-size: 11px; color: var(--fn-fg-faint); }

/* --- standing orders ------------------------------------------------------ */

.fn-op-order { display: grid; gap: 4px; padding-block: 8px; }
.fn-op-order + .fn-op-order { border-block-start: 1px solid var(--fn-hair); }
.fn-op-order-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.fn-op-order-text { font-size: 14px; color: var(--fn-fg); }
.fn-op-order.is-done .fn-op-order-text {
  color: var(--fn-fg-muted);
  text-decoration: line-through;
}
.fn-op-order.is-done .fn-op-bar-fill { background: var(--fn-encrypt); }
.fn-op-bounty {
  font-size: 12px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--fn-primary-text);
}
.fn-op-order.is-done .fn-op-bounty { color: var(--fn-encrypt-text); }
.fn-op-order-count {
  justify-self: end;
  font-size: 10px;
  font-variant-numeric: tabular-nums;
  color: var(--fn-fg-faint);
}

/* --- trophy wall ---------------------------------------------------------- */

.fn-op-trophies {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
  gap: 8px;
}
.fn-op-trophy {
  display: grid;
  gap: 4px;
  align-content: start;
  padding: 10px 8px;
  border-radius: 10px;
  border: 1px solid var(--fn-hair);
  background: var(--fn-surface-2);
  text-align: center;
  min-block-size: 56px;
}
.fn-op-trophy-name {
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.06em;
  color: var(--fn-fg-faint);
}
.fn-op-trophy-goal { font-size: 9px; font-variant-numeric: tabular-nums; color: var(--fn-fg-faint); }

/* Earned tiles carry their tier's colour; locked ones stay a slab, because a
   wall where everything glows tells you nothing about what you have done. */
.fn-op-trophy.is-earned { border-color: var(--fn-border-strong); background: var(--fn-surface-3); }
.fn-op-trophy.is-earned .fn-op-trophy-name { color: var(--fn-fg); }
.fn-op-trophy.is-earned.tier-bronze  { box-shadow: inset 0 0 0 1px hsl(25 60% 50% / 0.45); }
.fn-op-trophy.is-earned.tier-silver  { box-shadow: inset 0 0 0 1px hsl(210 16% 70% / 0.45); }
.fn-op-trophy.is-earned.tier-gold    { box-shadow: inset 0 0 0 1px hsl(43 90% 55% / 0.50); }
.fn-op-trophy.is-earned.tier-machine {
  box-shadow: inset 0 0 0 1px var(--fn-primary-line), 0 0 18px -8px var(--fn-glow);
}

/* --- ladder --------------------------------------------------------------- */

.fn-op-rung {
  display: grid;
  grid-template-columns: 24px 1fr auto auto;
  align-items: center;
  gap: 10px;
  padding-block: 6px;
}
.fn-op-rung + .fn-op-rung { border-block-start: 1px solid var(--fn-hair); }
.fn-op-pos {
  font-size: 12px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  color: var(--fn-fg-faint);
  text-align: end;
}
.fn-op-who { display: grid; gap: 1px; min-inline-size: 0; }
.fn-op-who strong {
  font-size: 14px;
  font-weight: 600;
  color: var(--fn-fg);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.fn-op-who em {
  font-style: normal;
  font-size: 10px;
  letter-spacing: 0.06em;
  color: var(--fn-fg-faint);
}
.fn-op-rung-streak { font-size: 11px; color: var(--fn-warn-text, #f6c177); font-variant-numeric: tabular-nums; }
.fn-op-rung-load {
  font-size: 13px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--fn-fg-muted);
}
.fn-op-rung.is-me {
  background: var(--fn-primary-soft);
  border-radius: 8px;
  padding-inline: 6px;
}
.fn-op-rung.is-me .fn-op-rung-load { color: var(--fn-primary-text); }


/* ------------------------------------------------------------------ §23 --
   IMAGE LIGHTBOX (components/lightbox.rs)

   A picture in a bubble is capped at 400px, which is the right size for a
   conversation and the wrong size for reading a screenshot. Tapping one lifts
   it onto a blurred scrim at whatever size the viewport can give it.

   The zoom is a FLIP: the picture is laid out at its resting size, measured
   by Rust, and then handed the transform that puts it back exactly over its
   thumbnail — same place, same size — which the entrance removes. So what
   travels is *the picture*, not a copy of it fading in over the top. The
   endpoints arrive as `--lb-dx/--lb-dy/--lb-s`, set on the scrim in one style
   attribute and inherited down.

   `--lb-ar` and `--lb-cap` are the resting geometry. Giving CSS the aspect
   ratio rather than a pixel size means the box is definite on the frame it
   mounts (so it can be measured before the bytes land) *and* still the
   browser's to re-fit when the window resizes. `--lb-cap` is twice the
   picture's own pixels: fitting the viewport unconditionally turns a 96px
   sticker into a wall of mush.

   Layered at 380: above every screen, below the portrait spotlight (400),
   which is the one thing that outranks it. */

/* The picture is the control. A 16px icon would be a poor hit area for
   something 400px wide, and on a phone it is the only hit area there is. */
.fn-media__zoom {
  display: block;
  inline-size: fit-content;
  max-inline-size: 100%;
  padding: 0;
  border: 0;
  background: none;
  cursor: zoom-in;
  border-radius: 10px;
  transition: filter var(--fn-dur-fast) var(--fn-ease);
}
@media (hover: hover) {
  .fn-media__zoom:hover img { filter: brightness(1.06); }
}
.fn-media__zoom:focus-visible {
  outline: 2px solid var(--fn-focus);
  outline-offset: 3px;
}

.fn-lightbox {
  position: fixed;
  inset: 0;
  z-index: 380;
  /* An explicit single-cell track, not `place-items: center` on an implicit
     one: an auto-sized row is indefinite, so the frame's `block-size: 100%`
     would resolve against the *picture* and `max-block-size` would clamp
     nothing. `minmax(0, 1fr)` is the definite area the picture is fitted
     into, and without it a tall picture runs off both ends of the screen. */
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: minmax(0, 1fr);
  padding-block: max(var(--fn-s4), var(--fn-safe-top)) max(var(--fn-s4), var(--fn-safe-bottom));
  padding-inline: max(var(--fn-s4), var(--fn-safe-left)) max(var(--fn-s4), var(--fn-safe-right));
  background: var(--fn-overlay);
  /* The same scrim pair the modal raises (§9): the page goes soft as the
     picture takes over, which is the depth cue that says *above* rather than
     merely on top. */
  animation: fn-fade var(--fn-dur) var(--fn-ease-cosine) both,
             fn-backdrop-blur var(--fn-dur-cosine) var(--fn-ease-cosine) both;
  cursor: zoom-out;
}
.fn-lightbox[data-closing] {
  animation: fn-fade-out var(--fn-dur-exit) var(--fn-ease-exit) both;
}

.fn-lightbox__frame {
  position: relative;
  margin: 0;
  inline-size: 100%;
  /* Definite, so the picture's `max-block-size: 100%` has something to
     resolve against — an auto-height flex line would give it nothing and the
     picture would run off the bottom of tall screens. */
  block-size: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* The box is definite from `--lb-ar` alone, so it exists at full size on the
   frame it mounts — before any of the picture has arrived — which is what
   makes the entrance measurable that early. When `max-block-size` then clamps
   a tall picture, the width is *not* recomputed from the ratio (a specified
   inline size does not transfer back), so the element box can be wider than
   the picture inside it. `object-fit` keeps that invisible, and `lightbox.rs`
   measures the painted rect rather than the box, so the travel is exact
   either way. */
.fn-lightbox__img {
  aspect-ratio: var(--lb-ar, 1);
  inline-size: min(100%, var(--lb-cap, 100%));
  block-size: auto;
  max-block-size: 100%;
  object-fit: contain;
  border-radius: var(--fn-r-card);
  box-shadow: var(--fn-elev-4);
}
/* Laid out but unpainted until Rust has measured it — one frame, and the only
   state in which no transform is known. `visibility`, not `display`, because
   a picture with no box is a picture that cannot be measured. */
.fn-lightbox:not([data-ready]) .fn-lightbox__img { visibility: hidden; }
/* Attribute-scoped so the animation *starts* when the endpoints arrive.
   Declared unconditionally it would begin during the unmeasured frame and
   then have its own keyframes swapped underneath it mid-flight. */
.fn-lightbox[data-ready] .fn-lightbox__img {
  animation: fn-lb-in var(--fn-dur-expo) var(--fn-ease-expo) both;
}
@keyframes fn-lb-in {
  from {
    transform: translate(var(--lb-dx, 0px), var(--lb-dy, 0px)) scale(var(--lb-s, 0.92));
    /* 1 when there is a thumbnail to travel from: the picture is already on
       screen, and fading it in would say "two pictures". 0 when there is
       not, where the fade is the whole entrance. */
    opacity: var(--lb-o, 0);
  }
}
/* Exit reverses the travel but not the curve — it leaves on `--fn-ease-exit`,
   accelerating away rather than easing to a stop (MOTION.md §4). 140ms here
   and in `lightbox.rs::EXIT_MS`; the two are one timeline. */
.fn-lightbox[data-closing] .fn-lightbox__img {
  animation: fn-lb-out var(--fn-dur-exit) var(--fn-ease-exit) both;
}
@keyframes fn-lb-out {
  to {
    transform: translate(var(--lb-dx, 0px), var(--lb-dy, 0px)) scale(var(--lb-s, 0.92));
    opacity: var(--lb-o, 0);
  }
}

/* The filename, floated over the foot of the picture rather than laid out
   under it: a caption in the flow would take height away from the thing it is
   describing. Delayed so it arrives after the travel has landed. */
.fn-lightbox__caption {
  position: absolute;
  inset-block-end: 0;
  inset-inline: 0;
  margin-inline: auto;
  inline-size: fit-content;
  max-inline-size: min(100%, 620px);
  padding: 6px 14px;
  border-radius: 999px;
  border: 1px solid var(--fn-rim);
  background: color-mix(in srgb, var(--fn-surface-2) 84%, transparent);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  font-size: var(--fn-t-sm);
  color: var(--fn-fg-muted);
  animation: fn-fade var(--fn-dur-cosine) var(--fn-ease-cosine) both;
  animation-delay: 120ms;
}

.fn-lightbox__close {
  position: absolute;
  inset-block-start: max(var(--fn-s3), var(--fn-safe-top));
  inset-inline-end: max(var(--fn-s3), var(--fn-safe-right));
  color: var(--fn-fg);
  background: color-mix(in srgb, var(--fn-surface-2) 76%, transparent);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  cursor: pointer;
}

@media (prefers-reduced-motion: reduce) {
  /* §17 already flattens the durations. This is the *hover* growth, which is
     a resting transform-adjacent effect and has to be removed outright. */
  .fn-media__zoom:hover img { filter: none; }
}

/* ------------------------------------------------------------------ §24 --
   TRANSFERS — a large upload, while it happens (components/transfers.rs)

   A card, bottom-left, that must clear three things: the composer, the bottom
   navigation on phones, and the home indicator. It used to clear none of them —
   a phone showed it sitting on top of the nav — so the offset is now composed
   rather than guessed: the safe area, plus the nav where there is one, plus a
   gutter.

   Fixed rather than in flow: the rail is mounted in the shell and must not
   reflow whatever screen is under it.

   Two stages, two treatments. The checksum pass is a local read and gets the
   quiet one; the transfer itself gets the accent, because that is the one with
   a network on the other end. Without the distinction a bar that fills, resets
   and fills again reads as a bug.

   §17's reduced-motion rule flattens the transition and kills the shimmer,
   which is correct: the number is the information and it stays exact.       */
.fn-transfers {
  position: fixed;
  /* Composed, not guessed — see above. `--fn-nav-h` is 0 on desktop. */
  inset-block-end: calc(
    var(--fn-s4) + var(--fn-safe-bottom, 0px) + var(--fn-nav-h, 0px)
  );
  inset-inline-start: var(--fn-s4);
  z-index: 60;
  display: grid;
  gap: var(--fn-s2);
  inline-size: min(360px, calc(100vw - var(--fn-s4) * 2));
  /* The container ignores the pointer so it never blocks the app behind it;
     the cancel button re-enables it for itself. */
  pointer-events: none;
}

.fn-transfer {
  position: relative;
  overflow: hidden;
  background: var(--fn-surface);
  border: 1px solid var(--fn-border);
  border-radius: var(--fn-radius-lg, 14px);
  padding: var(--fn-s3);
  display: grid;
  gap: 10px;
  /* Two shadows: a tight one that seats the card, and a wide soft one that
     lifts it off whatever it is covering. One shadow does one of those. */
  box-shadow:
    0 1px 2px color-mix(in srgb, #000 30%, transparent),
    0 12px 32px -8px color-mix(in srgb, #000 55%, transparent);
  animation: fn-list-in var(--fn-dur-soft) var(--fn-spring-soft) both;
}

/* A hairline of the accent down the leading edge — enough to read as "this is
   the system doing something" without a second colour anywhere else. */
.fn-transfer::before {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  inline-size: 2px;
  background: var(--fn-primary);
}
.fn-transfer[data-stalled="true"]::before {
  background: var(--fn-warn, hsl(38 92% 55%));
}

.fn-transfer__head {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
}

/* The filename gives up its width first: everything beside it is a
   fixed-width fact, and a long name must not push those off the card. */
.fn-transfer__name {
  flex: 1 1 auto;
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--fn-fs-sm);
  font-weight: 600;
  color: var(--fn-text);
}

/* The way out. Anything that can hang needs one, and this one is safe: the
   session survives on the server, so re-attaching the same file resumes it. */
.fn-transfer__cancel {
  flex: 0 0 auto;
  pointer-events: auto;
  display: grid;
  place-items: center;
  inline-size: 26px;
  block-size: 26px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: var(--fn-text-dim);
  cursor: pointer;
  transition: background var(--fn-dur) var(--fn-ease), color var(--fn-dur) var(--fn-ease);
}
.fn-transfer__cancel:hover {
  background: color-mix(in srgb, var(--fn-text) 12%, transparent);
  color: var(--fn-text);
}
.fn-transfer__cancel:focus-visible {
  outline: 2px solid var(--fn-primary);
  outline-offset: 2px;
}

.fn-transfer__meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--fn-s2);
}

/* Always visible, next to the number. A bare "100%" is what made a hung
   checksum pass look like a finished upload. */
.fn-transfer__stage {
  font-size: var(--fn-fs-xs);
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--fn-text-dim);
}
.fn-transfer[data-stalled="true"] .fn-transfer__stage {
  color: var(--fn-warn, hsl(38 92% 55%));
}

/* Tabular figures, or the percentage shifts the layout every chunk as the
   digits change width. */
.fn-transfer__pct {
  font-size: var(--fn-fs-md, 1rem);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--fn-primary);
}
.fn-transfer[data-stalled="true"] .fn-transfer__pct {
  color: var(--fn-warn, hsl(38 92% 55%));
}

.fn-transfer__track {
  position: relative;
  block-size: 6px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--fn-text) 10%, transparent);
  overflow: hidden;
}

.fn-transfer__fill {
  display: block;
  block-size: 100%;
  border-radius: inherit;
  /* A gradient rather than a flat fill, so the leading edge is brighter than
     the tail and the bar reads as moving even when it is barely moving. */
  background: linear-gradient(
    90deg,
    color-mix(in srgb, var(--fn-primary) 55%, transparent),
    var(--fn-primary)
  );
  /* The leading edge carries a soft glow of its own colour, so the bar reads
     as a light moving through the track rather than a region changing size. */
  box-shadow: 0 0 10px color-mix(in srgb, var(--fn-primary) 45%, transparent);
  /* Width, not a transform: the fill carries a glow on its leading edge and
     scaling would stretch that into a smear across the whole bar.

     The duration deliberately outlasts the report interval. Chunks can land
     every few tens of milliseconds, and a bar that snaps to each one strobes;
     easing each report out over ~400ms makes consecutive updates blend into
     one continuous glide. easeOutExpo, so the bar leaps toward the new value
     and settles — momentum, not lag. */
  transition:
    inline-size 420ms var(--fn-ease-expo),
    background 260ms var(--fn-ease),
    box-shadow 260ms var(--fn-ease);
}

/* The local pass. Dimmer, flat, and unlit — nothing is crossing a network
   yet, and the glow is reserved for bytes that are. */
.fn-transfer__fill[data-stage="checksum"] {
  background: color-mix(in srgb, var(--fn-text) 45%, transparent);
  box-shadow: none;
}

/* A moving sheen, so a slow chunk still looks alive. Only while the transfer
   is actually moving: on a stalled row this would be a lie. */
.fn-transfer__fill[data-stage="moving"]::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in srgb, #fff 35%, transparent),
    transparent
  );
  animation: fn-transfer-sheen 1.6s var(--fn-ease-cosine) infinite;
}
.fn-transfer[data-stalled="true"] .fn-transfer__fill {
  background: var(--fn-warn, hsl(38 92% 55%));
}
.fn-transfer[data-stalled="true"] .fn-transfer__fill::after { display: none; }

@keyframes fn-transfer-sheen {
  from { transform: translateX(-100%); }
  to   { transform: translateX(100%); }
}

/* --- The good ending -----------------------------------------------------
   `TransferDone` holds the row for a beat before removal, and this is that
   beat: the bar settles full and turns the presence green, the glow blooms
   once, and then the whole card takes its leave — down and out, the reverse
   of how it arrived. The exit is *delayed*, not slowed: the green must be
   readable as a state before it starts moving, or it reads as a glitch on
   the way out. `forwards` holds the card invisible for the frames between
   the animation ending and Rust actually unmounting it. */
.fn-transfer[data-done="true"]::before {
  background: var(--fn-online);
}
.fn-transfer__fill[data-stage="done"] {
  background: var(--fn-online);
  box-shadow: 0 0 14px color-mix(in srgb, var(--fn-online) 60%, transparent);
}
.fn-transfer__fill[data-stage="done"]::after { display: none; }
.fn-transfer[data-done="true"] .fn-transfer__stage,
.fn-transfer[data-done="true"] .fn-transfer__pct {
  color: var(--fn-online);
}
.fn-transfer[data-done="true"] {
  animation: fn-transfer-out 380ms var(--fn-ease-exit) 450ms forwards;
}
@keyframes fn-transfer-out {
  to {
    opacity: 0;
    translate: 0 8px;
    scale: 0.97;
  }
}

.fn-transfer__size {
  font-size: var(--fn-fs-xs);
  color: var(--fn-text-dim);
  font-variant-numeric: tabular-nums;
}

/* The instruction, not just the diagnosis: a stalled row says what to do. */
.fn-transfer__hint {
  color: var(--fn-warn, hsl(38 92% 55%));
  margin-inline-start: 0.4em;
}

/* On a phone the card spans the width, because a 360px card beside 40px of
   dead space reads as a misplaced popup rather than a status line. */
@media (max-width: 560px) {
  .fn-transfers {
    inset-inline: var(--fn-s3);
    inline-size: auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  .fn-transfer { animation: none; }
  .fn-transfer__fill { transition: none; }
  .fn-transfer__fill::after { display: none; }
  .fn-transfer__cancel { transition: none; }
  /* The done *state* stays — colour is information, motion is decoration.
     With the exit animation gone the row holds its green until Rust removes
     it, and an abrupt removal is exactly what reduced motion asks for. */
  .fn-transfer[data-done="true"] { animation: none; }
}

/* A video in the stream is a thumbnail until it is asked for
   (message.rs::AttachmentEmbed). The poster is a `<video preload="metadata">`
   rather than a generated image: the browser already decodes a frame to
   satisfy `metadata`, so this costs the header of the file and no more — a few
   hundred kilobytes of a film that may be gigabytes.

   The badge is what makes it read as clickable. A still with no affordance
   looks like an image that failed to load, which is the exact impression a
   film should not give. */
.fn-attach__play {
  position: relative;
  display: block;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}
.fn-attach__play-badge {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  transform: translate(-50%, -50%);
  display: grid;
  place-items: center;
  inline-size: 48px;
  block-size: 48px;
  border-radius: 50%;
  color: #fff;
  /* Its own scrim: a bright frame behind a white glyph would swallow it. */
  background: color-mix(in srgb, #000 55%, transparent);
  backdrop-filter: blur(2px);
  pointer-events: none;
  transition: transform var(--fn-dur) var(--fn-ease), background var(--fn-dur) var(--fn-ease);
}
.fn-attach__play:hover .fn-attach__play-badge {
  transform: translate(-50%, -50%) scale(var(--fn-tap-hover));
  background: color-mix(in srgb, var(--fn-primary) 70%, #000);
}
.fn-attach__play:active .fn-attach__play-badge {
  transform: translate(-50%, -50%) scale(var(--fn-tap-press));
}
@media (prefers-reduced-motion: reduce) {
  .fn-attach__play-badge { transition: none; }
}

/* ==========================================================================
   §21 PHOTO GALLERY — /rooms/:id/gallery (components/gallery.rs)

   The phone-photo-roll shape: square tiles, hairline gaps, newest first.
   Tiles are `object-fit: cover` because a grid is a contact sheet, not a
   viewer — the lightbox (§23) is where a picture keeps its own shape.
   ========================================================================== */

.fn-gallery-wrap {
  flex: 1;
  overflow-y: auto;
  padding: var(--fn-s2);
}

.fn-gallery {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  /* Hairline gaps, not card gutters: the pictures are the surface. */
  gap: 3px;
}

.fn-gallery__cell { min-inline-size: 0; }

.fn-gallery__tile {
  position: relative;
  display: block;
  inline-size: 100%;
  aspect-ratio: 1;
  padding: 0;
  border: 0;
  border-radius: var(--fn-r1);
  overflow: hidden;
  cursor: pointer;
  /* The plate a transparent PNG or a slow load sits on — near-black, so a
     tile arriving late is a dark square, not a white flash. */
  background: color-mix(in srgb, var(--fn-ink, #000) 92%, #fff);
}

.fn-gallery__shot {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
  display: block;
}

/* A video with no poster frame: the dark plate alone, under the badge. */
.fn-gallery__plate {
  display: block;
  inline-size: 100%;
  block-size: 100%;
  background:
    radial-gradient(circle at 50% 45%,
      color-mix(in srgb, #fff 8%, transparent), transparent 70%);
}

/* Same badge grammar as the in-bubble player (§ above), scaled to a tile. */
.fn-gallery__badge {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  transform: translate(-50%, -50%);
  display: grid;
  place-items: center;
  inline-size: 38px;
  block-size: 38px;
  border-radius: 50%;
  color: #fff;
  background: color-mix(in srgb, #000 55%, transparent);
  backdrop-filter: blur(2px);
  pointer-events: none;
  transition: transform var(--fn-dur) var(--fn-ease), background var(--fn-dur) var(--fn-ease);
}
.fn-gallery__tile:hover .fn-gallery__badge {
  transform: translate(-50%, -50%) scale(var(--fn-tap-hover));
  background: color-mix(in srgb, var(--fn-primary) 70%, #000);
}
.fn-gallery__tile:focus-visible {
  outline: 2px solid var(--fn-primary);
  outline-offset: 2px;
}

.fn-gallery__more {
  display: flex;
  justify-content: center;
  padding: var(--fn-s3);
}

/* The video overlay: the lightbox's scrim with a player where the picture
   would be. Clicks on the video are its controls; the scrim is the dismiss. */
.fn-gallery__player {
  position: fixed;
  inset: 0;
  z-index: 80;
  display: grid;
  place-items: center;
  background: color-mix(in srgb, #000 82%, transparent);
  backdrop-filter: blur(6px);
}
.fn-gallery__player video {
  max-inline-size: min(96vw, 1280px);
  max-block-size: 88dvh;
  border-radius: var(--fn-r2);
  background: #000;
}
.fn-gallery__player-close {
  position: fixed;
  inset-block-start: max(var(--fn-s2), env(safe-area-inset-top));
  inset-inline-end: max(var(--fn-s2), env(safe-area-inset-right));
  color: #fff;
}

@media (max-width: 600px) {
  /* Three across on a phone, like every photo roll. */
  .fn-gallery { grid-template-columns: repeat(3, 1fr); gap: 2px; }
}

@media (prefers-reduced-motion: reduce) {
  .fn-gallery__badge { transition: none; }
}

/* ==========================================================================
   SKYNET DASHBOARD — the operator's view of the deployment (/dashboard,
   components/dashboard.rs, docs/API.md §6.14): the server in counts, then
   the disk in files. Admin-only in effect; the gate is server-side and this
   styling assumes nothing about it.

   Additive, like §18: new class names only, tokens only. The charts keep to
   one recessive ink on purpose — identity lives in the row labels and the
   direct values, so nothing here is said by colour alone (DESIGN.md §17),
   and the single accent stays reserved for actions (DESIGN.md §1).
   ========================================================================== */

.fn-dash {
  display: grid;
  gap: 16px;
  padding: 16px;
  max-width: 1080px;
  margin-inline: auto;
  align-content: start;
}

.fn-dash__head {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: 14px;
  align-items: center;
}
.fn-dash__head h1 {
  font-size: var(--fn-t-xl);
  letter-spacing: -0.01em;
}
/* The archivist's mark (tools/genart.py `dashboard-emblem`, one drawing per
   skin). Decoration, so it spans the rows and stays out of the reading
   order; the squircle radius is the identity-tile shape, which is what makes
   44px read as an emblem rather than a thumbnail. */
.fn-dash__emblem {
  grid-row: 1 / 3;
  width: 44px;
  height: 44px;
  border-radius: 34%;
  box-shadow: var(--fn-elev-1);
}

/* The two panel labels. An eyebrow rather than a second h1-sized heading:
   the sections are rooms of one instrument, not two screens sharing a URL. */
.fn-dash__eyebrow {
  margin-top: 8px;
  font-size: var(--fn-t-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--fn-fg-faint);
}
.fn-dash__sub {
  margin-top: 4px;
  font-size: var(--fn-t-sm);
  color: var(--fn-fg-muted);
  max-width: 64ch;
}

/* --- stat tiles ----------------------------------------------------------- */

.fn-dash__tiles {
  display: grid;
  gap: 12px;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.fn-dash__tile {
  display: grid;
  gap: 4px;
  padding: 14px 16px;
  border-radius: 14px;
  background: var(--fn-surface);
  border: 1px solid var(--fn-border);
  box-shadow: var(--fn-elev-1);
}
.fn-dash__tile-label {
  font-size: var(--fn-t-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fn-fg-faint);
}
.fn-dash__tile-value {
  font-size: 26px;
  font-weight: 800;
  line-height: 1.15;
}
.fn-dash__tile-foot {
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-muted);
}

/* --- cards ---------------------------------------------------------------- */

.fn-dash__grid {
  display: grid;
  gap: 16px;
}
@media (min-width: 900px) {
  .fn-dash__grid { grid-template-columns: 1fr 1fr; }
}
.fn-dash__card {
  padding: 16px;
  border-radius: 14px;
  background: var(--fn-surface);
  border: 1px solid var(--fn-border);
  box-shadow: var(--fn-elev-1);
  min-width: 0;
}
.fn-dash__card-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 12px;
}
.fn-dash__card-head h3 { font-size: var(--fn-t-md); font-weight: 700; }
.fn-dash__card-aside { font-size: var(--fn-t-xs); color: var(--fn-fg-muted); }

.fn-dash__quiet {
  margin-top: 10px;
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-faint);
}

/* --- bar list ------------------------------------------------------------- */
/* One row = label · count · value on a line, with the bar as a thin track
   underneath. The value column is text, always — the bar only gives the
   numbers a shape, which is what lets a single ink carry the whole list. */

.fn-dash__row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  column-gap: 10px;
  row-gap: 3px;
  align-items: baseline;
  padding-block: 6px;
}
.fn-dash__row-label { font-size: var(--fn-t-sm); font-weight: 600; min-width: 0; }
.fn-dash__row-detail { font-size: var(--fn-t-xs); color: var(--fn-fg-faint); }
.fn-dash__row-value { font-size: var(--fn-t-sm); font-weight: 600; }
.fn-dash__track {
  grid-column: 1 / -1;
  height: 6px;
  border-radius: 999px;
  background: var(--fn-surface-3);
  overflow: hidden;
}
.fn-dash__fill {
  height: 100%;
  border-radius: inherit;
  background: color-mix(in srgb, var(--fn-fg) 34%, transparent);
  min-width: 2px;
}

/* --- growth chart --------------------------------------------------------- */

.fn-dash__chart {
  display: block;
  width: 100%;
  height: 120px;
}
.fn-dash__col { fill: color-mix(in srgb, var(--fn-fg) 34%, transparent); }
.fn-dash__col:hover { fill: color-mix(in srgb, var(--fn-fg) 55%, transparent); }
.fn-dash__axis {
  stroke: var(--fn-border);
  stroke-width: 1;
  /* The viewBox is stretched, so an unscaled hairline needs this. */
  vector-effect: non-scaling-stroke;
}
.fn-dash__chart-foot {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  margin-top: 6px;
  font-size: var(--fn-t-xs);
  color: var(--fn-fg-faint);
}

/* --- activity ------------------------------------------------------------- */

.fn-dash__flows {
  display: grid;
  gap: 16px;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.fn-dash__flow { display: grid; gap: 6px; align-content: start; }
.fn-dash__flow h4 {
  font-size: var(--fn-t-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fn-fg-faint);
}
.fn-dash__flow-big { font-size: 22px; font-weight: 800; line-height: 1.15; }
.fn-dash__flow-stats {
  display: grid;
  gap: 2px;
  font-size: var(--fn-t-sm);
}
.fn-dash__flow-stats > div {
  display: flex;
  justify-content: space-between;
  gap: 12px;
}
.fn-dash__flow-stats dt { color: var(--fn-fg-muted); }
.fn-dash__flow-stats dd { font-weight: 600; }

/* --- the table ------------------------------------------------------------ */

.fn-dash__toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  margin-bottom: 12px;
}
.fn-dash__filter { flex: 1 1 220px; min-width: 0; }
.fn-dash__chips { display: flex; flex-wrap: wrap; gap: 6px; }
.fn-dash__chip {
  padding: 4px 12px;
  border-radius: 999px;
  border: 1px solid var(--fn-border-strong);
  background: var(--fn-surface);
  color: var(--fn-fg-muted);
  font: inherit;
  font-size: var(--fn-t-sm);
  cursor: pointer;
}
.fn-dash__chip:hover { background: var(--fn-surface-3); }
/* Pressed = filled with ink, not with the accent: this is a filter, not a
   call to action, and orange on it would claim otherwise (DESIGN.md §2.2). */
.fn-dash__chip[aria-pressed="true"] {
  background: var(--fn-fg);
  border-color: var(--fn-fg);
  color: var(--fn-bg);
  font-weight: 700;
}

/* Wide content scrolls inside its own container, never the page. */
.fn-dash__tablewrap { overflow-x: auto; }
.fn-dash__table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fn-t-sm);
}
.fn-dash__table th {
  text-align: left;
  padding: 6px 10px 6px 0;
  border-bottom: 1px solid var(--fn-border-strong);
  white-space: nowrap;
}
.fn-dash__table td {
  padding: 7px 10px 7px 0;
  border-bottom: 1px solid var(--fn-border);
  vertical-align: baseline;
  max-width: 26ch;
}
.fn-dash__table td.fn-dash__cell-name { max-width: 34ch; font-weight: 600; }
.fn-dash__table td .fn-truncate { display: block; }
.fn-dash__cell-date { white-space: nowrap; font-family: var(--fn-font-mono); font-size: var(--fn-t-xs); }
.fn-dash__sort {
  border: 0;
  background: none;
  padding: 0;
  font: inherit;
  font-size: var(--fn-t-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fn-fg-muted);
  cursor: pointer;
}
.fn-dash__sort:hover { color: var(--fn-fg); }
th[aria-sort="ascending"] .fn-dash__sort,
th[aria-sort="descending"] .fn-dash__sort { color: var(--fn-fg); }
.fn-dash__kind {
  display: inline-block;
  padding: 1px 8px;
  border-radius: 999px;
  background: var(--fn-surface-3);
  font-size: var(--fn-t-xs);
  font-weight: 700;
  color: var(--fn-fg-muted);
  white-space: nowrap;
}

/* ==========================================================================
   SKYNET PASSWORD — the encrypted key/value store
   (docs/API.md §18, components/passwords.rs)

   Borrows the hit-card family from Knowledge and Publish, because it is the
   same object: a row with a title, a timestamp and a set of actions. What is
   local to this screen is the *secret* line — a monospaced field, masked by
   default, whose mask is a fixed ten dots regardless of the real length.
   Password length is worth something to a guesser, and a list where one row's
   dots run twice as wide as another's publishes it to anyone glancing over.
   ========================================================================== */

.fn-pw {
  display: grid;
  gap: var(--fn-s4);
  padding: var(--fn-s4);
  align-content: start;
}

.fn-pw__hero {
  display: flex;
  align-items: flex-start;
  gap: var(--fn-s3);
  color: var(--fn-primary-text);
}

.fn-pw__hero > svg {
  flex-shrink: 0;
  margin-block-start: 2px;
}

.fn-pw__hero p {
  margin: 0 0 var(--fn-s1);
  max-inline-size: 56ch;
}

.fn-pw__promise {
  font-size: 0.82rem;
  color: var(--fn-online, var(--fn-primary-text));
}

/* The recovery story is "there isn't one". It is said on the screen, in the
   warning colour, above the form — not tucked into a tooltip somebody reads
   after they have already trusted this with a bank password. */
.fn-pw__warning {
  display: flex;
  align-items: center;
  gap: var(--fn-s1);
  font-size: 0.78rem;
  color: var(--fn-danger-text, #ff6b6b);
}

.fn-pw__form,
.fn-pw__list {
  display: grid;
  gap: var(--fn-s3);
}

/* "Add a secret" and "Fast password" side by side, wrapping to two lines on a
   narrow phone rather than shrinking either past its label. */
.fn-pw__addrow {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  flex-wrap: wrap;
}

.fn-pw__addbtn,
.fn-pw__fastbtn {
  display: inline-flex;
  align-items: center;
  gap: var(--fn-s2);
  justify-self: start;
}

.fn-pw__draft {
  display: grid;
  gap: var(--fn-s3);
  padding: var(--fn-s3);
  border: 1px solid var(--fn-border);
  border-radius: 14px;
  background: var(--fn-surface-2);
}

/* Monospaced while composing: a generated password is read character by
   character when somebody proof-reads it against the field they are pasting
   into, and a proportional face makes l/1 and O/0 a coin toss. */
.fn-pw__secretinput {
  font-family: var(--fn-mono, monospace);
}

.fn-pw__genrow {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  flex-wrap: wrap;
}

.fn-pw__bits {
  font-size: 0.75rem;
  color: var(--fn-fg-muted);
  margin-inline-start: auto;
}

.fn-pw__gen {
  display: grid;
  gap: var(--fn-s2);
  padding: var(--fn-s3);
  border: 1px dashed var(--fn-border);
  border-radius: 12px;
}

.fn-pw__len {
  display: flex;
  align-items: center;
  gap: var(--fn-s2);
  font-size: 0.82rem;
}

.fn-pw__len input[type="range"] {
  flex: 1;
  min-inline-size: 8rem;
}

.fn-pw__len output {
  font-family: var(--fn-mono, monospace);
  min-inline-size: 3ch;
  text-align: end;
}

.fn-pw__classes {
  display: flex;
  gap: var(--fn-s2);
  flex-wrap: wrap;
}

.fn-pw__class {
  padding: var(--fn-s1) var(--fn-s3);
  border: 1px solid var(--fn-border);
  border-radius: 999px;
  background: transparent;
  color: var(--fn-fg-muted);
  font-size: 0.78rem;
  cursor: pointer;
}

.fn-pw__class--on {
  border-color: var(--fn-primary-line);
  color: var(--fn-primary-text);
}

.fn-pw__error {
  margin: 0;
  font-size: 0.8rem;
  color: var(--fn-danger-text, #ff6b6b);
}

.fn-pw__actions {
  display: flex;
  gap: var(--fn-s2);
  justify-content: flex-end;
}

.fn-pw__filterrow {
  display: flex;
  align-items: center;
  gap: var(--fn-s3);
}

.fn-pw__count {
  font-size: 0.75rem;
  color: var(--fn-fg-muted);
  white-space: nowrap;
}

.fn-pw__loading {
  display: grid;
  place-items: center;
  padding: var(--fn-s5) 0;
}

.fn-pw__rows {
  display: grid;
  gap: var(--fn-s2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.fn-pwcard {
  display: grid;
  gap: var(--fn-s2);
}

.fn-pwcard__head {
  display: flex;
  align-items: baseline;
  gap: var(--fn-s3);
}

.fn-pwcard__name {
  font-weight: 600;
  overflow-wrap: anywhere;
}

.fn-pwcard__head .fn-hitcard__time {
  margin-inline-start: auto;
  white-space: nowrap;
}

.fn-pwcard__value {
  font-family: var(--fn-mono, monospace);
  font-size: 0.85rem;
  overflow-wrap: anywhere;
  /* Selectable on purpose: hand-selecting the revealed secret is the fallback
     when the clipboard is unavailable, which on a plain-http LAN origin is
     every time. */
  user-select: text;
}

.fn-pwcard__value--masked {
  letter-spacing: 0.15em;
  color: var(--fn-fg-muted);
}

.fn-pwcard__sealed {
  font-size: 0.8rem;
  color: var(--fn-fg-muted);
}

.fn-pwcard__actions {
  display: flex;
  gap: var(--fn-s2);
  flex-wrap: wrap;
}

.fn-pwcard__actions .topcoat-button,
.fn-pwcard__actions .topcoat-button--quiet {
  display: inline-flex;
  align-items: center;
  gap: var(--fn-s1);
}

.fn-pwcard__remove--armed {
  color: var(--fn-danger-text, #ff6b6b);
  border-color: var(--fn-danger-text, #ff6b6b);
}

/* Armed paste is amber, not red: it is destructive, but it is not delete, and
   two identically red buttons a thumb apart is how the wrong one gets a second
   click. */
.fn-pwcard__paste--armed {
  color: var(--fn-crown-text, #d99b1b);
  border-color: var(--fn-crown-text, #d99b1b);
}

/* --- My Jarvis: the agent at work -------------------------------------- */

/* The activity line. It replaces a spinner with a sentence, because the
   interesting part of a tool-using agent is *which* tool — a six-second pause
   labelled "thinking" reads as a hang, and the same pause labelled "searching
   your rooms" reads as work. */
.fn-jarvis-activity {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0 16px 8px;
  padding: 9px 14px;
  border: 1px solid var(--fn-accent-dim, rgb(120 170 255 / 0.28));
  border-radius: 999px;
  background: linear-gradient(
    100deg,
    var(--fn-surface-2, rgb(255 255 255 / 0.04)),
    var(--fn-accent-wash, rgb(120 170 255 / 0.1))
  );
  font-size: 0.86rem;
  color: var(--fn-text-2, rgb(255 255 255 / 0.78));
}
.fn-jarvis-activity__label {
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.fn-jarvis-activity__pulse {
  display: inline-flex;
  flex: none;
  gap: 3px;
}
.fn-jarvis-activity__pulse i {
  inline-size: 5px;
  block-size: 5px;
  border-radius: 50%;
  background: var(--fn-accent, rgb(120 170 255));
  animation: fn-jarvis-pulse 1.1s ease-in-out infinite;
}
.fn-jarvis-activity__pulse i:nth-child(2) {
  animation-delay: 0.16s;
}
.fn-jarvis-activity__pulse i:nth-child(3) {
  animation-delay: 0.32s;
}
@keyframes fn-jarvis-pulse {
  0%,
  100% {
    opacity: 0.3;
    transform: translateY(0);
  }
  50% {
    opacity: 1;
    transform: translateY(-3px);
  }
}
/* Somebody who has asked not to see motion still needs to know it is working,
   so the dots stop moving and stay lit rather than disappearing. */
@media (prefers-reduced-motion: reduce) {
  .fn-jarvis-activity__pulse i {
    animation: none;
    opacity: 0.85;
  }
}

/* The vault switch. Deliberately wordy for a control this small: it grants a
   language model access to a password store, and the sentence under it is the
   only place the actual guarantee ("it never sees a password") is stated where
   somebody is deciding. */
.fn-jarvis-vault {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  margin: 0 16px 8px;
  padding: 10px 14px;
  border: 1px solid var(--fn-border, rgb(255 255 255 / 0.08));
  border-radius: 12px;
  background: var(--fn-surface-2, rgb(255 255 255 / 0.03));
  cursor: pointer;
}
.fn-jarvis-vault input {
  margin-block-start: 2px;
  flex: none;
}
.fn-jarvis-vault__text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-inline-size: 0;
}
.fn-jarvis-vault__text strong {
  font-size: 0.88rem;
  font-weight: 600;
}
.fn-jarvis-vault__text small {
  font-size: 0.76rem;
  line-height: 1.4;
  color: var(--fn-text-3, rgb(255 255 255 / 0.55));
}

/* The confirmation body. A proposed message is quoted rather than described,
   so what is approved is the text that will actually be sent. */
.fn-jarvis-confirm {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 6px 14px;
  margin: 0;
}
.fn-jarvis-confirm dt {
  font-size: 0.78rem;
  color: var(--fn-text-3, rgb(255 255 255 / 0.55));
}
.fn-jarvis-confirm dd {
  margin: 0;
  min-inline-size: 0;
  overflow-wrap: anywhere;
}
.fn-jarvis-confirm__body {
  grid-column: 1 / -1;
  padding: 10px 12px;
  border-inline-start: 3px solid var(--fn-accent, rgb(120 170 255));
  border-radius: 4px;
  background: var(--fn-surface-2, rgb(255 255 255 / 0.04));
  white-space: pre-wrap;
}

/* The starter chips. Only ever on screen in an empty My Jarvis — the job is to
   teach the tool surface exists, and once there is a transcript the transcript
   does that better. */
.fn-jarvis-starters {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin: 0 16px 10px;
}
.fn-jarvis-starters__lead {
  font-size: 0.76rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fn-text-3, rgb(255 255 255 / 0.5));
}
.fn-jarvis-starters__row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.fn-jarvis-starter {
  padding: 8px 14px;
  border: 1px solid var(--fn-border, rgb(255 255 255 / 0.12));
  border-radius: 999px;
  background: var(--fn-surface-2, rgb(255 255 255 / 0.04));
  color: inherit;
  font: inherit;
  font-size: 0.84rem;
  cursor: pointer;
  transition:
    border-color 120ms ease,
    background 120ms ease,
    transform 120ms ease;
}
.fn-jarvis-starter:hover {
  border-color: var(--fn-accent, rgb(120 170 255));
  background: var(--fn-accent-wash, rgb(120 170 255 / 0.12));
}
.fn-jarvis-starter:active {
  transform: translateY(1px);
}
@media (prefers-reduced-motion: reduce) {
  .fn-jarvis-starter {
    transition: none;
  }
  .fn-jarvis-starter:active {
    transform: none;
  }
}
