/*
 * NHRL Portal chrome — Bootstrap 5.3 light/dark sidebar + system bar.
 * data-bs-theme lives on <html> (global); the toggle also syncs it onto
 * #site-footer and .portal-page-themed.
 * Themed pages (.portal-page-themed): dark chrome at 20% over matching dark page bg.
 */

:root {
    --portal-chrome-opacity-dark: 0.2;
    --portal-chrome-opacity-dark-fallback: 0.94;
    /* 0.85, not the old 0.92: at 0.92 the saturate(130%) in --portal-blur-chrome
       had almost nothing left to work on, so light chrome was a flat white bar
       that happened to be running a blur. */
    --portal-chrome-opacity-light: 0.85;
    /* Dark divider is BLACK, matching the keyline rule in
       portal-components.css: the strips are lighter than the near-black
       canvas, so the seam between them and the content is a dark line with
       a bright catch just inside it (the specular below), never a grey
       line drawn on top. Light theme keeps its purple-tinted hairline —
       there the page is the bright thing and the logic inverts. */
    --portal-chrome-border-dark: rgba(0, 0, 0, 0.66);
    --portal-chrome-border-light: rgba(41, 28, 94, 0.1);
    /* Chrome bevel: a 1px highlight immediately INSIDE each strip's divider,
       on the edge that faces content. Divider + inner highlight is the pair
       that makes a chrome strip read as machined rather than drawn — the three
       most persistent surfaces in the app had no edge treatment at all.
       Defined here (not in portal-components.css) because the check-in kiosk
       loads this sheet standalone; the strips must never depend on the token
       file for their own material. */
    --portal-chrome-specular-dark: rgba(255, 255, 255, 0.11);
    --portal-chrome-specular-light: rgba(255, 255, 255, 0.65);
    /* A floating slab needs a bottom shade as well as a top catch: bounded on
       four sides, it is lit from above like everything else. */
    --portal-chrome-shade-dark: rgba(0, 0, 0, 0.5);
    --portal-chrome-shade-light: rgba(28, 25, 54, 0.07);
    /* The slab's own drop shadow. It is what lifts the chrome off the canvas
       — without it an inset bar reads as a hole cut in the page rather than
       an object on it. */
    /* The strip tint as TOKENS (v3 #11): the gallery's chrome mock and the
       real strips read the same value, so the mock cannot lie. */
    /* The inner var resolves AT THE CONSUMER, so the fallback chain must
       land on the glass-on default (the strips' own scope overrides
       --portal-chrome-opacity for glass-off) — a bare 0.94 fallback made
       the home-logo mirror compute the DEGRADED tint while the topbar
       computed glass, which screensaver.spec caught within the hour. */
    --portal-c-chrome-surface: rgba(31, 29, 44, var(--portal-chrome-opacity, var(--portal-chrome-opacity-dark, 0.2)));
    --portal-c-chrome-surface-light: rgba(244, 244, 246, var(--portal-chrome-opacity-light, 0.85));
    --portal-chrome-elev: 0 2px 6px rgba(0, 0, 0, 0.28), 0 10px 28px rgba(0, 0, 0, 0.34);
    --portal-chrome-elev-scrolled: 0 4px 12px rgba(0, 0, 0, 0.45), 0 16px 40px rgba(0, 0, 0, 0.55);
    --portal-accent: #cf23ca;
    --portal-accent-teal: #00dad8;
    --portal-nhrl-purple: rgb(41, 28, 94);
    /* No consumers yet — kept deliberately: tournament-logger.css:17 names it
       as the token its hardcoded #21164b resolves to when Gil's pages adopt
       the system (docs/plans/DESIGN-SYSTEM-ADOPTION.md). */
    --portal-nhrl-purple-deep: rgb(33, 22, 75);

    /* ── Design language (2026-07; body face v3 2026-08) ───────────────
     * Type: a display serif for page titles, a mono for micro-labels/data,
     * Inter for everything else. "Later" arrived: the body stack leads with
     * the self-hosted Inter 4.1 variable (fonts.css), with "Inter Fallback"
     * — Arial wearing Inter's fontpie metrics — so the swap moves no line
     * boxes, and the old system stack behind both. The mono stays system:
     * SF Mono/Menlo are fine at 11px and cost zero bytes.
     */
    /* Corner colour bases — raw brand values, kiosk-safe (no derivation
       here: the inks need --portal-c-text, which lives on body). */
    --portal-c-corner-pink: var(--portal-accent);
    --portal-c-corner-blue: var(--portal-accent-teal);

    --portal-font-display: "Joyride STD", "Arial Black", sans-serif;
    --portal-font-body: "Inter", "Inter Fallback", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
    --portal-font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;

    /* Type ladder — 11/12/13/14/16/18/21.6/24/32px (v3 §2.6). Nearest-value
       rounding for page CSS; the two kit roles that sit between steps are
       named, not rounded. Tuned on Inter (the previous commit) on purpose —
       a ladder tuned on the system face would be re-tuned twice. */
    --portal-text-2xs: 0.6875rem;  /* 11px — mono labels, dense meta */
    --portal-text-xs: 0.75rem;     /* 12px — captions, chips, badges (was 0.72) */
    --portal-text-sm: 0.8125rem;   /* 13px — buttons, tabs, table body, dense UI (was 0.85) */
    --portal-text-md: 0.875rem;    /* 14px — row values, tile titles, form fields */
    --portal-text-base: 1rem;      /* 16px — prose, phone inputs */
    --portal-text-lg: 1.125rem;    /* 18px — empty-state titles, phone page title (was 1.35) */
    --portal-text-xl: 1.35rem;     /* 21.6px — page title (was 1.5) */
    --portal-text-2xl: 1.5rem;     /* 24px — empty-state glyph, hero figures */
    --portal-text-3xl: 2rem;       /* 32px — display numerals on live surfaces (Inter opsz 32) */
    --portal-title-page: var(--portal-text-xl);
    --portal-title-card: 0.9375rem; /* role: card/modal titles — deliberately between md and base */
    --portal-weight-body: 400;
    --portal-weight-medium: 500;
    --portal-weight-label: 600;
    --portal-weight-strong: 700;
    --portal-leading-tight: 1.2;
    --portal-leading-snug: 1.3;
    --portal-leading-body: 1.45;
    /* The mono micro-label, one voice */
    --portal-label-size: var(--portal-text-2xs);
    --portal-label-size-sm: 0.625rem; /* 10px — chrome only: FLAT/POLL tags, tab-bar labels, breadcrumbs */
    --portal-label-weight: var(--portal-weight-label);
    --portal-label-tracking: 0.08em;  /* was 0.12 — the kit's two canonical labels already used .08 */
    --portal-tracking-caps: 0.03em;   /* uppercase SANS (badges, chips) — not the mono label */

    /* Aurora — atmospheric brand colour, kept broad and low-chroma so it
       reads as depth in the canvas rather than as two coloured spotlights. */
    --portal-aurora-magenta: rgba(207, 35, 202, 0.07);
    --portal-aurora-violet: rgba(104, 78, 168, 0.18);
    --portal-aurora-teal: rgba(20, 132, 139, 0.14);
    /* On paper these are just enough to keep translucent surfaces legible;
       they should never present as coloured areas in their own right. */
    --portal-aurora-violet-light: rgba(91, 69, 151, 0.05);
    --portal-aurora-teal-light: rgba(14, 119, 124, 0.04);

    /* Oversized ellipses keep the falloff gradual. Their centres sit outside
       the viewport so there is no visible hot spot or hard colour boundary. */
    --portal-aurora-violet-pos: 70% -10%;
    --portal-aurora-violet-size: 96rem 54rem;
    --portal-aurora-teal-pos: 2% 106%;
    --portal-aurora-teal-size: 80rem 48rem;

    /* Radius scale — concentric Apple-tier radii. Bootstrap 5.3 routes every
     * component radius (btn, card, modal, dropdown, nav-pills, forms) through
     * these tokens, so this is the whole switch — and the whole revert.
     * Tiers: sm = nested chips, base = controls, lg = cards/menus,
     * xl = modals/dialogs, xxl = hero surfaces (login) only.
     * --bs-border-radius-pill is left alone: genuinely round things stay round.
     */
    --bs-border-radius: 0.375rem;                    /* 6px */
    --bs-border-radius-sm: 0.25rem;    /* 4px */
    --bs-border-radius-lg: 0.625rem;   /* 10px */
    --bs-border-radius-xl: 0.875rem;   /* 14px */
    --bs-border-radius-xxl: 1.25rem;   /* 20px */
    /* …and see the [data-bs-theme] twin below: on :root alone, half of this
       scale was inert in light theme. */


    /* ── Glass blur vocabulary ────────────────────────────────────────
     * Single lever per role; every backdrop-filter rule (both spellings)
     * reads one of these. The degrade gates at the end of
     * portal-components.css re-point them to none. Never write a literal
     * blur() in a component rule. Dimmers are hard-capped at 2px — a
     * full-viewport blur is the most expensive surface there is. */
    --portal-blur-chrome: blur(14px) saturate(130%);
    /* 12px, down from 20px on 2026-08-12. Overlay alpha never changed (0.72,
       the same --portal-c-overlay ⌘K uses) — but at 20px everything behind the
       dialog smeared into a flat field, so the material read as a solid panel
       and the tier looked like it had no glass at all. 12 keeps enough
       structure to read as translucent while staying a clear step heavier than
       ⌘K's 8px + lens, which is the hero tier and stays reserved for ⌘K and the
       login card. Shared by modals, Tom Select menus and alertify dialogs —
       they move together on purpose. */
    --portal-blur-overlay: blur(12px) saturate(140%);
    --portal-blur-dim: blur(2px);

    /* Spacing scale (4px base). For site/kit CSS internals — templates keep
     * using Bootstrap utilities (g-3 ≈ space-4, mb-4 ≈ space-5). */
    --portal-space-1: 0.25rem;  /* icon gaps */
    --portal-space-2: 0.5rem;   /* control gaps, header action gaps */
    --portal-space-3: 0.75rem;  /* intra-card element gap */
    --portal-space-4: 1rem;     /* card padding, grid gutter */
    --portal-space-5: 1.5rem;   /* between page regions */
    --portal-space-6: 2rem;     /* section gap on long pages */
    --portal-space-7: 3rem;     /* empty-state breathing room */

    --portal-leading-tight: 1.2;

    /* One control height, for anything that can sit on a row with a button:
     * .btn, .form-control, .form-select, Tom Select. 2.25rem is what a themed
     * button already computes to (13px text × 1.5 + 2 × 0.45rem + borders =
     * 35.88px) — the token names that number so the fields can meet it.
     *
     * Load-bearing detail, and the reason this exists: `.portal-page-themed
     * .btn` sets font-size and padding DIRECTLY, which out-specifies the
     * --bs-btn-* custom properties `.btn-sm` sets. So .btn-sm has no effect in
     * the portal — every button is this height — while Bootstrap's fields kept
     * two sizes, leaving a filter search 4.9px shorter than the button beside
     * it on every filter bar in the app. Applied in portal-components.css. */
    --portal-control-h: 2.25rem;
    /* Icon-tile scale (v3 §2.8): four sizes, one class. sm = the ios row
       icon, md = stat/badge/card-header, lg = compact empty state, xl = the
       full empty state. sm/md square on the control radius; lg/xl round. */
    --portal-icon-tile-sm: 1.8125rem;
    --portal-icon-tile-md: 2.5rem;
    --portal-icon-tile-lg: 2.75rem;
    --portal-icon-tile-xl: 4rem;

    /* Z-index ladder.
     *
     * Eleven literals of 1000 or more were spread across five files with
     * nothing saying what sits above what — the answer lived in whoever last
     * needed something on top. Every rung below is a real consumer, and the
     * gaps are deliberate: fab/drawer sit just above the chrome strips
     * because they are raised BY them, and chrome-raised is where the rail's
     * flyout has to clear the strip it belongs to. (The -topbar rung, 1040,
     * retired with the topbar in v3.1 — the number stays unoccupied.)
     *
     * A menu anchored to a chrome strip does NOT take a rung — it rides its
     * strip's stacking context, which is why the system bar's Appearance
     * dropup and the bell need nothing here. */
    --portal-z-dock: 10;
    --portal-z-sticky: 1020;
    --portal-z-chrome: 1030;
    --portal-z-tabbar: 1035;
    --portal-z-fab: 1041;
    --portal-z-drawer: 1042;
    --portal-z-chrome-raised: 1045;
    --portal-z-offcanvas: 1050;
    --portal-z-modal: 1055;
    /* Impersonation banner and the collapsed user menu: above the modal on
       purpose — "you are acting as someone else" outranks whatever dialog is
       open, because acting on the wrong account is the mistake it prevents. */
    --portal-z-top: 1060;
    /* Arrival banners (notifications-bell.css): over modals like the OS's
       own banners, under the toast tier — an alertify error must still beat
       an announcement. */
    --portal-z-banner: 1978;
    /* alertify's own, documented rather than fought: the vendor sets 1982 and
       toasts must clear everything. */
    --portal-z-toast: 1982;

    /* Motion vocabulary — consumed by portal-motion.css and component
     * transitions. Micro (hovers/color) / small movement / entrances. */
    /* Press-in only. 80ms is under the ~100ms threshold where a press reads
       as a response rather than an animation — the release is what gets the
       spring. NOT a home for the 0.15s literals scattered through page CSS:
       those map to dur-1, and a third short duration would just be a slower
       dur-1 nobody could tell apart. */
    --portal-dur-0: 80ms;
    --portal-dur-1: 120ms;
    --portal-dur-2: 200ms;
    --portal-dur-3: 320ms;
    /* Attention decays — a value that just ticked, a row that just changed.
       Long on purpose: these are "you missed nothing" signals, read at a
       glance from across a pit, not UI feedback. */
    --portal-dur-4: 700ms;
    --portal-dur-5: 900ms;
    --portal-ease-out: cubic-bezier(0.2, 0.7, 0.3, 1);
    --portal-ease-in-out: cubic-bezier(0.45, 0, 0.25, 1);
    /* Emphasized decelerate — overlay ENTRANCES only (dialogs, modals,
       toasts): hard initial velocity, long settle, monotonic (never
       crosses 1 — no overshoot, house rule). Exits use --portal-ease-in-out. */
    --portal-ease-emphasized: cubic-bezier(0.05, 0.7, 0.1, 1);
    /* Hero arrivals — the one curve allowed to take its time settling. */
    --portal-ease-settle: cubic-bezier(0.16, 1, 0.3, 1);
    /* The spring. 2.04% overshoot: enough to read as a release, small enough
       that a 2px lift does not visibly overshoot into 3. Upgraded below where
       linear() exists. Allowed on transform/translate of NON-BLURRED elements
       only — a spring on a blurred surface re-samples the backdrop every frame,
       which is the per-frame GPU cost the degrade gates exist to avoid. */
    --portal-ease-spring: cubic-bezier(0.3, 1.25, 0.5, 1);
}

