/* ═══════════════════════════════════════════════════════════════════════════
   Hoppe.home Portal — Shared UI Layer  ("3D Pixel Glass")
   ═══════════════════════════════════════════════════════════════════════════

   WHAT THIS FILE IS
   -----------------
   `portal-frame.css` is the FRAME: sidebar, main column, page header, topbar.
   This file is the CONTENT VOCABULARY that goes inside it — cards, KPI tiles,
   tables, pills, chips, banners, buttons, toolbars, empty states.

   It exists because that vocabulary had been hand-copied into every page.
   Measured 2026-09-05 across the 52 pages that load the frame:

       .btn defined 126 times   .kpi 92 times   .table 54 times
       .card 53 times           .status-pill 58 times   .chip 41 times

   That is `feedback_copied_ui_fragments_drift` at Portal scale — the same
   failure that gave the CFO Tools eight different sidebars. A component
   defined 53 times is 53 components, and they drift the moment one is
   touched. Define it once, here; pages describe WHAT they show, not how a
   card looks.

   LOAD ORDER (matters)
   --------------------
       portal-frame.css   → the frame
       portal-ui.css      → this file, the components      ← always second
       page <style>       → genuinely page-specific rules only

   A page's own <style> still wins, so adding this file cannot break a page.
   That is deliberate: it lets the layer land safely first and the local
   copies be removed page by page afterwards.

   THE LOOK, AND WHY
   -----------------
   Two things Jörn asked to converge (2026-09-05), taken from the PM Tool's
   surface and from our own brand:

   1. **Apple glass.** Glass is not transparency. It is three things at once —
      a BLUR of what lies behind, a LIGHT EDGE along the top where the light
      breaks, and a SOFT SHADOW beneath so the pane reads as floating. Drop
      any one and you get a semi-transparent box, not glass. The blur also
      needs something behind it to show: hence `body::before/::after`, the
      ambient wash. On a flat colour every blur is wasted work.

   2. **3D Pixel.** The house's own visual language (branding-hub, LEGO/voxel
      isometric). Here it is a restrained accent, not decoration: the stud row
      on a section rule, the brick edge on a KPI tile, the isometric corner on
      a hero. Enough that the Portal reads as the same house as the wallpapers
      and the HoppeOS login — not so much that a dashboard becomes a toy.

   READABILITY BEATS EFFECT. Surfaces carrying numbers are markedly more
   opaque than decorative ones, and table bodies are opaque outright. A
   dashboard you have to squint at is a worse dashboard, however pretty.

   FIVE THEMES, NOT TWO. Everything below derives from the frame's existing
   tokens (--card/--surface/--border/--accent) with color-mix(), so dark,
   light, horde, horde-light and fusion all work without a per-theme block.
   Only the ambient wash and a few edges are tuned per theme.

   ESCAPE HATCHES
       <html data-glass="off">              opaque surfaces, everything else kept
       @supports not (backdrop-filter)      same, automatically
       prefers-reduced-transparency         same, automatically
       prefers-reduced-motion               no lift/slide transitions
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Cascade layer ─────────────────────────────────────────────────────────
   Everything below is in `hh.components`, declared after `hh.frame`. Page
   styles stay UNLAYERED, and unlayered normal declarations beat every layer at
   ANY specificity, so the contract this file has claimed in prose since it was
   written is now enforced by the cascade:

       hh.frame        the shell
       hh.components   this file          — always beats the frame
       (unlayered)     a page's <style>   — always beats both

   No page needed editing to get that: leaving them unlayered IS the top layer.

   What had to happen first, on 2026-09-05, in this order:
     - 565 duplicated component rules removed from 52 pages
     - 184 + 25 + 9 duplicated SHELL rules removed, until zero page that loads
       the sidebar carries any of its CSS
     - the frame's thirteen !important declarations deleted, then three more
       here that were propping the sidebar up against those page copies

   The last step matters: with layers, an !important in an EARLIER layer beats
   one in a later layer. Had the frame kept its lock, introducing this would
   have silently inverted the whole design system in the frame's favour.

   MEASURED BEFORE DOING IT, not assumed. Page rules that previously lost to a
   more specific rule here now win. A first, blunt check said 149 such rules; it
   was matching `.muted` against `.status-pill.muted` and `.card` against
   `[data-glass="off"] .card`, which never style the same element. Requiring
   that this file's selector be the page's selector plus left-hand context gives
   the real number: 17, across 10 pages, and every one of them is a page
   deliberately styling its own `a`, `h2` or `.tab.active`. That is the contract
   working, not a regression. */