/* ── The app face reaches Bootstrap (v3 §2.6) ─────────────────────────
 * Scoped to the app shell, NOT :root: the TV bracket shell loads the kit
 * stylesheets too, and an unscoped re-point would make a venue wall fetch
 * the woff2 for a page whose every rule pins its own faces. Everything with
 * a .portal-page-themed <main> — every app page, login and the auth pages
 * included (all carry the class; confirmed, not assumed) — reads Inter.
 * font-optical-sizing selects the Display end of the opsz axis on its own. */
body:has(.portal-page-themed) {
    --bs-font-sans-serif: var(--portal-font-body);
    --bs-body-font-family: var(--portal-font-body);
    font-family: var(--portal-font-body);
    font-optical-sizing: auto;
}

/* Same commit as the face on purpose: Playwright runs on macOS, so a later
   smoothing change would be a second full re-baseline for two lines. */
html[data-bs-theme="dark"] body {
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

/* The real spring, where the engine can express one.
 *
 * THE @supports IS LOAD-BEARING, not politeness. A custom property is not
 * parse-validated, so re-declaring --portal-ease-spring with a linear() ramp
 * an engine cannot parse would store it as guaranteed-invalid and every
 * consumer would fall back to `ease` — the same trap as the color-mix tokens
 * the parse floor exists for, one property over.
 *
 * The ramp is a damped oscillator sampled at ζ=0.78: peak 1.020 at 42%,
 * settled by 72%. A cubic-bezier cannot express a settle, only a single
 * overshoot; this decays. */
@supports (animation-timing-function: linear(0, 1)) {
    :root {
        --portal-ease-spring: linear(
            0, 0.0063 2%, 0.0246 4%, 0.0947 8.1%, 0.2063 12.6%, 0.3653 17.6%,
            0.5252 22.2%, 0.6742 26.8%, 0.8043 31.4%, 0.9111 36%, 0.9919 40.6%,
            1.0197 42.4%, 1.0459 45.1%, 1.0661 48.3%, 1.0765 51.9%, 1.0776 55.9%,
            1.0709 60.4%, 1.0505 68.1%, 1.0243 76.3%, 1.0043 85.4%, 0.9975 92.3%, 1
        );
    }
}

/* Radius scale, restated for themed scopes — NOT redundant.
 *
 * Bootstrap 5.3 ships its radius tokens in `:root,[data-bs-theme=light]`. Every
 * app page carries `data-bs-theme` on <main> (and the chrome strips carry their
 * own), so in LIGHT theme that selector matched an INNER element and shadowed
 * the portal scale for its whole subtree. The result was a silent split: dark
 * theme got the intended 10/14/20, light theme quietly fell back to Bootstrap's
 * 8/16/32 — cards too square, modals too round, the login hero twice its tier.
 * Dark never showed it because Bootstrap's dark block declares no radii.
 *
 * Same specificity as Bootstrap's block (0,1,0); this sheet loads after it, so
 * the later declaration wins. Keep the two lists in sync — a value changed
 * above and not here re-opens exactly this bug, in exactly one theme.
 */
[data-bs-theme="light"],
[data-bs-theme="dark"] {
    --bs-border-radius: 0.375rem;                    /* 6px */
    --bs-border-radius-sm: 0.25rem;    /* 4px */
    --bs-border-radius-lg: 0.625rem;   /* 10px */
    --bs-border-radius-xl: 0.875rem;   /* 14px */
    --bs-border-radius-xxl: 1.25rem;   /* 20px */
}

/* Bootstrap's OWN dark hairline, re-pointed at the portal keyline. Declared
 * here for the same cascade reason as the radius scale above — same
 * specificity as Bootstrap's block, later sheet — and it has to be here
 * rather than on `body` with the other tokens, because `.portal-page-themed`
 * carries data-bs-theme on <main> and would otherwise re-declare Bootstrap's
 * value on an INNER element and shadow ours for the whole page.
 *
 * This is the widest-reaching line in the v3 dark pass. Bootstrap's dark
 * --bs-border-color is #495057 — rgb(73,80,87), a genuinely light grey — and
 * it is the border of every component the portal never explicitly themed:
 * list groups, accordions, input-group addons, nav-tabs, modal header/footer
 * rules, dropdown dividers, and every `.border*` utility in a template. Those
 * were the grey rectangles left over on pages that otherwise looked right;
 * the kit's own components had already moved to --portal-c-border.
 *
 * The literal fallback matters: the check-in kiosk loads this sheet WITHOUT
 * portal-components.css, so --portal-c-border does not exist there. Keep it
 * in sync with the token's value.
 */
[data-bs-theme="dark"] {
    --bs-border-color: var(--portal-c-border, rgba(0, 0, 0, 0.72));
    --bs-border-color-translucent: var(--portal-c-border, rgba(0, 0, 0, 0.72));
    /* .invalid-feedback and friends read these; on the themed <main> the
       token resolves, on a bare shell the literal twin wins — both are the
       same colour by construction (hand-maintained mirror, as above). */
    --bs-form-invalid-color: var(--portal-c-badge-danger-color, #ff7d7d);
    --bs-form-invalid-border-color: var(--portal-c-input-border-invalid, rgba(255, 125, 125, 0.55));
    --bs-form-valid-color: var(--portal-c-badge-success-color, #45d381);
    --bs-form-valid-border-color: var(--portal-c-input-border-valid, rgba(69, 211, 129, 0.55));
}

/* The light twin of the form-state re-point above — light's badge inks are
   the deep pair, so the literal fallbacks differ per theme on purpose. */
[data-bs-theme="light"] {
    --bs-form-invalid-color: var(--portal-c-badge-danger-color, #c92a2a);
    --bs-form-invalid-border-color: var(--portal-c-input-border-invalid, rgba(201, 42, 42, 0.55));
    --bs-form-valid-color: var(--portal-c-badge-success-color, #2b8a3e);
    --bs-form-valid-border-color: var(--portal-c-input-border-valid, rgba(43, 138, 62, 0.55));
}


/* ── Links ────────────────────────────────────────────────────────────
 * Anchors ride the accent instead of Bootstrap blue — the single loudest
 * "stock template" tell. Dark uses a softened teal so body-copy links
 * don't vibrate on the dark surface; full accent arrives on hover. */
/* THE SELECTOR HAS TO MATCH BOOTSTRAP'S, not just <html>.
 *
 * These rules were written as `html[data-bs-theme="…"]` and did nothing for
 * any link on any page for as long as they have existed. Bootstrap declares
 * the same tokens as:
 *
 *     :root, [data-bs-theme="light"] { --bs-link-color: #0d6efd }
 *
 * — an ATTRIBUTE selector, which matches any element carrying it. page.twig
 * puts data-bs-theme on `main.portal-page-themed`, so Bootstrap re-declared
 * the token for the entire page body and the portal value survived only on
 * <html>, where no link lives. Measured before this fix: <html> said #4ce0de
 * and the link rendered #6ea8fe; light said #0a7c7a and rendered #0d6efd.
 *
 * So the comment above was describing an intention, not a behaviour. Matching
 * the attribute the same way Bootstrap does is what makes it true.
 *
 * It also closes an accessibility failure for free: light-theme links measured
 * 4.35:1 against the panel and missed AA. That was Bootstrap blue. The teal is
 * 4.86:1 and passes with no retune — see tests/visual/contrast.spec.js. */
html[data-bs-theme="dark"],
[data-bs-theme="dark"] {
    --bs-link-color: #4ce0de;
    --bs-link-color-rgb: 76, 224, 222;
    --bs-link-hover-color: #00dad8;
    --bs-link-hover-color-rgb: 0, 218, 216;
}

html[data-bs-theme="light"],
[data-bs-theme="light"] {
    --bs-link-color: #0a7c7a;
    --bs-link-color-rgb: 10, 124, 122;
    --bs-link-hover-color: #096866;
    --bs-link-hover-color-rgb: 9, 104, 102;
}

:where(a) {
    text-decoration: none;
}

:where(a:hover) {
    text-decoration: underline;
    text-underline-offset: 2px;
}

/* ── Focus ────────────────────────────────────────────────────────────
 * One keyboard-focus voice, theme-aware (--portal-c-accent flips per theme;
 * the raw teal is only the pre-token fallback). :where() keeps specificity
 * at zero so component-level focus treatments (tabs, form rings) still win. */
:where(a, button, [role="button"], [tabindex]):focus-visible {
    outline: 2px solid var(--portal-c-accent, var(--portal-accent-teal));
    outline-offset: 2px;
}

/* ── Scrollbars ───────────────────────────────────────────────────────
 * OS-default chunky bars glare on dark panels; thin themed bars both ways —
 * but ONLY on inner scroll containers. The DOCUMENT scrollbar is excluded
 * (:not(body); html isn't matched by a descendant selector): styling
 * ::-webkit-scrollbar on the root turns macOS overlay scrollbars into
 * classic ones that take 8px of layout, so the fixed topbar's right-edge
 * content shifted sideways between scrolling and non-scrolling pages. */
html {
    /* Classic-scrollbar systems (Windows, "always show" on macOS): keep the
       viewport gutter reserved so page-to-page scrollbar appearance can't
       shift the layout either. Overlay scrollbars ignore this. The aurora
       canvas paints the reserved strip, so it's invisible. */
    scrollbar-gutter: stable;

    /* ── App-feel touch behaviour ──────────────────────────────────────
       The grey flash on every tap is the loudest "this is a web page" tell,
       and it is worst on the chrome, which lives outside .portal-page-themed
       — hence html, not the themed scope. Anything that loses the flash MUST
       gain a real :active state (sidebar links do, below).

       overscroll-behavior-y: contain keeps the native rubber-band (that is
       what an app feels like) while killing pull-to-refresh and scroll
       chaining — every live surface here polls or streams already, so PTR is
       pure hazard. -x: none complements body's overflow-x: hidden; neither
       touches the iOS edge swipe-back, which is a navigation gesture rather
       than overscroll. Both belong on html: from body they would just
       propagate to the viewport anyway, but only sometimes. */
    -webkit-tap-highlight-color: transparent;
    overscroll-behavior-y: contain;
    overscroll-behavior-x: none;
}

/* THE WHOLE PREFIX IS IN :where(), AND THAT IS THE POINT. A default that
   every scroll container in the portal inherits has to be the easiest thing in
   the file to override, and until 2026-08-30 it was one of the hardest:
   `html[data-bs-theme="dark"] :where(:not(body))` is specificity (0,1,1) — the
   inner :where() zeroed the part nobody needed to outrank while the attribute
   and the element still counted — so a component asking for its own bar with a
   bare single class (0,1,0) LOST, silently, with no error and nothing for a
   test to catch. Eight rules across six files were doing exactly that: the
   floor-strip dots, the audit chips, the notification tabs, three tvb surfaces
   (all asking to HIDE their bar and all still showing one), plus the dashboard
   card and the vision grid asking for their own colours and getting these.

   Zeroed, the blanket is a true default: any single class beats it, which is
   what every one of those rules already assumed. Keep it that way — if this
   ever needs to win against something, the fix is a more specific rule at the
   call site, not specificity back in here. */
:where(html[data-bs-theme="dark"]) :where(:not(body)) {
    scrollbar-width: thin;
    scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}

:where(html[data-bs-theme="light"]) :where(:not(body)) {
    scrollbar-width: thin;
    scrollbar-color: rgba(41, 28, 94, 0.25) transparent;
}

/* Chrome 121+ IGNORES every rule below on any element that also sets
   scrollbar-width — which the blanket above does, on the same elements. So
   this pill is Safari-only in practice and has almost certainly never rendered
   in Chrome. Kept rather than deleted because Safari before 18.2 has no
   scrollbar-width to honour, and it is the only styling those versions get;
   whether to keep paying for it is UI-IDEAS CJ, not a decision for this pass.
   Zeroed alongside the standard properties so an override needs to beat one
   specificity, not two. */
:where(html[data-bs-theme="dark"]) :where(:not(body))::-webkit-scrollbar {
    width: 8px;
    height: 8px;
}

:where(html[data-bs-theme="dark"]) :where(:not(body))::-webkit-scrollbar-thumb {
    background: rgba(255, 255, 255, 0.16);
    border-radius: 999px;
    border: 2px solid transparent;
    background-clip: content-box;
}

:where(html[data-bs-theme="light"]) :where(:not(body))::-webkit-scrollbar {
    width: 8px;
    height: 8px;
}

:where(html[data-bs-theme="light"]) :where(:not(body))::-webkit-scrollbar-thumb {
    background: rgba(41, 28, 94, 0.22);
    border-radius: 999px;
    border: 2px solid transparent;
    background-clip: content-box;
}

/* ── Unified dark chrome surface ─────────────────────────────────────── */
body:has(.portal-page-themed) {
    --portal-chrome-opacity: var(--portal-chrome-opacity-dark);
    --portal-chrome-divider: var(--portal-chrome-border-dark);
}

body:not(:has(.portal-page-themed)) {
    --portal-chrome-opacity: var(--portal-chrome-opacity-dark-fallback);
    --portal-chrome-divider: var(--portal-chrome-border-dark);
}

#site-sidebar[data-bs-theme="dark"],
#site-footer[data-bs-theme="dark"],
#portal-tab-bar[data-bs-theme="dark"] {
    /* Neutral-with-a-purple-whisper glass tint; the blur value is venue-tuned
       (cheap panels in dark venues) — retint freely, retune blur carefully. */
    background-color: var(--portal-c-chrome-surface);
    background-image: linear-gradient(
        180deg,
        rgba(31, 29, 44, var(--portal-chrome-opacity)) 0%,
        rgba(23, 22, 34, var(--portal-chrome-opacity)) 100%
    );
    backdrop-filter: var(--portal-blur-chrome);
    -webkit-backdrop-filter: var(--portal-blur-chrome);
}

#site-footer[data-bs-theme="dark"] {
    /* A slab has FOUR edges. These were one-sided because the strips were
       edge-to-edge bars and only the content-facing side existed; a floating
       slab is bounded on every side and casts a shadow onto the page. */
    border: 1px solid var(--portal-chrome-border-dark);
    box-shadow: inset 0 1px 0 var(--portal-chrome-specular-dark),
                inset 0 -1px 0 var(--portal-chrome-shade-dark),
                var(--portal-chrome-elev);
    /* The bar carries real text now (breadcrumb, floor tray) — this ink moved
       here from the topbar when the fold retired it. */
    color: rgba(244, 244, 244, 0.88);
}

/* ── Unified light chrome surface ────────────────────────────────────── */
#site-sidebar[data-bs-theme="light"],
#site-footer[data-bs-theme="light"],
#portal-tab-bar[data-bs-theme="light"] {
    /* On paper a black drop shadow is a smudge. Same geometry, violet ink at
       a fraction of the alpha. */
    --portal-chrome-elev: 0 2px 6px rgba(24, 20, 45, 0.06), 0 10px 28px rgba(24, 20, 45, 0.1);
    --portal-chrome-elev-scrolled: 0 4px 12px rgba(24, 20, 45, 0.08), 0 16px 40px rgba(24, 20, 45, 0.14);
    background-color: rgba(255, 255, 255, var(--portal-chrome-opacity-light));
    background-image: none;
    backdrop-filter: var(--portal-blur-chrome);
    -webkit-backdrop-filter: var(--portal-blur-chrome);
    --portal-chrome-divider: var(--portal-chrome-border-light);
}

#site-footer[data-bs-theme="light"] {
    /* A slab has FOUR edges. These were one-sided because the strips were
       edge-to-edge bars and only the content-facing side existed; a floating
       slab is bounded on every side and casts a shadow onto the page. */
    border: 1px solid var(--portal-chrome-border-light);
    box-shadow: inset 0 1px 0 var(--portal-chrome-specular-light),
                inset 0 -1px 0 var(--portal-chrome-shade-light),
                var(--portal-chrome-elev);
    /* The bar carries real text now (breadcrumb, floor tray) — this ink moved
       here from the topbar when the fold retired it. */
    color: #495057;
}

/* ── Phone tab bar — chrome family, content-facing edge is its top ──── */
#portal-tab-bar[data-bs-theme="dark"] {
    border: 1px solid var(--portal-chrome-border-dark);
    box-shadow: inset 0 1px 0 var(--portal-chrome-specular-dark),
                inset 0 -1px 0 var(--portal-chrome-shade-dark),
                var(--portal-chrome-elev);
}

#portal-tab-bar[data-bs-theme="light"] {
    border: 1px solid var(--portal-chrome-border-light);
    box-shadow: inset 0 1px 0 var(--portal-chrome-specular-light),
                inset 0 -1px 0 var(--portal-chrome-shade-light),
                var(--portal-chrome-elev);
}

/* ── Sidebar — chrome family, the vertical slab ─────────────────────────
 * Back on 2026-08-28 as a per-account preference (SidebarService). It takes
 * the same four-edge treatment as the system bar because it is the same piece
 * of chrome standing up: a floating slab is bounded on every side and casts a
 * shadow onto the page.
 *
 * Geometry is navigation.css; these are the inks. */
#site-sidebar[data-bs-theme="dark"] {
    border: 1px solid var(--portal-chrome-border-dark);
    box-shadow: inset 0 1px 0 var(--portal-chrome-specular-dark),
                inset 0 -1px 0 var(--portal-chrome-shade-dark),
                var(--portal-chrome-elev);
    color: rgba(244, 244, 244, 0.88);
    /* NO background-image, deliberately: the slab takes the shared chrome
     * gradient from the selector above, which is byte-for-byte what the system
     * bar wears. This used to add two decorative radials on top of it — magenta
     * at the head, teal at the foot — which made the sidebar the one piece of
     * chrome painted differently from the rest of it. The two ends of one
     * chrome have to read as one piece, and the aurora belongs to the CANVAS
     * (portal-page-theme.css), which the slab is supposed to float above rather
     * than carry a second copy of. */
}