@layer hh.components {


/* ── Glass tokens ──────────────────────────────────────────────────────────
   Derived from the theme, not hard-coded, so a new theme inherits the glass.
   Opacity is a readability dial: `pane` for decoration, `solid` for content
   with numbers in it. */
:root {
    --glass-blur: 18px;
    --glass-blur-soft: 10px;
    --glass-blur-chrome: 24px;
    --glass-sat: 155%;

    /* Translucency, mixed from the theme's own card colour. */
    --glass-pane:  color-mix(in srgb, var(--card) 72%, transparent);
    --glass-solid: color-mix(in srgb, var(--card) 90%, transparent);
    --glass-sunk:  color-mix(in srgb, var(--surface) 66%, transparent);

    /* The light edge. A single bright hairline along the top — the actual
       "light on the pane". Without it a blurred box still looks like a box. */
    --glass-edge: rgba(255,255,255,.16);
    --glass-edge-strong: rgba(255,255,255,.24);
    --glass-hair: color-mix(in srgb, var(--border) 82%, transparent);

    --glass-lift: 0 12px 38px rgba(0,0,0,.38), 0 2px 6px rgba(0,0,0,.24);
    --glass-lift-hi: 0 22px 60px rgba(0,0,0,.48);

    /* The ambient wash behind the glass. */
    --amb-1: var(--accent);
    --amb-2: var(--accent);
    --amb-opacity: .28;

    /* 3D Pixel: one brick unit, and the stud colour. */
    --brick: 6px;
    --brick-face: color-mix(in srgb, var(--accent) 78%, var(--strong));
    --brick-side: color-mix(in srgb, var(--accent) 55%, #000);
    --brick-top:  color-mix(in srgb, var(--accent) 40%, var(--strong));
}

/* Light themes: the edge must be a dark hairline, not a white one — a white
   highlight on a white pane is invisible, and the pane loses its shape. */
[data-theme="light"], [data-theme="horde-light"] {
    --glass-pane:  color-mix(in srgb, var(--card) 62%, transparent);
    --glass-solid: color-mix(in srgb, var(--card) 88%, transparent);
    --glass-sunk:  color-mix(in srgb, var(--surface) 60%, transparent);
    --glass-edge: rgba(255,255,255,.72);
    --glass-edge-strong: rgba(255,255,255,.92);
    --glass-lift: 0 8px 28px rgba(18,44,80,.14), 0 1px 2px rgba(18,44,80,.08);
    --glass-lift-hi: 0 16px 44px rgba(18,44,80,.20);
    --amb-opacity: .26;
}
[data-theme="horde"], [data-theme="horde-light"] { --amb-2: var(--warn); }
[data-theme="fusion"] { --amb-2: #ff6a8d; --amb-opacity: .24; }
[data-theme="light"]  { --amb-2: #6fbeff; }


/* ── A note for standalone pages ───────────────────────────────────────────
   portal-frame.css sets `body { display: flex }`, because the Portal proper is
   a fixed sidebar beside a main column. A page WITHOUT a sidebar — the front
   door, the public pages, a download page — must set `body { display: block }`
   (or grid, as the error pages do for centring) in its own <style>, or every
   top-level block becomes a flex item in a row.

   This is not fixed here on purpose. The obvious shared fix,
   `body:not(:has(#sidebar-fragment)) { display: block }`, depends on `:has()`,
   and a browser without it would silently get the broken layout instead of a
   missing nicety. An explicit line per standalone page is deterministic.
   It is step 1 of the Wiki's "Add a Portal page" SOP. */

/* ── If color-mix() is missing ─────────────────────────────────────────────
   Nearly every surface in this layer is derived with color-mix(), which is how
   five themes are supported without five blocks. If a browser does not know
   it, EVERY one of those declarations is invalid at computed-value time and
   the panes fall back to transparent — text on the wallpaper, unreadable.

   That is the one failure worth insuring against, so it is insured against
   once, at the tokens, rather than by duplicating several hundred rules. The
   result is the Portal without glass, which is exactly what `data-glass="off"`
   already produces and is perfectly usable.

   Support: Chrome 111+, Firefox 113+, Safari 16.2+ (all 2023). No browser in
   this house is older, so this block should never run — which is precisely why
   it is four lines and not a parallel stylesheet. */
@supports not (color: color-mix(in srgb, red 50%, blue)) {
    :root {
        --glass-pane: var(--card); --glass-solid: var(--card); --glass-sunk: var(--surface);
        --glass-hair: var(--border);
        --brick-face: var(--accent); --brick-side: var(--accent); --brick-top: var(--strong);
    }
    .card, .kpi, .hero, .panel, .glass, .menu-panel {
        -webkit-backdrop-filter: none; backdrop-filter: none;
    }
    .sidebar { background: var(--surface); }
    .btn { background: var(--accent); }
    body::before, body::after { display: none; }
}

/* ── The ground behind the glass ───────────────────────────────────────────
   Pseudo-elements on <body>, so all 52 framed pages get this with zero markup
   change. Fixed and pointer-transparent; it must never intercept a click. */
body::before,
body::after {
    content: "";
    position: fixed;
    z-index: -2;
    pointer-events: none;
    border-radius: 50%;
    filter: blur(80px);
    opacity: var(--amb-opacity);
}
body::before {
    width: 62vw; height: 62vw; max-width: 1100px; max-height: 1100px;
    top: -22vw; left: -14vw;
    background: radial-gradient(circle at 42% 42%, var(--amb-1), transparent 66%);
}
body::after {
    width: 66vw; height: 66vw; max-width: 1200px; max-height: 1200px;
    right: -20vw; bottom: -26vw;
    background: radial-gradient(circle at 55% 45%, var(--amb-2), transparent 64%);
}

/* The glass surface itself. One rule, one place — every pane below points here
   rather than repeating the blur/edge/shadow triplet. */
.card, .kpi, .hero, .panel, .glass,
.menu-panel, .table-wrap.is-pane {
    background-color: var(--glass-pane);
    -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
    backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
    border: 1px solid var(--glass-hair);
    /* The light edge is an INSET hairline, not a background gradient: it
       survives a page overriding `background`, follows the border radius, and
       reads crisper. That single line is what separates glass from a
       semi-transparent box. */
    box-shadow: inset 0 1px 0 var(--glass-edge), var(--glass-lift);
}

/* Fallbacks. No effect beats grey mush. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .card, .kpi, .hero, .panel, .glass, .menu-panel, .table-wrap.is-pane { background-color: var(--card); }
}
@media (prefers-reduced-transparency: reduce) {
    .card, .kpi, .hero, .panel, .glass, .menu-panel, .table-wrap.is-pane {
        background-color: var(--card);
        -webkit-backdrop-filter: none; backdrop-filter: none;
    }
    body::before, body::after { display: none; }
}
[data-glass="off"] .card, [data-glass="off"] .kpi, [data-glass="off"] .hero,
[data-glass="off"] .panel, [data-glass="off"] .glass, [data-glass="off"] .menu-panel {
    background-color: var(--card);
    -webkit-backdrop-filter: none; backdrop-filter: none;
}
[data-glass="off"] body::before, [data-glass="off"] body::after { display: none; }


/* ── Accessibility corrections ─────────────────────────────────────────────
   Found 2026-09-05 by portal/tests/contrast-audit.html, which measures the
   REAL rendered colours — compositing every translucent glass layer over what
   is behind it — against WCAG 2.1 AA, in all five themes. Twenty-one failures,
   all of them things a person actually reads.

   1. LINKS IN CONTENT WERE THE BROWSER DEFAULT. Nothing in the Portal styled
      an <a> inside a card, so every link rendered #0000EE — 1.54:1 on a dark
      card. Unreadable, on every dark theme, everywhere.
   2. PRIMARY BUTTONS FAILED IN EVERY THEME. White on the raw accent is 2.75:1
      on blue, 4.08 on horde red, 4.44 on fusion. The button background is now
      the accent deepened toward black, which passes everywhere and reads
      better besides.
   3. STATUS TEXT ON ITS OWN TINT sat between 3.3 and 4.4. Pushing the status
      colour toward the theme's own extreme (--strong is white on dark themes
      and black on light ones) fixes both directions with one expression.
   4. TWO FRAME TOKENS ARE WRONG for horde-light, corrected below. */

/* A link in content. Derived from the accent so it stays on-brand, pushed
   toward the theme's extreme so it clears 4.5:1 in both directions. */
.content a:not(.btn):not(.chip):not(.nav-item):not(.tab),
.card a:not(.btn):not(.chip),
.banner a, .hero a {
    color: color-mix(in srgb, var(--accent) 62%, var(--strong));
    text-underline-offset: 2px;
    text-decoration-thickness: 1px;
}
.content a:not(.btn):not(.chip):not(.nav-item):not(.tab):hover,
.card a:not(.btn):not(.chip):hover { color: var(--strong); }

/* horde-light declared --strong:#ffffff — white "strong" text on a near-white
   page. Every card heading, KPI value and hero title on that theme measured
   1.03:1: invisible. --muted:#c24a4a was also just under AA on the same
   ground. Corrected here rather than in portal-frame.css so the frame keeps
   its cache-buster and 70 pages do not re-fetch it; the frame should be fixed
   at source next time it is versioned. */
[data-theme="horde-light"] {
    --strong: #1c0606;
    --muted: #8f2f2f;
}

/* ── Document structure ────────────────────────────────────────────────────
   Added 2026-09-05. The Portal had NO landmarks: <main> 0, <header> 0, <h1> 0
   across every page measured. A screen-reader user could not skip to the
   content and there was no heading to navigate by — a hole the contrast audit
   could never have found, because it measures colour and says nothing about
   structure. The page shell is now
   <header class="page-header"> · <h1 class="page-title"> · <main class="content">
   and stale-code-monitor.sh fails the deploy if a page loses them. */

/* An <h1> carries a browser margin that a <div> did not. */
h1.page-title { margin: 0; }

/* Skip link: invisible until focused, then a real control. It is the first
   thing in the tab order on every framed page. */
.skip-to-main {
    position: absolute; left: 12px; top: -60px; z-index: 4000;
    padding: 10px 16px; border-radius: 10px;
    background: var(--card); color: var(--strong);
    border: 1px solid var(--accent);
    font-size: 13px; font-weight: 700; text-decoration: none;
    box-shadow: var(--glass-lift);
    transition: top .16s ease;
}
.skip-to-main:focus { top: 12px; }

/* For a heading a page needs semantically but not visually — the login screen
   has its brand mark, not a heading, and that is the right design; it still
   owes assistive technology an <h1>. Clip rather than display:none, which
   would remove it from the accessibility tree as well. */
.sr-only {
    position: absolute !important; width: 1px; height: 1px;
    padding: 0; margin: -1px; overflow: hidden;
    clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* ── Card ──────────────────────────────────────────────────────────────────
   The workhorse. Everything that is "a thing on a dashboard" is a .card. */
.card {
    border-radius: 14px;
    padding: 15px 16px;
    /* A grid, not a block. Portal cards are stacks of blocks -- a title, a
       table, a note -- and `gap` spaces them without every child needing its
       own margin. Several pages had already discovered this and written
       `display:grid;gap:10px` into their own copy; the layer adopts it rather
       than flattening those pages when their copy was removed. */
    display: grid;
    gap: 11px;
    align-content: start;
    transition: box-shadow .16s ease, transform .16s ease, border-color .16s ease;
}
.card h2, .card h3, .card .card-title, .card-head h3, .card-title {
    margin: 0;
    font-size: 15px; font-weight: 700; color: var(--strong);
    letter-spacing: .01em;
    /* A card title very often carries something on its right -- a count, a
       timestamp, a filter. Flex by default so it does, and identical to a
       plain heading when it does not. */
    display: flex; align-items: center; justify-content: space-between;
    gap: 10px; flex-wrap: wrap;
}
.card .card-note, .card .subtitle, .card-note, .card-sub {
    margin: -4px 0 0;
    font-size: 12.5px; line-height: 1.55; color: var(--muted);
}
/* Opt back out, for a card that really is one flowing block of prose. */
.card.is-prose { display: flow-root; }
.card.is-prose h2, .card.is-prose h3 { margin: 0 0 10px; }
.card.is-interactive:hover { box-shadow: var(--glass-lift-hi); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
.card.is-flush { padding: 0; gap: 0; overflow: hidden; }
/* A row of studs along the top edge — the 3D Pixel signature at card scale.
   Used on the front door (login, signed-out, the error pages), where there is
   no page title to carry the brick and the page has to say "Hoppe.home" on its
   own. */
.card.is-pixel-top { position: relative; }
.card.is-pixel-top::before {
    content: ""; position: absolute; left: 0; right: 0; top: 0; height: 5px;
    border-radius: 14px 14px 0 0;
    background-image: radial-gradient(circle at 3px 3px,
        color-mix(in srgb, var(--accent) 85%, transparent) 1.8px, transparent 2px);
    background-size: var(--brick) 5px;
    opacity: .85;
}   /* head and body meet; the gap would be a seam */
.card.is-accent { border-left: 3px solid var(--accent); }
.card.is-ok   { border-left: 3px solid var(--ok); }
.card.is-warn { border-left: 3px solid var(--warn); }
.card.is-err  { border-left: 3px solid var(--err); }

/* A card head, for cards whose body scrolls or is flush. */
.card-head {
    display: flex; align-items: center; gap: 9px;
    padding: 11px 14px;
    background: linear-gradient(to bottom, var(--glass-edge-strong), transparent);
    border-bottom: 1px solid var(--glass-hair);
    border-radius: 14px 14px 0 0;
}
.card-head .card-title { flex: 1; min-width: 0; margin: 0; font-size: 14px; font-weight: 700; color: var(--strong);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-head .card-icon { font-size: 15px; flex-shrink: 0; }
.card-body { padding: 14px; }

/* Card grids — the layout half of "nice overviews". */
.card-grid  { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
.card-grid.is-wide  { grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }
.card-grid.is-tight { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; }


/* ── Container queries ─────────────────────────────────────────────────────
   A component library's layout should answer to the space it is IN, not to
   the size of the window. A card in a 320px sidebar column and the same card
   spanning a 1400px dashboard are the same width to a media query and
   obviously different to a reader.

   So `.card` is a size container, and the things inside it respond to IT.
   Everything below is additive: a browser without @container simply keeps the
   wide layout, which is what every page had yesterday. */
/* SCOPED TO THE FRAMED SHELL ON PURPOSE -- 2026-09-05.
   `container-type: inline-size` also applies `contain: inline-size`, which
   means the element's own contents STOP contributing to its intrinsic width.
   Inside `.content` that is harmless: a card there is a block in a column and
   stretches to the width it is given. In a shrink-to-fit context -- a flex
   item, a float, `position:absolute`, an inline-block -- the same rule
   collapses the card to nothing, with the content hanging out beside it.

   That is not hypothetical. `signed-out.html` centres its card with
   `body{display:flex}` and set no width; on the live Portal it rendered as a
   35px strip with the whole page to the right of it. The four `errors/*.html`
   pages use the identical layout and escaped only because each of them
   happens to set `width:min(760px,100%)`.

   The container queries below only serve dashboard furniture -- `.facts`,
   `.card-head`, `.kpi-grid`, `.toolbar`, `.filters`, `.table.is-compact` --
   and every card containing any of it lives inside `.content` (measured: zero
   framed pages put a `.card` before their `.content` element). So scoping
   costs nothing and takes the landmine out of every standalone page. */
.content .card { container-type: inline-size; container-name: hh-card; }

@container hh-card (max-width: 400px) {
    /* A two-column fact list at 380px is two cramped columns. */
    .facts { grid-template-columns: 1fr; gap: 2px 0; }
    .facts dt, .facts .k { margin-top: 6px; }
    /* A card head with a title and a right-hand action stacks instead of
       squeezing the title to three characters and an ellipsis. */
    .card-head { flex-wrap: wrap; row-gap: 4px; }
    .kpi-grid { grid-template-columns: 1fr; }
    .toolbar, .filters { row-gap: 6px; }
    .table.is-compact th, .table.is-compact td { padding: 5px 7px; }
}
@container hh-card (max-width: 260px) {
    .kpi .value, .kpi .kpi-value { font-size: 20px; }
    .card-head .card-title { white-space: normal; }
}

/* ── KPI tile ──────────────────────────────────────────────────────────────
   Numbers first. More opaque than a card and less blurred, because a figure
   read at a glance must not fight the wallpaper behind it. The left edge is
   the 3D Pixel brick, stacked in the accent. */
.kpi-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
.kpi-grid.is-tight { grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 10px; }
.kpi-grid.is-wide  { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }

.kpi {
    position: relative;
    border-radius: 12px;
    padding: 13px 14px 13px 18px;
    background-color: var(--glass-solid);
    -webkit-backdrop-filter: blur(var(--glass-blur-soft)) saturate(135%);
    backdrop-filter: blur(var(--glass-blur-soft)) saturate(135%);
    overflow: hidden;
}
/* The brick edge: three stacked studs in the accent, isometrically lit. */
.kpi::before {
    content: "";
    position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
    background:
        linear-gradient(180deg,
            var(--brick-face) 0 33.33%,
            var(--brick-top)  33.33% 34.5%,
            var(--brick-face) 34.5% 66.66%,
            var(--brick-top)  66.66% 67.8%,
            var(--brick-face) 67.8% 100%);
    box-shadow: 1px 0 0 var(--brick-side);
}
.kpi.is-ok::before   { --brick-face: var(--ok);   --brick-side: color-mix(in srgb, var(--ok) 55%, #000); }
.kpi.is-warn::before { --brick-face: var(--warn); --brick-side: color-mix(in srgb, var(--warn) 55%, #000); }
.kpi.is-err::before  { --brick-face: var(--err);  --brick-side: color-mix(in srgb, var(--err) 55%, #000); }

.kpi .label, .kpi .kpi-label {
    font-size: 10.5px; font-weight: 700; letter-spacing: .09em;
    text-transform: uppercase; color: var(--muted);
}
.kpi .value, .kpi .kpi-value {
    margin-top: 3px;
    font-size: 25px; font-weight: 800; line-height: 1.15;
    color: var(--strong);
    font-variant-numeric: tabular-nums;
    letter-spacing: -.01em;
}
.kpi .value.is-sm { font-size: 20px; }
.kpi .meta, .kpi .kpi-meta { margin-top: 4px; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
.kpi .meta.is-ok { color: var(--ok); } .kpi .meta.is-warn { color: var(--warn); } .kpi .meta.is-err { color: var(--err); }


/* ── Tables ────────────────────────────────────────────────────────────────
   The one place transparency is refused outright. A table body is where
   people read model numbers, IPs and amounts; the header can float, the rows
   cannot. Sticky header, tabular figures, and a horizontal scroll container
   so a wide table scrolls inside itself and never widens the page. */
.table-wrap {
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    border-radius: 12px;
    scrollbar-width: thin;
    scrollbar-color: var(--border) transparent;
}
.table-wrap.is-pane { border-radius: 12px; }
.table-wrap::-webkit-scrollbar { height: 10px; }
.table-wrap::-webkit-scrollbar-track { background: transparent; }
.table-wrap::-webkit-scrollbar-thumb {
    background: color-mix(in srgb, var(--border) 80%, transparent);
    border: 2px solid transparent; background-clip: padding-box; border-radius: 999px;
}

.table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.table th {
    position: sticky; top: 0; z-index: 2;
    text-align: left; padding: 9px 11px;
    font-size: 10.5px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase;
    color: var(--muted); white-space: nowrap;
    background: var(--surface);
    border-bottom: 1px solid var(--border);
    box-shadow: inset 0 -1px 0 var(--border);
}
.table td {
    padding: 9px 11px;
    border-bottom: 1px solid color-mix(in srgb, var(--border) 62%, transparent);
    color: var(--text); vertical-align: top;
}
.table tbody tr { transition: background .12s ease; }
.table tbody tr:hover { background: var(--accent-lt); }
.table tbody tr:last-child td { border-bottom: 0; }
.table .num, .table td.num, .table th.num { text-align: right; font-variant-numeric: tabular-nums; }
.table .nowrap { white-space: nowrap; }
.table caption { caption-side: bottom; padding: 8px 2px 0; font-size: 11.5px; color: var(--muted); text-align: left; }
/* Zebra is opt-in — on a glass ground it is usually noise, but long log-style
   tables genuinely need it. */
.table.is-zebra tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surface) 42%, transparent); }
.table.is-zebra tbody tr:nth-child(even):hover { background: var(--accent-lt); }
.table.is-compact th, .table.is-compact td { padding: 6px 9px; font-size: 12px; }


/* ── Status pills, chips, badges ───────────────────────────────────────────
   .status-pill = a STATE (ok/warn/bad/muted).  .chip = a LABEL or filter.
   Kept distinct on purpose: pages had been using one for the other, which is
   how a neutral tag ends up looking like an alert. */
.status-pill {
    display: inline-flex; align-items: center; gap: 6px;
    padding: 3px 10px; border-radius: 999px;
    font-size: 11px; font-weight: 700; white-space: nowrap;
    border: 1px solid var(--ok); background: var(--ok-bg);
    color: color-mix(in srgb, var(--ok) 76%, var(--strong));
    -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
}
.status-pill.warn  { border-color: var(--warn); background: var(--warn-bg); color: color-mix(in srgb, var(--warn) 76%, var(--strong)); }
.status-pill.bad,
.status-pill.err   { border-color: var(--err);  background: var(--err-bg);  color: color-mix(in srgb, var(--err) 74%, var(--strong)); }
.status-pill.info  { border-color: var(--accent); background: var(--accent-lt); color: var(--accent); }
.status-pill.muted { border-color: var(--border); background: color-mix(in srgb, var(--surface) 70%, transparent); color: color-mix(in srgb, var(--muted) 80%, var(--strong)); }

.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0;
    background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 22%, transparent); }
.status-dot.warn { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 22%, transparent); }
.status-dot.bad, .status-dot.err { background: var(--err); box-shadow: 0 0 0 3px color-mix(in srgb, var(--err) 22%, transparent); }
.status-dot.muted { background: var(--muted); box-shadow: none; }

.chip {
    display: inline-flex; align-items: center; gap: 5px;
    padding: 3px 9px; border-radius: 8px;
    font-size: 11.5px; font-weight: 600; white-space: nowrap;
    color: var(--text);
    background: color-mix(in srgb, var(--surface) 68%, transparent);
    border: 1px solid var(--glass-hair);
}
.chip.is-accent { background: var(--accent-lt); border-color: color-mix(in srgb, var(--accent) 40%, transparent); color: var(--accent); }
button.chip, a.chip { cursor: pointer; transition: background .12s ease, border-color .12s ease; }
button.chip:hover, a.chip:hover, .chip:hover { border-color: var(--accent); color: var(--strong); }
.chip[aria-pressed="true"], .chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }

/* .pill and .badge: two more names the Portal already speaks for the same
   idea. Kept as real definitions rather than aliases because they differ in
   weight -- .pill is a small standalone label, .badge is smaller still and
   usually sits inside another control. Found by diffing what the sweep removed
   against what this file defines; without them, sixteen `.pill` and fourteen
   `.badge` elements would have rendered as bare text. */
.pill {
    display: inline-flex; align-items: center; gap: 6px;
    padding: 3px 10px; border-radius: 999px;
    font-size: 11px; font-weight: 700; letter-spacing: .03em; white-space: nowrap;
    border: 1px solid var(--glass-hair);
    background: color-mix(in srgb, var(--surface) 70%, transparent);
    color: color-mix(in srgb, var(--muted) 80%, var(--strong));
}
.pill.ok   { border-color: color-mix(in srgb,var(--ok) 50%,transparent);   background: var(--ok-bg);   color: var(--ok); }
.pill.warn { border-color: color-mix(in srgb,var(--warn) 50%,transparent); background: var(--warn-bg); color: var(--warn); }
.pill.bad, .pill.err { border-color: color-mix(in srgb,var(--err) 50%,transparent); background: var(--err-bg); color: var(--err); }
.pill.accent, .pill.info { border-color: color-mix(in srgb,var(--accent) 45%,transparent); background: var(--accent-lt); color: var(--accent); }
.pill::before {
    content: ""; width: 6px; height: 6px; border-radius: 50%;
    background: currentColor; opacity: .85; flex-shrink: 0;
}
.pill.no-dot::before { display: none; }

.badge {
    display: inline-block;
    padding: 2px 8px; border-radius: 999px;
    font-size: 10px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase;
    border: 1px solid var(--glass-hair);
    background: color-mix(in srgb, var(--surface) 74%, transparent);
    color: color-mix(in srgb, var(--muted) 80%, var(--strong));
}
.badge.ok   { border-color: color-mix(in srgb,var(--ok) 50%,transparent);   background: var(--ok-bg);   color: var(--ok); }
.badge.warn { border-color: color-mix(in srgb,var(--warn) 50%,transparent); background: var(--warn-bg); color: var(--warn); }
.badge.bad, .badge.err { border-color: color-mix(in srgb,var(--err) 50%,transparent); background: var(--err-bg); color: var(--err); }
.badge.accent, .badge.info { border-color: color-mix(in srgb,var(--accent) 45%,transparent); background: var(--accent-lt); color: var(--accent); }

.status-dot.ok { background: var(--ok); }

.hero-copy { display: grid; gap: 6px; position: relative; }

.kpi:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }

/* Stacked banners need air between them; pages had been adding it themselves. */
.banner + .banner { margin-top: 10px; }

/* `.tab` outside a `.tabs` wrapper -- several pages write it that way. */
.tab {
    padding: 8px 13px; background: none; border: 0; border-bottom: 2px solid transparent;
    color: var(--muted); font-weight: 600; font-size: 13px; cursor: pointer;
}
.tab:hover { color: var(--text); }
.tab.active, .tab[aria-selected="true"] { color: var(--strong); border-bottom-color: var(--accent); font-weight: 750; }

/* Bare form controls inside a toolbar get the .input treatment without needing
   the class -- filter bars are written as plain <select>/<input> everywhere. */
.toolbar input, .toolbar select, .filters input, .filters select {
    padding: 7px 10px; border-radius: 8px; font: inherit; font-size: 13px;
    color: var(--text);
    background: color-mix(in srgb, var(--card) 70%, transparent);
    border: 1px solid var(--glass-hair);
}
.toolbar input:focus, .toolbar select:focus,
.filters input:focus, .filters select:focus {
    outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-lt);
}

.mono, code.mono {
    font-family: "Cascadia Mono", Consolas, "SFMono-Regular", ui-monospace, monospace;
    font-size: 11.5px;
    background: color-mix(in srgb, var(--surface) 72%, transparent);
    border: 1px solid var(--glass-hair);
    padding: 1px 5px; border-radius: 5px;
}


/* ── Buttons ───────────────────────────────────────────────────────────────
   The frame already defines .btn. This refines it with the glass treatment and
   a press response, without changing its shape or its API. */
.btn {
    border-radius: 9px;
    font-weight: 650;
    /* White on the raw accent is 2.75:1. Deepened, it clears AA in all five
       themes and looks more considered than a flat bright fill. */
    background: color-mix(in srgb, var(--accent) 76%, #000);
    transition: background .13s ease, border-color .13s ease, transform .1s ease, box-shadow .13s ease;
    border: 1px solid color-mix(in srgb, var(--accent) 70%, transparent);
    box-shadow: 0 1px 2px rgba(0,0,0,.14);
}
.btn:hover { transform: translateY(-1px); box-shadow: 0 5px 14px color-mix(in srgb, var(--accent) 28%, transparent); }
.btn:active { transform: translateY(0); box-shadow: 0 1px 2px rgba(0,0,0,.16); }
.btn:disabled, .btn[aria-disabled="true"] { opacity: .5; cursor: not-allowed; transform: none; box-shadow: none; }
.btn.secondary {
    background: color-mix(in srgb, var(--card) 55%, transparent);
    -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
    border-color: var(--glass-hair);
}
.btn.secondary:hover { background: var(--accent-lt); border-color: var(--accent); }
/* Variant names taken from what the Portal already uses in markup, counted
   2026-09-05: secondary x127, small x11, primary x6, ghost x4, danger x2,
   compact x1. The layer adopts the existing vocabulary rather than renaming
   it across 52 pages -- a rename would have been 170-odd edits to buy nothing,
   and every one of them a chance to miss a button and leave it unstyled.
   The `is-` forms are aliases for anything written from here on. */
.btn.small, .btn.compact, .btn.is-sm { padding: 5px 9px; font-size: 12px; }
.btn.primary, .btn.is-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.primary:hover, .btn.is-primary:hover { background: var(--accent-hov); }
.btn.danger, .btn.is-danger { background: var(--err); border-color: var(--err); color: #fff; }
.btn.danger:hover, .btn.is-danger:hover { background: var(--err); box-shadow: 0 5px 14px color-mix(in srgb, var(--err) 30%, transparent); }
.btn.ghost, .btn.is-ghost {
    background: transparent; border-color: transparent; color: var(--text);
    box-shadow: none;
}
.btn.ghost:hover, .btn.is-ghost:hover { background: var(--accent-lt); color: var(--strong); box-shadow: none; }


/* ── Hero / page intro ─────────────────────────────────────────────────────
   The block at the top of a dashboard that says what this page is for. Carries
   the isometric 3D Pixel corner — the one place the brand motif is allowed to
   be large. */
.hero {
    position: relative;
    border-radius: 16px;
    padding: 18px 20px;
    display: grid; gap: 10px;
    overflow: hidden;
    background-color: var(--glass-pane);
    background-image:
        linear-gradient(to bottom, var(--glass-edge) 0, transparent 1px, transparent 100%),
        linear-gradient(135deg, var(--accent-lt), transparent 58%);
}
/* Isometric brick field, top-right. Pure gradients — no image, no request. */
.hero::after {
    content: "";
    position: absolute; top: -10px; right: -10px;
    width: 190px; height: 130px;
    pointer-events: none; opacity: .5;
    background:
        repeating-linear-gradient( 30deg, color-mix(in srgb,var(--accent) 62%,transparent) 0 1.2px, transparent 1.2px calc(var(--brick) * 2)),
        repeating-linear-gradient(-30deg, color-mix(in srgb,var(--accent) 62%,transparent) 0 1.2px, transparent 1.2px calc(var(--brick) * 2)),
        repeating-linear-gradient(  0deg, color-mix(in srgb,var(--accent) 34%,transparent) 0 1.2px, transparent 1.2px calc(var(--brick) * 2));
    -webkit-mask-image: radial-gradient(ellipse at 78% 12%, #000 0%, transparent 72%);
    mask-image: radial-gradient(ellipse at 78% 12%, #000 0%, transparent 72%);
}
.hero-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; flex-wrap: wrap; }
.hero-title { font-size: 19px; font-weight: 800; color: var(--strong); position: relative; }
.hero-text  { font-size: 13.5px; line-height: 1.6; color: var(--text); max-width: 900px; position: relative; }
.hero .pill-row { position: relative; }
.pill-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }


/* ── Banners ───────────────────────────────────────────────────────────────
   The frame defines .banner; these are the semantic variants, unified so the
   same words mean the same colour on every page. */
.banner {
    border-radius: 11px;
    background: color-mix(in srgb, var(--card) 66%, transparent);
    -webkit-backdrop-filter: blur(var(--glass-blur-soft)); backdrop-filter: blur(var(--glass-blur-soft));
    line-height: 1.55;
}
/* The frame paints banner text in the status colour. On a 13% tint of the same
   colour that is dim -- the confirmation banner was the least readable thing on
   the first render. Body text goes back to --text; the status colour moves to a
   3px left edge and the border, where it carries the same meaning at full
   strength and costs no contrast. */
.banner.ok, .banner.warn, .banner.err, .banner.bad, .banner.info { color: var(--text); }
.banner.ok   { border-color: color-mix(in srgb,var(--ok) 45%,transparent);   background: var(--ok-bg);   border-left: 3px solid var(--ok); }
.banner.warn { border-color: color-mix(in srgb,var(--warn) 48%,transparent); background: var(--warn-bg); border-left: 3px solid var(--warn); }
.banner.err,
.banner.bad  { border-color: color-mix(in srgb,var(--err) 48%,transparent);  background: var(--err-bg); border-left: 3px solid var(--err); }
.banner.info { border-color: color-mix(in srgb,var(--accent) 42%,transparent); background: var(--accent-lt); border-left: 3px solid var(--accent); }


/* ── Section rule ──────────────────────────────────────────────────────────
   A heading that separates two groups of cards. The rule is a row of voxel
   studs rather than a plain line — the 3D Pixel motif at its smallest useful
   size, which is where it belongs on a working dashboard. */
.section-label {
    display: flex; align-items: center; gap: 12px;
    margin: 26px 0 12px;
    font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase;
    color: var(--muted);
}
.section-label::after {
    content: ""; flex: 1; height: 5px; border-radius: 2px;
    background-image: radial-gradient(circle at 2.5px 2.5px,
        color-mix(in srgb, var(--accent) 55%, transparent) 1.6px, transparent 1.8px);
    background-size: var(--brick) 5px;
    opacity: .7;
}
.section-label:first-child { margin-top: 0; }


/* ── Progress bar ──────────────────────────────────────────────────────────
   Blocky by design: the fill is drawn in brick units, so a bar reads as
   stacked pixels rather than a smooth gradient. */
.bar { height: 8px; border-radius: 999px; overflow: hidden;
    background: color-mix(in srgb, var(--border) 72%, transparent); }
.bar > i, .bar > .bar-fill {
    display: block; height: 100%; border-radius: 999px;
    background-color: var(--accent);
    background-image: repeating-linear-gradient(90deg,
        rgba(255,255,255,.16) 0 1px, transparent 1px var(--brick));
    transition: width .35s ease;
}
.bar.is-ok > i, .bar.is-ok > .bar-fill   { background-color: var(--ok); }
.bar.is-warn > i, .bar.is-warn > .bar-fill { background-color: var(--warn); }
.bar.is-err > i, .bar.is-err > .bar-fill  { background-color: var(--err); }


/* ── Key/value list ────────────────────────────────────────────────────────
   For "facts about one thing" — the most common non-table content in the
   Portal's backend pages.

   NOT called `.kv`, deliberately. `.kv` already means three incompatible
   things here: a flex row with a dashed rule (analytics, apis, ml-overview,
   api-endpoints), a bordered block with the label stacked above the value
   (admin, profile), and a two-column list. A name that means three things
   cannot be a shared component — claiming it broke all six pages in the first
   pass of this refresh. Those keep their own `.kv`; new work uses `.facts`. */
.facts { display: grid; grid-template-columns: minmax(120px, max-content) 1fr; gap: 6px 14px; font-size: 12.5px; }
.facts dt, .facts .k { color: var(--muted); font-weight: 600; }
.facts dd, .facts .v { margin: 0; color: var(--text); font-variant-numeric: tabular-nums; }


/* ── Toolbar / filters ─────────────────────────────────────────────────────*/
.toolbar, .filters {
    display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
    margin-bottom: 12px;
}
.toolbar .spacer, .filters .spacer { flex: 1; }
.field-inline { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); }
.input {
    padding: 7px 10px; border-radius: 8px;
    color: var(--text);
    background: color-mix(in srgb, var(--card) 70%, transparent);
    border: 1px solid var(--glass-hair);
    -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
    font: inherit; font-size: 13px;
}
.input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-lt); }
select.input { cursor: pointer; }


/* ── Empty / loading states ────────────────────────────────────────────────
   Named so a page never has to invent one. An empty table that says nothing
   is indistinguishable from a broken one. */
.empty {
    display: grid; place-items: center; text-align: center;
    gap: 8px; padding: 34px 20px;
    color: var(--muted); font-size: 13px;
}
.empty .empty-icon { font-size: 30px; opacity: .55; }
.empty .empty-title { font-size: 14px; font-weight: 700; color: var(--text); }

.skeleton {
    border-radius: 8px; min-height: 14px;
    background: linear-gradient(90deg,
        color-mix(in srgb,var(--border) 55%,transparent) 25%,
        color-mix(in srgb,var(--border) 90%,transparent) 50%,
        color-mix(in srgb,var(--border) 55%,transparent) 75%);
    background-size: 220% 100%;
    animation: hh-shimmer 1.3s linear infinite;
}
@keyframes hh-shimmer { from { background-position: 120% 0; } to { background-position: -120% 0; } }


/* ── Tabs ──────────────────────────────────────────────────────────────────*/
.tabs { display: flex; gap: 2px; flex-wrap: wrap; border-bottom: 1px solid var(--border); margin-bottom: 14px; }
.tabs .tab {
    padding: 8px 13px; background: none; border: 0; border-bottom: 2px solid transparent;
    color: var(--muted); font-weight: 600; font-size: 13px; cursor: pointer;
    transition: color .12s ease, border-color .12s ease;
}
.tabs .tab:hover { color: var(--text); }
.tabs .tab[aria-selected="true"], .tabs .tab.active {
    color: var(--strong); border-bottom-color: var(--accent); font-weight: 750;
}


/* ── Frame refinements ─────────────────────────────────────────────────────
   The sidebar and the page header belong to portal-frame.css; these are the
   glass treatments layered on top. The sidebar was a fixed pale-blue panel in
   all five themes — the one surface that ignored the theme entirely, and the
   loudest reason the Portal read as older than the apps inside it. */
.sidebar {
    background: color-mix(in srgb, var(--surface) 82%, transparent);
    -webkit-backdrop-filter: blur(var(--glass-blur-chrome)) saturate(150%);
    backdrop-filter: blur(var(--glass-blur-chrome)) saturate(150%);
    border-right: 1px solid var(--glass-hair);
    box-shadow: 1px 0 24px rgba(0,0,0,.18);
}
/* Every control in the sidebar is a <button>, and a bare <button> carries the
   user agent's own light `buttonface` background. That was invisible while the
   sidebar was a fixed pale panel; the moment it follows the theme, the group
   toggles render as bright grey slabs on a dark ground. Found in the first
   headless render of this refresh, not in review. */
.sidebar button,
.sidebar .nav-item,
.sidebar .nav-main-toggle,
.sidebar .nav-subgroup-toggle {
    background: transparent;
    color: var(--text);
    -webkit-appearance: none; appearance: none;
}
.sidebar .nav-main-toggle:hover,
.sidebar .nav-subgroup-toggle:hover { background: var(--accent-lt); }
.sidebar .nav-item:hover { background: var(--accent-lt); color: var(--strong); }
.sidebar .nav-item.active {
    background: var(--accent-lt);
    color: var(--strong);
    border-left: 3px solid var(--accent);
}
.sidebar .nav-main { color: var(--text); }
.sidebar .brand-name { color: var(--strong); }
.sidebar .brand-name span { color: var(--accent); }
.sidebar .brand-sub { color: var(--muted); }
.sidebar .nav-chevron { color: var(--muted); }


/* ── The brand mark ────────────────────────────────────────────────────────
   Jörn, 2026-09-05: "still far away from beautiful 3D Pixel". The clearest
   reason was at the top of every page — BOTH H marks, the sidebar brand block
   and the PORTAL nav row, were CSS-gradient blobs from the old design with no
   3D Pixel in them at all. The one element guaranteed to be seen on every page
   carried none of the identity, and the nav one rendered as a pale grey slab.

   It is now the brick-built mark from branding-hub/tools/render-pixel-icons.py:
   a silhouette extruded toward the viewer with a stud on every brick, and it
   takes its colours from the theme, so it is blue in the Portal, red on Horde,
   green on JoschiCraft. */
.hoppe-h-pixel {
    --hm-face: var(--accent);
    --hm-side: color-mix(in srgb, var(--accent) 26%, #000);
    --hm-stud: color-mix(in srgb, var(--accent) 16%, #000);
    --hm-lit: #fff;
    width: 34px; height: 34px; flex-shrink: 0;
    display: inline-block; vertical-align: middle;
    filter: drop-shadow(0 3px 8px color-mix(in srgb, var(--accent) 34%, transparent));
}
.hoppe-h-pixel.is-brand  { width: 46px; height: 46px; }
.hoppe-h-pixel.is-nav    { width: 22px; height: 22px; }
.hoppe-h-pixel.is-footer { width: 17px; height: 17px; filter: none; opacity: .85; }
.hoppe-h-pixel.is-hero   { width: 84px; height: 84px; }
.hoppe-h-pixel.is-tile-badge { width: 20px; height: 20px; filter: none; }

/* One-time "drop and settle" on page load — Jörn, 2026-09-18: "use the PIXEL
   design ... to make it stick", then "Do all of your recommendations on all
   pages" once he'd seen it on the homepage and /joern/ and /joanna/. Lives
   here, not duplicated per page, so it reaches every page that carries
   .is-brand or .is-hero (the sidebar brand block, and gallery.html/
   design.html/streaming.html's hero marks) without anyone having to remember
   to copy it into the next one.

   Deliberately NOT applied to .is-nav/.is-footer/.is-tile-badge: those render
   several-to-many per page (nav rows, tile grids), and all of them bouncing
   in at once would be noise, not delight — restrained micro-motion stays the
   house style (nr-in, weatherGlowPulse), not a page full of moving parts. */
@keyframes pixel-intro-drop {
    0%   { opacity: 0; transform: translateY(-12px) scale(.6) rotate(-8deg); }
    60%  { opacity: 1; transform: translateY(2px) scale(1.08) rotate(2deg); }
    80%  { transform: translateY(-1px) scale(.96) rotate(-1deg); }
    100% { opacity: 1; transform: translateY(0) scale(1) rotate(0deg); }
}
.hoppe-h-pixel.is-brand,
.hoppe-h-pixel.is-hero {
    animation: pixel-intro-drop .6s cubic-bezier(.34,1.56,.64,1) both;
}
@media (prefers-reduced-motion: reduce) {
    .hoppe-h-pixel.is-brand,
    .hoppe-h-pixel.is-hero { animation: none; }
}

/* The brand block: glass, with a stud row along its bottom edge instead of the
   heavy filled panel it was. */
.sidebar .brand-block {
    position: relative;
    background: color-mix(in srgb, var(--card) 52%, transparent);
    -webkit-backdrop-filter: blur(12px) saturate(150%); backdrop-filter: blur(12px) saturate(150%);
    border: 1px solid var(--glass-hair);
    box-shadow: inset 0 1px 0 var(--glass-edge), 0 8px 22px rgba(0,0,0,.22);
    overflow: hidden;
}
.sidebar .brand-block::after {
    content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 4px;
    background-image: radial-gradient(circle at 3px 2px,
        color-mix(in srgb, var(--accent) 70%, transparent) 1.5px, transparent 1.7px);
    background-size: var(--brick) 4px;
    opacity: .7;
}

/* ── The menu ──────────────────────────────────────────────────────────────
   Rebuilt 2026-09-05. Three things were wrong with it, and none of them was
   colour:

   1. **64 colour emoji.** They render differently on every operating system,
      so the menu did not even look the same to two people in this house, and a
      🎨 beside "Branding Hub" reads consumer, not enterprise. Replaced with the
      3D Pixel glyph set — one sprite, `currentColor`, so the icons follow the
      theme accent and carry the house identity into the navigation, which is
      the one place it was completely absent.
   2. **No hierarchy.** `.nav-grp-hd` — the small uppercase muted group-header
      style — existed in the frame and was used ZERO times. "Application &
      Services" was rendered at the same weight as "Branding Hub" underneath
      it, so the menu read as one flat list of thirty things.
   3. **Loose density.** ~45px per row. A twenty-item section did not fit on
      screen, for no extra information.  */

/* The glyphs. `pi-b` is the face, `pi-t` the lit top row; the one-pixel
   extrusion is a drop-shadow rather than doubled geometry. */
.pixel-icon {
    /* 19px, not 17. The glyph is an 8x8 voxel grid: at 17px a brick is barely
       two device pixels and the 3D Pixel identity stops reading as one. */
    width: 19px; height: 19px;
    flex-shrink: 0;
    /* `color` and `--pi-top` are the only two channels that reach the paths:
       <use> renders the symbol into a shadow tree, so a descendant selector
       never matches inside it. The first version styled `.pixel-icon .pi-b`
       and every glyph came out default black -- dark on dark, while the CSS
       read perfectly. Inherited values cross that boundary; selectors do not. */
    color: var(--accent);
    --pi-top: color-mix(in srgb, var(--accent) 30%, var(--strong));
    filter: drop-shadow(1px 1px 0 color-mix(in srgb, var(--accent) 40%, #000));
    transition: color .13s ease, transform .13s ease;
}
.pixel-icon .pi-b { fill: currentColor; }
.pixel-icon .pi-t { fill: var(--pi-top, currentColor); }

.sidebar .nav-item:hover .pixel-icon { transform: translate(-.5px, -.5px); }
.sidebar .nav-item.active .pixel-icon { color: var(--accent); }
/* A group header's own glyph steps back — it labels a section, it is not a
   destination, and at full accent it competed with the items beneath it. */
.sidebar .nav-main .pixel-icon { width: 16px; height: 16px; opacity: .78; }

/* Hierarchy: a section header is a LABEL, not a bigger menu item. */
.sidebar .nav-main {
    font-size: 13.5px;
    font-weight: 700;
    letter-spacing: 0;
    text-transform: none;
    color: var(--text);
}
.sidebar .nav-main-row > .nav-main {
    font-size: 10.5px;
    font-weight: 800;
    /* .11em -> .085em, and a tighter gap below. With 19px glyphs beside them,
       .11em pushed "ARTIFICIAL INTELLIGENCE" onto a second line in the 292px
       column. Small caps carry the hierarchy; the extra tracking was decoration
       that cost a line. */
    letter-spacing: .085em;
    gap: 9px;
    text-transform: uppercase;
    /* Legible in BOTH states. The first version used --muted for a collapsed
       section, and on the dark glass sidebar "ADMIN TOOLS" came out so faint it
       read as disabled. Hierarchy is carried by size, weight and letterspacing;
       dimness on top of those is just a section you cannot find.

       The !important that stood here from 2026-09-05 is gone: it was propping
       this up against ten pages that still carried their own sidebar CSS, and
       those are now swept — zero shell rules remain in any page that loads the
       shell. The cascade layer below does the job properly instead. */
    color: color-mix(in srgb, var(--text) 74%, var(--muted));
    padding-top: 7px; padding-bottom: 7px;
}
.sidebar .nav-main-row:hover > .nav-main,
.sidebar .menu-section.expanded > .nav-main-row > .nav-main { color: var(--strong); }

/* Focus. A bare <button> shows the user agent's own ring, which on a dark
   sidebar is a black rounded box — it appears in Jörn's screenshots around
   "Infrastructure Consoles" and the Network chevron and reads as broken
   chrome, not as focus. */
.sidebar button:focus-visible,
.sidebar a:focus-visible,
.btn:focus-visible, .chip:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
    border-radius: 8px;
}
.sidebar button:focus:not(:focus-visible) { outline: none; }
.sidebar .nav-main-row { margin-top: 13px; }
.sidebar .nav-main-row .nav-item.nav-main { margin-top: 0; }
/* The chevron becomes a quiet mark, not a text glyph competing with the label. */
.sidebar .nav-chevron,
.sidebar .nav-subgroup-chevron { font-size: 9px; opacity: .55; }

/* Density: 45px a row was too loose, ~29 was too tight. This is ~35.

   The first pass chased "a twenty-item section fits on one screen" and got
   there by taking the air out -- 6px of padding, 13.5px labels and 17px glyphs.
   Jörn, 2026-09-06: *"it is tooo squeezed!!! Find a better balance between 3D
   pixel and nice view of the menu"*. Two things were wrong with that trade. A
   voxel glyph is an 8x8 grid, so at 17px each brick is barely two device pixels
   and the mark the whole identity is built on reads as a smudge -- it needs
   size to be recognisable at all. And a menu is read far more often than it is
   scrolled: fitting one more row costs legibility on every visit to save a
   flick of the wheel on some of them.

   So rows breathe, glyphs are big enough to read as bricks, and the list
   scrolls. Section headers keep their small caps -- those were not the problem,
   and enlarging them would wrap "APPLICATION & SERVICES" in a 292px column. */
.sidebar .nav-item {
    padding-top: 8px; padding-bottom: 8px;
    font-size: 14px;
    gap: 11px;
    border-radius: 8px;
    /* min-height holds the row even if the padding or line-height above is lost
       to something outside this file. Added 2026-09-06 because Jörn kept seeing
       ~21px rows on two pages while a headless render of the very same deployed
       files measured 32-40px -- I could not reproduce it, so rather than guess a
       seventh time the row is given a floor it cannot fall through. If the cause
       ever turns up, this line stays: it costs nothing and it is the difference
       between "correct" and "cannot be wrong". */
    min-height: 34px;
}
.sidebar .nav-item.sub-item { min-height: 32px; }
.sidebar .nav-item.sub-item { font-size: 13.5px; padding-left: 26px; }
.sidebar .nav-item.sub-item.sub-item-nested { padding-left: 38px; font-size: 13px; }
.sidebar .nav-item.active { padding-left: 23px; }
.sidebar .nav-item.sub-item.active { padding-left: 23px; }
.sidebar-nav { padding-top: 6px; }

/* A nested sub-group gets a hairline down its left edge, so three levels of
   indent are readable as three levels rather than as three paddings. */
.sidebar .nav-subgroup-body {
    margin-left: 22px;
    padding-left: 6px;
    border-left: 1px solid var(--glass-hair);
}

.page-header { padding-top: 40px; }
.page-title {
    display: flex; align-items: center; gap: 12px;
    letter-spacing: -.015em;
}
/* The 3D Pixel accent on the page title: a single lit brick. Small, and the
   same shape as the KPI edge, so the motif repeats rather than accumulates. */
.page-title::before {
    content: ""; flex-shrink: 0;
    width: 7px; height: 26px; border-radius: 2px;
    background: linear-gradient(180deg,
        var(--brick-face) 0 32%, var(--brick-top) 32% 34%,
        var(--brick-face) 34% 66%, var(--brick-top) 66% 68%,
        var(--brick-face) 68% 100%);
    box-shadow: 2px 0 0 var(--brick-side), 0 0 14px color-mix(in srgb, var(--accent) 40%, transparent);
}
.page-subtitle { max-width: 900px; line-height: 1.6; }

.user-chip, .back-btn {
    -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
    background: color-mix(in srgb, var(--card) 62%, transparent);
    border: 1px solid var(--glass-hair);
}
.menu-panel { border-radius: 12px; }


/* ── Motion ────────────────────────────────────────────────────────────────*/
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important;
        transition-duration: .01ms !important; }
    .btn:hover, .card.is-interactive:hover { transform: none; }
}


/* ── Narrow screens ────────────────────────────────────────────────────────
   Blur is expensive on phone GPUs and the ambient wash has little room to
   read. Below 780px the panes go opaque — the layout and the brand motif
   stay, the effect does not. */
@media (max-width: 780px) {
    .card, .kpi, .hero, .panel, .glass {
        -webkit-backdrop-filter: none; backdrop-filter: none;
        background-color: var(--card);
    }
    .sidebar { -webkit-backdrop-filter: none; backdrop-filter: none; background: var(--surface); }
    body::before, body::after { display: none; }
    .kpi-grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
    .card-grid { grid-template-columns: 1fr; }
    .page-title { font-size: 23px; }
    .page-title::before { height: 22px; width: 6px; }
}


/* ── Print ─────────────────────────────────────────────────────────────────
   Glass does not print. Ink on paper, borders instead of shadows. */
@media print {
    body::before, body::after { display: none; }
    .card, .kpi, .hero, .panel, .glass {
        background: #fff !important; color: #000 !important;
        -webkit-backdrop-filter: none !important; backdrop-filter: none !important;
        box-shadow: none !important; border: 1px solid #999 !important;
    }
    .table th { background: #eee !important; color: #000 !important; }
    .toolbar, .filters, .tile-actions, .shell-tools { display: none !important; }
}



/* ── Sidebar structure (moved out of shared-sidebar.html, 2026-09-06) ───────
   These rules used to live in a <style> block inside the sidebar FRAGMENT,
   which every page injects with innerHTML. That put them in the document
   UNLAYERED, so they silently outranked every rule in this file no matter
   how specific it was -- `gap: 7px` there beat `gap: 11px` here, and any
   future edit would have kept losing for a reason nobody could see in the
   stylesheet.

   Jörn, 2026-09-06: *"make sure all the sidebar are the same and we have only
   one wireframe"*. One place styles the sidebar now, and it is this file.
   The fragment carries markup and the icon sprite -- nothing else. */
.sidebar .nav-main-row {
    display: flex;
    align-items: stretch;
    gap: 6px;
    margin: 1px 8px;
}
.sidebar .nav-main-row .nav-item.nav-main {
    margin: 0;
    flex: 1 1 auto;
}
.sidebar .nav-main-toggle {
    width: 38px;
    min-width: 38px;
    justify-content: center;
    padding-left: 0;
    padding-right: 0;
}
.sidebar .menu-section:not(.expanded) .menu-submenu {
    display: none;
}
.sidebar .nav-chevron {
    display: inline-block;
    transition: transform .15s ease;
}
.sidebar .menu-section.expanded .nav-chevron {
    transform: rotate(90deg);
}
/* Pending/stub nav items */
.sidebar .nav-stub {
    opacity: .52;
    cursor: not-allowed;
    pointer-events: none;
}
.sidebar .nav-stub-badge {
    margin-left: auto;
    font-size: 9px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .06em;
    padding: 1px 5px;
    border-radius: 4px;
    background: rgba(240,180,41,.18);
    color: #f0b429;
    border: 1px solid rgba(240,180,41,.35);
    flex-shrink: 0;
}
.sidebar .nav-item.sub-item { display: flex; align-items: center; }
/* Nested collapsible sub-groups within a submenu (2026-09-02, Jörn: Admin
   Tools regroup) - deliberately separate classes from .menu-section/
   .nav-chevron above so a nested toggle never gets caught by the broader
   ".menu-section.expanded .nav-chevron" selector and rotates independently
   of its parent section's own expand state. */
.sidebar .nav-subgroup { margin: 1px 0; }
.sidebar .nav-item.sub-item.nav-subgroup-toggle {
    width: 100%;
    background: none;
    border: none;
    cursor: pointer;
    font: inherit;
    text-align: left;
}
.sidebar .nav-subgroup-chevron {
    display: inline-block;
    margin-left: auto;
    transition: transform .15s ease;
}
.sidebar .nav-subgroup.expanded .nav-subgroup-chevron {
    transform: rotate(90deg);
}
.sidebar .nav-subgroup:not(.expanded) .nav-subgroup-body {
    display: none;
}
/* nesting indent: see the density block above (38px) -- the 30px that stood
   here came from the fragment and quietly won because it was unlayered. */
/* LAN/VPN-only consoles, labelled on the external front door (auth-gate.js applyHostAwareLinks). */
.sidebar .nav-item.lan-only .nav-lan { margin-left: auto; font-size: 9px; letter-spacing: .04em; padding: 1px 6px; border-radius: 999px; border: 1px solid rgba(255,255,255,.22); opacity: .75; }
}

/* ── Tabs (2026-09-23) ──────────────────────────────────────────────────────
   One tabs component. admin.html, analytics.html and intelligence.html had each
   hand-rolled their own (inline onclick, no keyboard, no ARIA) -- the same drift
   as the copied .btn/.card fragments this file exists to end. Behaviour is in
   js/hh-tabs.js (arrow/Home/End keys, URL hash, aria-selected, `hh-tab-change`).
   Markup: <nav class="hh-tabs" role="tablist" data-hh-tabs> with
   <button class="hh-tab" role="tab" data-tab aria-controls> and panels marked
   `.hh-tabpanel` (shown when `.active`). Add `.is-sticky` to pin the bar. */
@layer hh.components {
.hh-tabs { display: flex; gap: 4px; overflow-x: auto; scrollbar-width: thin; padding: 6px;
  border: 1px solid var(--border); border-radius: 14px;
  background: color-mix(in srgb, var(--surface) 88%, transparent);
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
.hh-tabs.is-sticky { position: sticky; top: 0; z-index: 20; }
.hh-tab { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px; border: 1px solid transparent;
  background: transparent; color: var(--muted); font: inherit; font-size: 13px; font-weight: 600;
  padding: 8px 13px; border-radius: 10px; cursor: pointer; white-space: nowrap; }
.hh-tab:hover { color: var(--strong); background: var(--card-hover); }
.hh-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.hh-tab[aria-selected="true"] { color: var(--strong); border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 16%, transparent); }
.hh-tab[disabled], .hh-tab.is-locked { opacity: .45; cursor: not-allowed; }
.hh-tab-ico { font-size: 14px; line-height: 1; }
.hh-tabpanel { display: none; animation: hhTabIn .16s ease-out; }
.hh-tabpanel.active { display: block; }
@keyframes hhTabIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .hh-tabpanel { animation: none; } }
}