#site-sidebar[data-bs-theme="light"] {
    border: 1px solid var(--portal-chrome-border-light);
    box-shadow: inset 0 1px 0 var(--portal-chrome-specular-light),
                inset 0 -1px 0 var(--portal-chrome-shade-light),
                var(--portal-chrome-elev);
    color: #495057;
}

/* ── Sidebar links ──────────────────────────────────────────────────────
 * Four states, and the current-page one must not compete with hover for the
 * same channel. It used to: a 10% white wash sat two percent above an 8%
 * hover, which on a cheap venue panel in a dark room is not a difference
 * anyone reads. So "you are here" is carried by the accent PILL on the
 * leading edge (navigation.css) and the wash halves to a quiet backing.
 *
 * The pressed state is not decoration. Touch has no hover and the tap
 * highlight is suppressed globally, so on a tablet this is the ONLY feedback
 * a finger gets from a nav row. */
#site-sidebar[data-bs-theme="dark"] .portal-sidebar-link {
    color: rgba(244, 244, 244, 0.78);
}

#site-sidebar[data-bs-theme="dark"] .portal-sidebar-link:hover,
#site-sidebar[data-bs-theme="dark"] .portal-sidebar-link:focus {
    color: #fff;
    background: var(--portal-c-chrome-hover);
}

#site-sidebar[data-bs-theme="dark"] .portal-sidebar-link:active {
    background: rgba(255, 255, 255, 0.14);
}

#site-sidebar[data-bs-theme="dark"] .portal-sidebar-link.is-active {
    color: #fff;
    /* Half the hover, deliberately — see the note above. Kept a literal
       rather than a color-mix off --portal-c-chrome-hover so it reads at a
       glance next to the value it is half of. */
    background: rgba(255, 255, 255, 0.05);
}

#site-sidebar[data-bs-theme="dark"] .portal-sidebar-link.is-active i {
    opacity: 1;
    color: var(--portal-c-accent);
}

#site-sidebar[data-bs-theme="light"] .portal-sidebar-link {
    color: #495057;
}

#site-sidebar[data-bs-theme="light"] .portal-sidebar-link:hover,
#site-sidebar[data-bs-theme="light"] .portal-sidebar-link:focus {
    color: var(--portal-nhrl-purple);
    background: var(--portal-c-chrome-hover);
}

#site-sidebar[data-bs-theme="light"] .portal-sidebar-link:active {
    background: rgba(41, 28, 94, 0.12);
}

#site-sidebar[data-bs-theme="light"] .portal-sidebar-link.is-active {
    color: var(--portal-nhrl-purple);
    background: rgba(41, 28, 94, 0.05);
}

/* On paper the accent is the darker teal (--portal-c-accent is #0a7c7a in the
   light theme), which is what the pill draws — but the glyph sits inside a row
   already inked purple, and two accents in one row is one too many. */
#site-sidebar[data-bs-theme="light"] .portal-sidebar-link.is-active i {
    opacity: 1;
    color: var(--portal-nhrl-purple);
}

/* The badge's white tint vanishes on the light chrome surface. */
#site-sidebar[data-bs-theme="light"] .portal-sidebar-badge {
    background: rgba(41, 28, 94, 0.08);
}

/* Inactive slots are quiet; the current page is the only teal in the bar.
   Colour AND weight carry it, never colour alone. */
#portal-tab-bar[data-bs-theme="dark"] .portal-tab-link {
    color: rgba(244, 244, 244, 0.62);
}

#portal-tab-bar[data-bs-theme="light"] .portal-tab-link {
    color: #6c757d;
}

#portal-tab-bar .portal-tab-link:hover,
#portal-tab-bar .portal-tab-link:focus-visible {
    color: var(--portal-c-accent, #00dad8);
}

#portal-tab-bar .portal-tab-link[aria-current="page"] {
    color: var(--portal-c-accent, #00dad8);
    font-weight: var(--portal-weight-strong);
}

/* No tap flash (suppressed globally), so a press needs its own answer. */
#portal-tab-bar .portal-tab-link:active .portal-tab-icon {
    transform: translateY(1px);
}

#portal-tab-bar .portal-tab-badge {
    background: var(--portal-c-badge-danger-color, #ff7d7d);
    color: #1a1922;
}

#portal-tab-bar[data-bs-theme="light"] .portal-tab-badge {
    background: var(--portal-c-btn-danger-bg, #c92a2a);
    color: #fff;
}

/* ── Dark chrome — shared text & controls ─────────────────────────── */
/* Touch has no hover, and the tap highlight is suppressed globally — so a
   pressed state is the ONLY feedback a finger gets. Deeper than hover. */

/* Chrome-nested menus stay OPAQUE, no blur of their own: a child
   backdrop-filter inside the filtered chrome samples the chrome's backdrop
   root, not the page — it blurs nothing in Chromium and mis-clips in Safari. */
/* ── Light chrome — component colors ─────────────────────────────────── */
#site-footer[data-bs-theme="light"] .nhrl-logo-svg path[fill="white"] {
    fill: var(--portal-nhrl-purple);
}

/* See the dark twin — the pressed state replaces the suppressed tap flash. */
/* The badge's default white tint vanishes on the light chrome surface. */
/* ── Shared chrome components ──────────────────────────────────────── */
#site-footer.footer,
#site-footer.portal-chrome-footer {
    /* The topbar's mirror: an inset slab, not merged into the sidebar. The
       fallbacks matter — this file also loads standalone on the check-in
       screensaver, where none of the chrome tokens exist and it degrades to
       the original flat bar. */
    right: var(--portal-chrome-inset, 0px);
    left: var(--portal-chrome-inset, 0px);
    bottom: calc(var(--portal-safe-bottom, 0px) + var(--portal-chrome-inset, 0px));
    border-radius: var(--portal-chrome-radius, 0);
    box-sizing: border-box;
    /* footer.twig is fixed-bottom, so on an installed iOS app the home indicator
       would otherwise sit on top of it. Grows downward and pads its content clear.
       Fallbacks because this file also loads without navigation.css (check-in.twig),
       where it degrades to the original flat 44px bar. */
    height: var(--portal-footer-height, 44px);
    min-height: var(--portal-footer-height, 44px);
    max-height: var(--portal-footer-height, 44px);
    padding: 0;
    z-index: var(--portal-z-chrome);
}

#site-footer .container-fluid {
    height: 100%;
    padding-left: calc(0.75rem + var(--portal-safe-left, 0px));
    padding-right: calc(0.75rem + var(--portal-safe-right, 0px));
}

.portal-user-name {
    max-width: 10rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: var(--portal-text-md);
    font-weight: var(--portal-weight-medium);
    line-height: 1.2;
}

.portal-user-dropdown {
    min-width: 15rem;
    padding: 0.5rem;
    margin-top: 0.5rem !important;
}

/* The identity card. It was .portal-user-dropdown-header, an inert row; it is
   a link to /settings now — the Apple ID banner idea, where your name at the
   top of Settings IS the way into your account. Same geometry, so the menu
   does not move; it just does something when you click it. */
.portal-user-identity {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    padding: 0.625rem 0.75rem;
    font-size: var(--portal-text-md);
}

.portal-user-identity-who {
    display: flex;
    flex-direction: column;
    min-width: 0;
    line-height: var(--portal-leading-snug);
}

.portal-user-identity-go {
    margin-left: auto;
    font-size: var(--portal-text-xs);
    opacity: 0.5;
}

.portal-user-identity .sparky-avatar {
    width: 2.25rem;
    height: 2.25rem;
    padding: 3px;
}

/* Concentric: see the formula in portal-components.css. Parent is the menu
   (lg), the gap is the menu's own 2px inset padding. */
/* It carries the Appearance rows now, so it is a tall menu on a short screen —
   a landscape iPad, a small laptop. Scroll rather than run off the top. */
.portal-user-dropdown {
    max-height: calc(100vh - var(--portal-footer-height, 44px) - 2rem);
    overflow-y: auto;
}

.portal-user-dropdown .dropdown-item {
    --portal-radius-parent: var(--bs-border-radius);
    --portal-radius-gap: 2px;
    border-radius: max(var(--bs-border-radius-sm), calc(var(--portal-radius-parent) - var(--portal-radius-gap)));
    font-size: var(--portal-text-md);
    padding: 0.5rem 0.75rem;
}

/* The menu's OWN items. Not the Appearance rows nested inside it since
   2026-08-26: they are .dropdown-item too, and this rule out-specifies
   .portal-appearance-check's opacity:0 — which left every unchecked theme row
   wearing a 75%-opaque tick, so the menu claimed both themes were current. */
.portal-user-dropdown .dropdown-item:not(.portal-appearance-item) i {
    width: 1.125rem;
    text-align: center;
    margin-right: 0.5rem;
    opacity: 0.75;
}

/* ── Footer layout + theme toggle ─────────────────────────────────── */
.footer-inner {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    align-items: center;
    column-gap: 0.75rem;
    height: 100%;
}

/* Left cluster: theme toggle + glass-state tag. */
.footer-left {
    grid-column: 1;
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-shrink: 0;
}

/* Right cluster: Follow menu + Sparky. */
.footer-right {
    grid-column: 3;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 0.4rem;
    flex-shrink: 0;
}

/* Centre cell: the floor tray plus the POLL tag. flex-start, not center —
   the tray continues the left cluster's reading line (brand › breadcrumb ›
   floor), the way a taskbar reads. The POLL tag has to be a sibling of the
   tray, not inside it: floor-strip.js owns the tray's contents. */
.portal-footer-center {
    grid-column: 2;
    display: flex;
    align-items: center;
    justify-content: flex-start;
    gap: 0.75rem;
    min-width: 0;
}

/* ── Glass-state tag ("FLAT") ────────────────────────────────────────
   Rendered hidden in footer.twig; portal-theme.js unhides it only when a
   degrade gate is active (kill switch / reduced transparency / no
   backdrop-filter support), with the reason in the tooltip. Lives here
   rather than navigation.css for the same reason as the server stats:
   the check-in kiosk pulls the footer with portal-theme.css alone. Mono
   is fine — it reports a rendering datum, same register as the stats. */
.portal-glass-flag {
    font-family: var(--portal-font-mono);
    font-size: var(--portal-label-size-sm);
    font-weight: var(--portal-weight-label);
    letter-spacing: var(--portal-label-tracking);
    color: var(--portal-c-text-muted, inherit);
    /* Control edge, not the keyline: this tag has no fill and lives on the
       footer chrome, so a dark rim would erase it outright. The literal
       fallback is the kiosk case again — portal-components.css does not
       load there. */
    border: 1px solid var(--portal-c-edge-control, rgba(255, 255, 255, 0.14));
    border-radius: var(--bs-border-radius-sm);
    padding: 0.1rem 0.35rem;
    user-select: none;
    opacity: 0.8;
}

/* ── Live Bus POLL tag ───────────────────────────────────────────────
   Rendered hidden in footer.twig; live-bus.js unhides it ONLY when the SSE
   stream has degraded to 5s polling. There is deliberately no healthy-state
   badge — the portal auto-updating is the normal case and needs no chrome.
   Amber, and never pulsing: this reports a problem, it isn't a heartbeat.
   Same file as the stats and the FLAT tag, for the same check-in reason —
   which is also why the warning token carries a literal fallback, as the
   stat bars below do: the kiosk loads this file without portal-components.css,
   where the token is defined. */
.portal-live-indicator {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    flex-shrink: 0;
    font-family: var(--portal-font-mono);
    font-size: var(--portal-label-size-sm);
    font-weight: var(--portal-weight-label);
    letter-spacing: var(--portal-label-tracking);
    color: var(--portal-c-badge-warning-color, #f5b342);
}

.portal-live-indicator-dot {
    width: 0.4rem;
    height: 0.4rem;
    border-radius: 50%;
    background: var(--portal-c-badge-warning-color, #f5b342);
}

/* ── System bar: brand, breadcrumb, search ───────────────────────────
   In this file, not navigation.css, because check-in.twig pulls the bar in
   with portal-theme.css alone — every token here carries a literal fallback
   for that standalone load, like the FLAT and POLL tags above. */
.portal-bar-brand {
    display: inline-flex;
    align-items: center;
    flex-shrink: 0;
    line-height: 0;
}

.portal-bar-brand .nhrl-logo-svg {
    height: 18px;
    width: auto;
}

.portal-bar-breadcrumb {
    display: inline-flex;
    align-items: center;
    gap: 0.45rem;
    min-width: 0;
    white-space: nowrap;
    /* NOT overflow:hidden. The group crumb opens a drop-up, and a clip here
       swallows it whole — the menu renders, correctly positioned, entirely
       invisible. Truncation is unaffected: each crumb already carries its own
       overflow:hidden + ellipsis, which is also what lets it shrink as a flex
       item, so the trail still collapses gracefully in a narrow bar. */
    overflow: visible;
    font-size: var(--portal-text-sm, 0.85rem);
}

.portal-bar-crumb-sep {
    opacity: 0.4;
    flex-shrink: 0;
}

.portal-bar-crumb {
    opacity: 0.62;
    overflow: hidden;
    text-overflow: ellipsis;
}

.portal-bar-crumb-current {
    font-weight: var(--portal-weight-label, 600);
    overflow: hidden;
    text-overflow: ellipsis;
}

/* The group crumb is a button that reads as a label until you reach for it —
   the Finder path-bar idiom. It keeps .portal-bar-crumb's opacity at rest so
   the trail still looks like a trail; hover and open bring it up to the
   current page's weight of attention. */
.portal-bar-crumb-group {
    display: inline-flex;
    min-width: 0;
}

.portal-bar-crumb-btn {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
    max-width: 100%;
    padding: 0.1rem 0.3rem;
    margin: 0 -0.3rem;
    border: 0;
    border-radius: var(--bs-border-radius-sm);
    background: transparent;
    color: inherit;
    font: inherit;
    white-space: nowrap;
    cursor: pointer;
    transition: opacity var(--portal-dur-1) var(--portal-ease-out),
                background-color var(--portal-dur-1) var(--portal-ease-out);
}

.portal-bar-crumb-btn:hover,
.portal-bar-crumb-btn:focus-visible,
.portal-bar-crumb-btn[aria-expanded="true"] {
    opacity: 1;
    background-color: var(--portal-c-chrome-hover);
}

/* The caret only admits the crumb is a door once you are near it. */
.portal-bar-crumb-caret {
    font-size: 0.62em;
    opacity: 0;
    transition: opacity var(--portal-dur-1) var(--portal-ease-out);
}

.portal-bar-crumb-btn:hover .portal-bar-crumb-caret,
.portal-bar-crumb-btn:focus-visible .portal-bar-crumb-caret,
.portal-bar-crumb-btn[aria-expanded="true"] .portal-bar-crumb-caret {
    opacity: 0.7;
}

.portal-crumb-menu {
    min-width: 14rem;
    max-height: min(60vh, 26rem);
    overflow-y: auto;
}

.portal-crumb-item {
    display: flex;
    align-items: center;
    gap: 0.55rem;
}

.portal-crumb-item-check {
    margin-left: auto;
    font-size: 0.8em;
    color: var(--portal-c-accent);
}

/* The search-icon twin died 2026-08-26 (it opened the identical surface
   as the launcher below — one door now, ⌘K in its tooltip). */
/* The launcher — opens the palette in browse mode. Same quiet idiom as the
   search button beside it. */
/* ── The dock: pinned pages in the system bar ────────────────────────────
   Icons only, sized and spaced off the same 1.75rem square the bar's other
   controls use, so the dock reads as part of the bar rather than as content
   sitting in it. Rendered only behind enableBarDock and only when the user
   has pins (footer.twig). */
.portal-bar-dock {
    display: inline-flex;
    align-items: center;
    gap: 0.15rem;
    flex-shrink: 0;
    /* The bar is a fixed-height row and the dock is the one segment that grows
       with user data, so it yields before the breadcrumb or the floor tray do.
       Overflow is hidden rather than scrolled: a scrollbar in 44px of chrome
       is worse than a pin you reach through the Launchpad instead. */
    overflow: hidden;
    min-width: 0;
}

/* The divider is a ::before rather than an element so that ONE :empty rule
   below hides the dock and its separator together. An element would need a
   previous-sibling selector, which does not exist. */
.portal-bar-dock::before {
    content: "";
    width: 1px;
    height: 1.1rem;
    margin: 0 0.45rem;
    background: var(--portal-chrome-divider);
    flex-shrink: 0;
}

/* An account with the dock ON but nothing pinned. The <nav> still renders (it
   is what tools.js writes into when a star is toggled), so it must take no
   space until it has something in it — otherwise every unpinned user pays a
   stray divider in the bar for a feature they are not using.

   THIS WAS `:empty` AND `:empty` DOES NOT DO THAT. The element is written by
   Twig as an opening tag, a `{% for %}` that yields nothing, and a closing tag
   on the next line — so it still contains a whitespace TEXT NODE, and `:empty`
   matches only an element with no children at all, text nodes included. The
   rule therefore never fired for the accounts it was written for: an unpinned
   user got a divider, no icons, and then the ghost-recents divider straight
   after it. Two black lines side by side with nothing between them, which is
   exactly how it was reported — "nhrl > home | |".

   `:has()` asks the question the rule always meant: not "is this element
   empty" but "does it have any items". It is also robust to the JS path —
   tools.js empties and refills this nav in place, and the answer stays right
   without anyone remembering to strip whitespace. */
.portal-bar-dock:not(:has(.portal-bar-dock-item)) {
    display: none;
}

.portal-bar-dock-item {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    flex-shrink: 0;
    border-radius: var(--bs-border-radius);
    color: inherit;
    text-decoration: none;
    opacity: 0.82;
}

.portal-bar-dock-item:hover,
.portal-bar-dock-item:focus-visible {
    opacity: 1;
    background: var(--portal-c-row-hover);
    color: inherit;
}

/* ── Ghost recents ─────────────────────────────────────────────────────
   The Dock's recent-applications strip, and dimmer than the pins on purpose:
   a pin is a choice, a ghost is a guess, and the bar should read that way at
   a glance rather than offering two equal rows of icons.

   It gives ground FIRST. The dock and the event floor tray both ride this
   bar, and how much room the dock may take beside a live tray is a question
   only an event day answers — so the half that nobody chose is the half that
   goes when the bar gets tight. */
.portal-bar-recents {
    display: flex;
    align-items: center;
    gap: 0.1rem;
}

/* The SAME divider the dock draws, drawn the same way.
 *
 * This was a `border-left: 1px solid var(--portal-c-border-subtle)` with its
 * own padding and margin, which made two 1px lines in one 44px strip at two
 * different weights and two different offsets — one reading as the bar's
 * divider and the other as a stray line beside it. They are the same object
 * separating the same kind of thing, so they are now one rule: a ::before on
 * the same token, the same 1.1rem height and the same 0.45rem gutter. */
.portal-bar-recents::before {
    content: "";
    width: 1px;
    height: 1.1rem;
    margin: 0 0.45rem;
    background: var(--portal-chrome-divider);
    flex-shrink: 0;
}

/* Same guard as the dock's, for the same reason. bar-dock.js removes this nav
   when there is nothing to put in it, so today it cannot happen — but the two
   are one component wearing two class names, and a divider with nothing after
   it is the failure they share. */
.portal-bar-recents:not(:has(.portal-bar-dock-item)) {
    display: none;
}

.portal-bar-dock-item.is-ghost {
    opacity: 0.42;
}

.portal-bar-dock-item.is-ghost:hover,
.portal-bar-dock-item.is-ghost:focus-visible {
    opacity: 0.85;
}

@media (max-width: 1199.98px) {
    .portal-bar-recents {
        display: none;
    }
}

/* Hold ⌥ and each pin says which number it is — iPadOS revealing its
   shortcuts on hold-⌘. Invisible until asked for, so the bar is no busier
   than it was and nobody has to be told the keys exist.
   The numeral takes the icon's PLACE rather than sitting under it: the bar is
   44px around a 28px icon, so there is no room below for a caption, and a
   badge hung off the edge is clipped by the bar's own rounded shell. Swapping
   glyph for numeral also says the right thing — while ⌥ is down, that key IS
   that pin. */
.portal-bar-dock-item[data-portal-pin-key] {
    position: relative;
}

/* The Dock's oldest idea: an icon that says something needs you. The count is
   already on the page's tile in the launcher; the dock icon is the one you can
   see without opening anything, which is the whole reason the bar keeps it. It
   costs no width — it hangs off the icon's corner. */
.portal-bar-dock-item {
    position: relative;
}

.portal-bar-dock-count {
    position: absolute;
    top: -0.15rem;
    right: -0.2rem;
    min-width: 0.9rem;
    height: 0.9rem;
    padding: 0 0.18rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--bs-border-radius-pill);
    background-color: var(--portal-c-btn-danger-bg);
    color: #fff;
    font-family: var(--portal-font-mono);
    font-size: var(--portal-label-size-sm);
    font-weight: var(--portal-weight-strong, 700);
    line-height: 1;
    font-variant-numeric: tabular-nums;
    pointer-events: none;
}

/* While ⌥ is held the icon becomes its number, and a count sitting over that
   is two numbers on one 28px square meaning different things. */
body.portal-pin-keys-held .portal-bar-dock-count {
    opacity: 0;
}

.portal-bar-dock-item[data-portal-pin-key]::after {
    content: attr(data-portal-pin-key);
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: inherit;
    background-color: var(--portal-c-accent-muted);
    color: var(--portal-c-accent);
    font-family: var(--portal-font-mono);
    font-size: var(--portal-text-xs, 0.75rem);
    font-weight: var(--portal-weight-strong, 700);
    line-height: 1;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--portal-dur-1) var(--portal-ease-out);
}

body.portal-pin-keys-held .portal-bar-dock-item[data-portal-pin-key] > i {
    opacity: 0;
}

body.portal-pin-keys-held .portal-bar-dock-item[data-portal-pin-key]::after {
    opacity: 1;
}

.portal-bar-dock-item > i {
    transition: opacity var(--portal-dur-1) var(--portal-ease-out);
}

/* The page you are standing on, in the accent ring the Launchpad grid already
   uses for the current tile — one "you are here" idiom, two surfaces. An inset
   ring rather than a border so the 1.75rem box never changes size. */
.portal-bar-dock-item.is-current {
    opacity: 1;
    color: var(--portal-c-accent);
    box-shadow: inset 0 0 0 1px var(--portal-c-accent);
}

/* Phones already have the tab bar as their whole bottom system (five fixed
   slots); a second row of icons beside it is noise, and the bar has no room. */
@media (max-width: 767.98px) {
    .portal-bar-dock,
    .portal-bar-dock-sep {
        display: none;
    }
}

/* The launcher and the switcher: a glyph in a 1.75rem box, like every other
 * control in this row.
 *
 * The launcher used to be THE ONE EXCEPTION — it took the width of the words
 * "All pages", because after v3.1.1 retired the sidebar it was the only route
 * to every page and a bare ⊞ among glyphs did not advertise that. The label
 * came out on 2026-08-26 (Brian's call) and the exception went with it. What
 * makes that safe is not taste: the directory has other doors now — Home
 * renders the whole tool grid on arrival, ⌘K opens it, any printable key
 * opens it — so the bar is no longer the only thing standing between a new
 * person and the tools. The name lives on hover and in the accessible name.
 *
 * One rule for both buttons rather than two copies: they are the same object
 * with different glyphs, and hand-copied bar buttons always drift. No phone
 * rule is needed — #site-footer is display:none under 768px, where the tab
 * bar's Menu slot is the labelled door instead. */
.portal-launcher-btn,
.portal-switcher-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    border: 0;
    border-radius: var(--bs-border-radius, 0.375rem);
    background: transparent;
    color: inherit;
    flex-shrink: 0;
    opacity: 0.82;
    transition: opacity var(--portal-dur-1, 120ms) var(--portal-ease-out, ease);
}

.portal-launcher-btn:hover,
.portal-launcher-btn:focus-visible,
.portal-switcher-btn:hover,
.portal-switcher-btn:focus-visible {
    opacity: 1;
}

/* Hidden until the ledger has somewhere to send you — switcher.js unhides it
   at load. A control that opens an empty panel teaches people to stop
   pressing it. */
.portal-switcher-btn[hidden] { display: none; }


/* The user menu, up out of the bar — the sidebar's foot used to hold it. */
.portal-bar-user {
    display: inline-flex;
    align-items: center;
    flex-shrink: 0;
}

.portal-bar-user-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    padding: 0;
    border: 0;
    border-radius: var(--bs-border-radius, 0.375rem);
    background: transparent;
    color: inherit;
    flex-shrink: 0;
    opacity: 0.82;
    transition: opacity var(--portal-dur-1, 120ms) var(--portal-ease-out, ease);
    font-size: var(--portal-text-md, 0.9rem);
}

.portal-bar-user-btn:hover,
.portal-bar-user-btn:focus-visible,
.portal-bar-user-btn[aria-expanded="true"] {
    opacity: 1;
}

/* Logged out, the same slot is the door back in. */
.portal-bar-login {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    flex-shrink: 0;
    color: inherit;
    text-decoration: none;
    font-size: var(--portal-text-sm, 0.8125rem);
    font-weight: var(--portal-weight-label, 600);
    opacity: 0.85;
}

.portal-bar-login:hover,
.portal-bar-login:focus-visible {
    color: inherit;
    opacity: 1;
}

.portal-bar-login-label {
    white-space: nowrap;
}

/* ── Follow dropup ───────────────────────────────────────────────────── */
.footer-social {
    display: inline-flex;
    align-items: center;
    flex-shrink: 0;
}

.footer-social-toggle {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.2rem 0.5rem;
    background: none;
    border: 1px solid transparent;
    border-radius: var(--bs-border-radius);
    font-size: var(--portal-text-2xs);
    line-height: 1;
    cursor: pointer;
    /* Colour-only hover. The transparent border stays declared so the button
       holds its box now that nothing ever paints on it. */
    transition: color var(--portal-dur-1, 120ms) var(--portal-ease-out, ease);
}

/* The Follow label died in v3.1.1 (icon + title now). */
.footer-social-toggle:focus-visible {
    /* --portal-c-accent flips with the theme (teal on dark, deep teal on light);
       the raw teal was invisible-ish on the light chrome. */
    outline: 2px solid var(--portal-c-accent, var(--portal-accent-teal));
    outline-offset: 2px;
}

.footer-social-menu {
    min-width: 11rem;
    font-size: var(--portal-text-sm);
}

.footer-social-menu .dropdown-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

#site-footer[data-bs-theme="dark"] .footer-social-toggle,
#site-footer[data-bs-theme="dark"] .portal-floor-strip {
    color: rgba(244, 244, 244, 0.78);
}

#site-footer[data-bs-theme="dark"] .footer-social-toggle:hover,
#site-footer[data-bs-theme="dark"] .footer-social-toggle[aria-expanded="true"] {
    color: #fff;
}

#site-footer[data-bs-theme="light"] .footer-social-toggle,
#site-footer[data-bs-theme="light"] .portal-floor-strip {
    color: #495057;
}

#site-footer[data-bs-theme="light"] .footer-social-toggle:hover,
#site-footer[data-bs-theme="light"] .footer-social-toggle[aria-expanded="true"] {
    color: var(--portal-nhrl-purple);
}

.portal-footer-theme-btn {
    grid-column: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    padding: 0;
    border: 1px solid transparent;
    border-radius: var(--bs-border-radius);
    background: transparent;
    font-size: var(--portal-text-md);
    line-height: 1;
    cursor: pointer;
    /* Colour-only hover, same as its neighbours. The transparent border stays
       declared so the button holds its box. */
    transition: color var(--portal-dur-1, 120ms) var(--portal-ease-out, ease);
}

.portal-footer-theme-btn:focus-visible {
    outline: 2px solid var(--portal-c-accent, var(--portal-accent-teal));
    outline-offset: 2px;
}

#site-footer[data-bs-theme="dark"] .portal-footer-theme-btn {
    color: rgba(244, 244, 244, 0.85);
}

#site-footer[data-bs-theme="dark"] .portal-footer-theme-btn:hover {
    color: #fff;
}

#site-footer[data-bs-theme="light"] .portal-footer-theme-btn {
    color: #495057;
}

#site-footer[data-bs-theme="light"] .portal-footer-theme-btn:hover {
    color: var(--portal-nhrl-purple);
}

/* ── Appearance menu (site/_appearance.twig) ───────────────────────────
 * Lives here with the Follow dropup because it is chrome. No new z-index rung:
 * the menu rides #site-footer's stacking context exactly as the Follow and user
 * menus do. Nested-blur guard (portal-components.css) already makes a menu
 * inside the chrome opaque, so this costs nothing from the blur budget. */
.portal-appearance {
    grid-column: 1;
    display: inline-flex;
    align-items: center;
    flex-shrink: 0;
}

/* The same box and the same colour-only affordance as the cycle button it
   replaces — chrome-controls.spec.js measures this family at rest and hover,
   and the rule there is that chrome hover changes colour and nothing else. */
.portal-appearance-toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    width: 1.75rem;
    height: 1.75rem;
    padding: 0;
    border: 1px solid transparent;
    border-radius: var(--bs-border-radius);
    background: transparent;
    color: inherit;
    font-size: var(--portal-text-md);
    line-height: 1;
    cursor: pointer;
    transition: color var(--portal-dur-1, 120ms) var(--portal-ease-out, var(--portal-ease-out, ease));
}

/* The drawer mount carries a label, so it stops being a square. */
.portal-appearance[data-portal-appearance-mount="drawer"] .portal-appearance-toggle {
    width: auto;
    height: auto;
    padding: 0.375rem 0.5rem;
}

.portal-appearance-toggle:focus-visible {
    outline: 2px solid var(--portal-c-accent, var(--portal-accent-teal));
    outline-offset: 2px;
}

/* The bar's Appearance TOGGLE retired on 2026-08-26 — its four colour rules
   here went with it. The toggle still exists on the palette's footer mount
   (<768px, where there is no bar) and in the gallery; neither is inside
   #site-footer, so neither ever matched these. */

.portal-appearance-menu {
    min-width: 15rem;
    padding: 0.375rem;
}

/* Bare: the rows with no menu of their own, sitting inside a host panel that
   already has the material, the padding and the elevation. All this has to do
   is not re-declare them — the rows below are the same rows either way, which
   is the point of the mode. */
.portal-appearance--bare,
.portal-appearance-groups {
    display: block;
    width: 100%;
}

/* `width: 100%` here was correct for exactly one host and that host is gone.
   The 240px offcanvas drawer this mount is named after died with the sidebar
   (v3.1.1); the mount was relocated into the PALETTE'S FOOTER and the rule came
   with it. 100% of a ~113px footer span is a 113px menu, and `min-width: 0`
   cancelled the 15rem floor that would have caught it — so on a phone the theme
   rows rendered as "D■k", "L■ht", "M■rix" with each swatch sitting on top of
   its own label, the glass switch on top of the word "Glass", "effects" running
   off the panel, and the note wrapping to seven lines. Reported from a phone,
   2026-08-31.

   A menu needs a width of its own. The floor is the same 15rem every other
   Appearance menu gets; the ceiling keeps it inside a 375px viewport after
   Bootstrap shifts it in from the right edge. Still no `width: 100%`: the
   clipping ancestor that rule was defending against does not exist here — the
   palette footer clips nothing, and Popper does the rest. */
.portal-appearance[data-portal-appearance-mount="drawer"] .portal-appearance-menu {
    min-width: 15rem;
    max-width: calc(100vw - 1.5rem);
}

.portal-appearance-header {
    padding: 0.375rem 0.5rem 0.25rem;
    font-family: var(--portal-font-mono, ui-monospace, monospace);
    font-size: var(--portal-label-size, 0.6875rem);
    font-weight: var(--portal-weight-label);
    letter-spacing: var(--portal-label-tracking);
    text-transform: uppercase;
    color: var(--portal-c-text-muted);
}

.portal-appearance-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    border-radius: var(--bs-border-radius);
}

.portal-appearance-text { flex: 1 1 auto; min-width: 0; }

/* The row's icon is a fixed-width secondary mark: it names the row, the check
   and the switch carry the state. */
.portal-appearance-glyph {
    flex: 0 0 auto;
    font-size: var(--portal-text-sm);
    color: var(--portal-c-text-muted);
}

/* Only the drawer mount has a label — the footer's trigger is a glyph in a
   1.75rem box, like every control beside it. */
.portal-appearance-toggle-label {
    font-size: var(--portal-text-sm, 0.8125rem);
    font-weight: var(--portal-weight-medium);
    line-height: 1;
}

/* A swatch of the theme it selects, painted by that theme's own tokens through
   .portal-theme-scope — so it is the real surface and keyline, not an
   approximation somebody has to keep in step by hand. */
.portal-appearance-swatch {
    flex: 0 0 auto;
    position: relative;
    width: 1.25rem;
    height: 1.25rem;
    border-radius: var(--bs-border-radius-sm, 4px);
    background-color: var(--portal-c-surface-opaque);
    border: 1px solid var(--portal-c-border);
    box-shadow: var(--portal-c-edge-card);
    overflow: hidden;
}

/* THE ACCENT, as a corner wedge. The surface alone is not enough to tell two
   themes apart and never was: dark and matrix are both near-black grounds
   (#1f1d2a against #07150a), which at 20px is a difference you have to hunt
   for even once each swatch is finally painting its own palette. The thing
   that actually separates them at a glance is the colour each theme WORKS in
   — purple-teal against that green — so the swatch shows both: the ground it
   puts behind your content, and the accent it draws on top of it.
   Generalised rather than a matrix special-case, because the next theme
   registered in PortalThemeService::THEMES will have the same problem the
   moment its ground lands near one already in the list, and a rule that reads
   the accent token needs nothing added for it. */
.portal-appearance-swatch::after {
    content: "";
    position: absolute;
    right: -35%;
    bottom: -35%;
    width: 100%;
    height: 100%;
    background: var(--portal-c-accent);
    transform: rotate(45deg);
}

/* The check marks the CURRENT theme. Hidden rather than absent so the rows do
   not reflow by a glyph width when the choice moves. */
.portal-appearance-check {
    flex: 0 0 auto;
    font-size: var(--portal-text-xs);
    color: var(--portal-c-accent);
    opacity: 0;
}

.portal-appearance-item[aria-checked="true"] .portal-appearance-check { opacity: 1; }

/* Selected is a RECESSED tint, not the raised pill an exclusive choice gets:
   the row is already marked by the check, and two affordances for one state is
   how "selected" and "hovered" stop being distinguishable.
   
   THEME rows only. The glass row is a checkbox whose state is carried by the
   switch beside it, and tinting it too made "glass is on" look like "glass is
   the selected theme" — the same mistake one row up. */
.portal-appearance-item[data-portal-theme-choice].active,
.portal-appearance-item[data-portal-theme-choice][aria-checked="true"] {
    background-color: var(--portal-c-accent-muted);
    color: inherit;
}

.portal-appearance-switch {
    flex: 0 0 auto;
    pointer-events: none;
}

/* The knob follows the ROW's aria-checked — the span is decorative, because a
   `switch` role is not a valid child of `menu`. */
.portal-appearance-item[aria-checked="false"] .portal-appearance-switch { opacity: 0.45; }

.portal-appearance-note {
    padding: 0.25rem 0.5rem 0.375rem;
    font-size: var(--portal-text-xs, 0.75rem);
    line-height: 1.35;
    color: var(--portal-c-text-muted);
}

/* Reduce Transparency wins over an explicit "on", and the row says so instead
   of silently doing nothing — an "on" switch above a surface that is still
   flat is worse than no switch at all. */
.portal-appearance-item.disabled,
.portal-appearance-item[aria-disabled="true"] {
    opacity: 0.55;
    pointer-events: none;
}

/* Exactly one trigger is ever reachable: the system bar's above 768px, the
   palette-footer's below it (that one is d-md-none in the template — the
   utility class is its half of this pair). kit-css.spec.js pins the
   invariant. */
@media (max-width: 767.98px) {
    #site-footer .portal-appearance { display: none; }
}

#site-footer[data-bs-theme="dark"],
.portal-page-themed[data-bs-theme="dark"] {
    color-scheme: dark;
}

#site-footer[data-bs-theme="light"],
.portal-page-themed[data-bs-theme="light"] {
    color-scheme: light;
}

/* The gallery mount is neither — it is an exhibit sitting in page content, so
   it is always visible and opens DOWN like any other menu on the page. */
.portal-appearance[data-portal-appearance-mount="gallery"] { display: inline-flex; }

.portal-theme-tooltip .tooltip-inner {
    font-size: var(--portal-text-sm);
    font-weight: var(--portal-weight-medium);
    padding: 0.4rem 0.65rem;
}


@media (max-width: 767.98px) {
    :root {
        /* Width changes legibility, not input modality. Coarse-pointer target
           sizing stays in the component layer below this token adjustment. */
        --portal-label-size-sm: var(--portal-text-xs);
        --portal-label-size: var(--portal-text-xs);
    }

    .footer-inner {
        column-gap: 0.5rem;
    }

}
