/* ===================================================================
   The Bloc — Shell backdrop
   Everything that used to live here was an interception layer: a set of
   `body .bg-<grey shade>` rules that re-coloured Tailwind's stock greys
   from CSS, because the Tailwind config still held the old palette.

   The config now reads tokens.css directly, so those rules would be a
   second, invisible opinion about the same pixel — two files that each
   look correct alone and disagree in the browser. They were deleted on
   2026-08-20; do not add another. Since 2026-08-21 there is no stock-grey
   override left to intercept either: every utility names a `surface-*`
   step directly, and tests/test_no_gray_returns.py scans this file to
   keep a CSS-only reintroduction out.

   What is left is the page backdrop itself, which is not a utility and
   has nowhere else to live. The palette is in tokens.css.
   =================================================================== */

html {
    background: var(--ground);
}

body {
    background-color: var(--ground);
    background-size: 6px 6px;
}

/* --- Sidebar nav items ----------------------------------------------
   One rule for both viewports: the desktop sidebar and the mobile drawer
   render the same macro (app/templates/_nav.html), so there is nowhere
   left for the two to disagree about a hover colour. Colour comes from
   tokens.css and nothing else. `.nav-item--active` is the filled pill;
   the icon uses currentColor, so it follows the text for free.
   -------------------------------------------------------------------- */
.nav-item {
    display: flex;
    align-items: center;
    gap: 10px;
    border-radius: var(--radius-control);
    padding: 8px 12px;
    font-size: 14px;
    font-weight: 500;
    color: var(--surface-300);
    transition: background-color .12s, color .12s;
}
.nav-item:hover { background: var(--surface-800); color: var(--surface-100); }
/* One vocabulary for every FILLED control in this panel: rest bloc-600,
   hover bloc-700, ink white. Measured on the declared channels — white on
   600 is 4.84:1 and on 700 is 7.11:1, both over the 4.5:1 text floor.
   What this replaced was surface-100 on bloc-500 = 3.24:1 at rest and 4.00:1
   on hover: the worst text pair in the panel, and lower than the markup
   buttons that surfaced the problem. Nothing on bloc-500 reads — white is
   3.93:1 and dark ink is 4.59:1 — so the FILL moved rather than the ink. */
.nav-item--active { background: var(--bloc-600); color: var(--white); }
.nav-item--active:hover { background: var(--bloc-700); color: var(--white); }
.nav-icon { width: 16px; height: 16px; flex: none; }

/* --- Sidebar chrome and the account block ---------------------------
   Spec 3.1: fixed 260px, surface-950, right border surface-600. The
   width itself stays in base.html, where it has to agree with the main
   column's left margin; only the colour lives here.

   `.shell-account` is `margin-top: auto` inside the sidebar's flex
   column, so it sits on the bottom edge whatever the nav does above it.
   It declares no `display` of its own since 2026-08-22: the row is the
   TRIGGER's now, and the block's other child is the popup, which is out
   of flow. It carries Tailwind's `relative` in the markup, which is
   both the popup's containing block and what the click-outside handler
   in base.html selects on.
   -------------------------------------------------------------------- */
.shell-sidebar { background: var(--surface-950); border-right: 1px solid var(--surface-600); }
.shell-account { margin-top: auto; padding: 12px; border-top: 1px solid var(--surface-600); }
/* Initials, not a fetched image. Crafatar would leak every panel user to a third
   party on every page load, and spec 3.7 bans remote artwork outright. */
.shell-avatar {
    width: 32px; height: 32px; flex: none; border-radius: var(--radius-pill);
    background: var(--bloc-700); color: var(--surface-100);
    display: grid; place-items: center; font-size: 13px; font-weight: 600;
}
.shell-account-name { display: block; font-size: 13px; font-weight: 600; color: var(--surface-100); overflow-wrap: anywhere; }
.shell-account-role { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--surface-400); }
/* The whole block is the button, exactly like the top bar's chip - Milo, from a
   screenshot: "make it a popup just like it is on the top right". `width: 100%`
   is what makes the CLICK TARGET the block: a <button> shrink-wraps its content
   otherwise, so the strip to the right of a short username would look like part
   of the control and do nothing when pressed. The font/padding/border/background
   resets are the UA's button styling, which would otherwise draw a grey rounded
   box across the foot of the sidebar.

   The colour here reaches only the chevron - the name and the role declare their
   own - so :hover moves the affordance and leaves the identity alone, which is
   what `.shell-topbar-user` does at the other end of the shell. */
.shell-account-trigger {
    display: flex; align-items: center; gap: 10px; width: 100%;
    padding: 0; background: none; border: 0; text-align: left;
    font: inherit; color: var(--surface-400); cursor: pointer;
    transition: color .12s;
}
.shell-account-trigger:hover { color: var(--surface-100); }
/* `margin-left: auto` is the other half of `width: 100%` above and is the whole
   fix for Milo's "the user one is off center" (2026-08-22). The trigger spans
   the block so the whole block is pressable; the chevron is what MARKS that far
   edge. Packed with the identity it stops wherever the username happens to end
   - measured with `rigadmin` by tools/probes/probe-controls.mjs: chevron right
   edge at x=135 in a trigger whose right edge is 247, i.e. 112px of dead air,
   so the control read as a short chip trailing an empty strip. The top bar's
   chip shrink-wraps and has always had the chevron on its own right edge; this
   is the sidebar catching up to it. Auto rather than a number, because the gap
   is whatever the name leaves. */
.shell-account-chevron { width: 16px; height: 16px; flex: none; margin-left: auto; }
/* The popup is the top bar's menu in another place: `.shell-topbar-menu` brings
   the border, radius, shadow and clipped corners, `.shell-topbar-menu-item` the
   rows. Two things differ and both are geometry, so both are in the markup:
   `bottom-full` (the block sits on the bottom edge of the viewport, so it opens
   UPWARD) and `left-3 right-3`, the trigger's own box inside this block's 12px
   padding.

   `width: auto` is what lets those two anchors decide the width, and the
   COMPOUND selector is not tidiness: `.shell-topbar-menu` pins 12rem and is
   declared later in this file, so a bare `.shell-account-menu` is the same
   specificity and loses on source order. MEASURED with exactly that edit in
   place (probe-account.mjs, 2026-08-22): the menu rendered 192px inside a 235px
   box, 43px short of the trigger it is anchored to, in both viewports. (With
   `width` and both insets set, CSS drops `right` and the anchors stop deciding
   anything at all.)

   Nothing here sets `display`, for the reason the top bar's block below gives:
   the markup toggles Tailwind's `.hidden`, and an author `display` at the same
   specificity is a coin flip on stylesheet order. */
.shell-topbar-menu.shell-account-menu { width: auto; }
/* The avatar sits beside a STACK: the name over the role, inside the trigger's
   row. `flex-direction: column` below is not redundant with `display: block` on
   those two - a flex container blockifies its children and lays them along its
   own main axis, so as a ROW the username and the role sit side by side and the
   block loses a line of height it was drawn with. */
/* min-width is NOT redundant: a flex child defaults to min-width:auto, which
   refuses to shrink below its longest unbreakable word, so one long username
   sized the whole sidebar. The username is app/models.py:23 String(80),
   validated Length(max=80) at app/auth/forms.py:7 - eighty characters is a
   number the panel itself permits, so it is the number that has to fit.
   Measured at 80 chars before this: the aside's scrollWidth went 259 -> 654
   and the shell grew a horizontal scrollbar on every page.

   Wrap, do not ellipsis. Truncation hides who you are logged in as and gives
   it back only on hover, which does not exist on a phone; a pathological name
   costing the block an extra line is the cheaper failure. */
.shell-account-body { display: flex; flex-direction: column; min-width: 0; }

/* --- The server identity block --------------------------------------
   Spec 3.1: name, connect address and a status dot, directly under the
   brand in both sidebars. This panel drives two Minecraft servers from
   one codebase, so "which server is this" is a question the shell has
   to answer on every page.

   The dot's colours are semantic, never decorative: success-500 online,
   surface-400 offline, and a dimmer surface-500 for UNKNOWN. Unknown is
   a real third state, not a placeholder - it is what the dot shows
   before the first probe answers and after one fails, and it must never
   be mistaken for either of the other two.

   .shell-dot is an inline <span>, so its width and height only apply
   because it is a flex item of .shell-identity-head and is therefore
   blockified. Measured 8x8 in Chromium on the rig. Take the head out of
   flex and the dot collapses to nothing.
   -------------------------------------------------------------------- */
.shell-identity { padding: 12px; border-bottom: 1px solid var(--surface-600); }
.shell-identity-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.shell-identity-name { font-size: 13px; font-weight: 600; color: var(--surface-100); }
.shell-identity-addr {
    display: block; margin-top: 4px; font-family: 'Overpass Mono', ui-monospace, monospace;
    font-size: 11px; color: var(--surface-300);
}
.shell-identity-state { display: block; margin-top: 2px; font-size: 10px; letter-spacing: .08em; color: var(--surface-400); }
.shell-dot { width: 8px; height: 8px; border-radius: var(--radius-pill); flex: none; }
.shell-dot--online { background: var(--success); }
.shell-dot--offline { background: var(--surface-400); }
.shell-dot--unknown { background: var(--surface-500); }

/* --- The server controls --------------------------------------------
   Treatment A, picked by Milo from a re-rendered A/B on 2026-08-22:
   three icon buttons, always all three, and the one that does not
   apply is DISABLED rather than taken away.

   That supersedes the presentation the pills had, not the rule behind
   them. Ruling B2 said the state decides what you may do and the
   identity block's LIVE/OFFLINE carries the green; both still hold.
   What changed is that a control you may not use stays on screen, so
   the row keeps its shape as the server comes and goes, and the three
   actions are learnable from a healthy day rather than only from a
   broken one.

   Ruling B3 is unchanged: state drives ENABLEMENT, never presence. All
   three stay in the DOM.

   One resting fill for all three, and the difference is what each one
   reveals under the pointer: green for Start, red for Stop, the
   ordinary raised step for Restart. Nothing is weighted at rest -
   there is no primary here any more, because which action is the
   likely one is exactly what the state already says.

   The buttons are icon-ONLY, so their names live in `aria-label` and
   `title` (see _server_identity.html). That is also why the hover
   fills are held to the 3:1 GRAPHIC floor rather than the 4.5:1 text
   one in tests/test_contrast_floors.py: the only glyph in here is a
   15px SVG. The one moment a word appears is serverAction's "working",
   and the button is `disabled` for exactly that moment - so the rule
   below is what guarantees no text ever sits on the danger fill.

   `.shell-ctl[disabled]:hover` is NOT a dead hover the way
   tests/test_hover_feedback.py means one. It exists to CANCEL the
   three rules above it: without it, a control the state has disabled
   still lights up green or red under the pointer and reads as
   pressable, which is the whole affordance the disable is removing.
   -------------------------------------------------------------------- */
.shell-controls { display: flex; gap: 6px; margin-top: 10px; }
.shell-ctl {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 7px 0;
    background: var(--surface-700);
    color: var(--surface-100);
    border: 1px solid transparent;
    border-radius: var(--radius-control);
    cursor: pointer;
    transition: background .12s, color .12s;
    /* For the one moment these buttons hold a WORD. serverAction replaces the
       pressed control's glyph with "… working" for the length of the POST -
       which cost nothing in a text pill that already said "Restart", and lands
       in a 74px icon button with no horizontal padding. MEASURED
       (tools/probes/probe-controls.mjs, 2026-08-22, replaying serverAction's
       own replaceChildren): with none of the three below, the word wraps to a
       second line and the whole control row goes 31px -> 40px, so the nav jumps
       9px down and back every time anyone restarts the server. `nowrap` is what
       keeps the height; `font-size` is here because a button that can hold text
       has to state its type size rather than inherit one. It does not overflow:
       one line of "working" at 12px is 47px inside 74.3px.

       `line-height` is the third of the three and the least obvious: with the
       first two the row still moved 31px -> 34px, because a 12px line box is
       18px tall and the glyph it replaced is 15px. Pinning the line box to the
       glyph's own height is what makes the swap cost nothing at all - measured
       back at 31px -> 31px. */
    white-space: nowrap;
    font-size: 12px;
    line-height: 15px;
}
.shell-ctl svg { width: 15px; height: 15px; display: block; }
.shell-ctl:hover { background: var(--surface-500); }
.shell-ctl--start:hover { background: var(--success); color: var(--surface-950); }
.shell-ctl--stop:hover { background: var(--danger); color: var(--surface-100); }
.shell-ctl[disabled] { opacity: .35; cursor: default; }
.shell-ctl[disabled]:hover { background: var(--surface-700); color: var(--surface-100); }

/* --- The top bar -----------------------------------------------------
   Height is 64px because the sidebar's logo well is `h-16` and the two
   sit on the same top edge; a mismatch shows as a step where one border
   meets the other. The bar keeps the `backdrop-blur-sm` it has carried
   since Plan A's 76eef61.

   Nothing in this block sets `display`, and that is deliberate rather
   than an omission. base.html shows and hides the account menu by
   toggling Tailwind's `.hidden` on `#user-dropdown`; a `display` on
   `.shell-topbar-menu` would be an author rule at the same specificity,
   so which one wins depends on whether the CDN's runtime <style> or this
   file came last in the document. Two rules that both look right and a
   menu that silently never opens. Layout stays in the utilities on the
   element, colour lives here. `.shell-topbar-menu-item` is the one
   exception and is safe: it is never `.hidden`.
   -------------------------------------------------------------------- */
.shell-topbar {
    height: 64px;
    background: var(--surface-800);
    border-bottom: 1px solid var(--surface-600);
}
.shell-topbar-title { font-size: 14px; font-weight: 600; color: var(--surface-100); }
.shell-topbar-icon { color: var(--surface-300); transition: color .12s; }
.shell-topbar-icon:hover { color: var(--surface-100); }
.shell-topbar-user { color: var(--surface-300); cursor: pointer; transition: color .12s; }
.shell-topbar-user:hover { color: var(--surface-100); }
/* Username and role read the same two sizes as `.shell-account-name` and
   `.shell-account-role` in the sidebar, because they are the same two
   facts about the same person shown twice on one page. */
.shell-topbar-user-name { font-size: 13px; font-weight: 600; color: var(--surface-100); }
.shell-topbar-user-role {
    font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
    color: var(--surface-400);
}
/* `overflow: hidden` is what rounds the first and last item, and it is
   not tidiness: the last child is a <form> wrapping the button, so a
   `:last-child` radius on the item itself would never match. Clipping at
   the menu covers both ends without the markup having to say which is
   which, and it keeps a hover fill inside the rounded corner. */
.shell-topbar-menu {
    width: 12rem;
    background: var(--surface-800);
    border: 1px solid var(--surface-600);
    border-radius: var(--radius-card);
    overflow: hidden;
    box-shadow: 0 10px 24px rgb(var(--c-shadow) / .24);
}
.shell-topbar-menu-item {
    display: block;
    width: 100%;
    text-align: left;
    padding: 12px 16px;
    font-size: 13px;
    color: var(--surface-100);
    background: none;
    border: 0;
    cursor: pointer;
    transition: background-color .12s, color .12s;
}
.shell-topbar-menu-item:hover { background: var(--surface-700); }
/* Sign-out was `text-red-400` / `hover:text-red-300` - stock Tailwind
   `red`, a family the panel does not theme, so it was the one colour in
   the shell that never went indigo-adjacent and never would have. Same
   two steps of the same ramp, now off the scale in tokens.css. */
.shell-topbar-menu-item--danger {
    color: rgb(var(--c-danger-400));
    border-top: 1px solid var(--surface-600);
}
.shell-topbar-menu-item--danger:hover { background: var(--surface-700); color: var(--danger-text); }


/* ===================================================================
   The confirmation modal, the working glyph and the flash strip.

   base.html loads app/static/js/server-actions.js on EVERY signed-in page -
   the sidebar carries Start/Stop/Restart everywhere - and until 2026-08-21
   every rule those widgets need lived in dashboard.css, which exactly five
   templates link. Off /dashboard the "modal" was a chain of unstyled
   static-position divs appended to the foot of <body>: no backdrop, no
   dialog, no click-outside target, and cancelBtn.focus() scroll-jumped the
   viewport down to it - guarding a destructive action. The JS moved to the
   shell in an earlier task; this is its CSS following it.

   Two tokens changed name in the move and NOTHING ELSE did. `--accent-primary`
   and `--surface` are declared at no `:root`, only inside .bloc-dashboard,
   .bloc-crash, .bloc-podium and .public-page - and the modal mounts on
   document.body, which is inside none of them, so here they would have
   computed to nothing. Each of those five scopes declares exactly
   `--accent-primary: var(--accent)` and `--surface: var(--ground)`, so the
   :root tokens below are the same colour the dashboard was already painting.
   scripts/check_vars.py is what says so out loud: shell.css declares no scope
   of its own and is held to `:root`, deliberately, because that is how the
   wordmark came to reach for a scope-only token from the shell.
   =================================================================== */

.bloc-chip-glyph { font-size: 9px; line-height: 1; }

.bloc-action-modal-backdrop {
    position: fixed;
    inset: 0;
    z-index: 1000;
    display: grid;
    place-items: center;
    padding: 18px;
    background: rgb(var(--c-modal-backdrop) / .72);
    backdrop-filter: blur(5px);
}

.bloc-action-modal {
    width: min(520px, 100%);
    max-height: min(680px, calc(100vh - 36px));
    overflow: auto;
    background: var(--modal-surface);
    color: var(--text);
    border: 1px solid var(--surface-500);
    border-radius: var(--radius-card);
    box-shadow: 0 24px 80px rgb(var(--c-shadow) / .52);
}

.bloc-action-modal--stop {
    border-color: rgb(var(--c-danger) / .42);
}

.bloc-action-modal-head {
    display: grid;
    grid-template-columns: 1fr auto;
    gap: 8px 12px;
    align-items: start;
    padding: 18px 20px 12px;
    border-bottom: 1px solid var(--surface-600);
}

.bloc-action-eyebrow {
    grid-column: 1;
    font-family: 'Overpass Mono', ui-monospace, monospace;
    font-size: 10px;
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--accent-secondary);
}

.bloc-action-title {
    grid-column: 1;
    margin: 0;
    font-size: 22px;
    line-height: 1.15;
    letter-spacing: 0;
}

.bloc-action-close {
    grid-column: 2;
    grid-row: 1 / span 2;
    width: 34px;
    height: 34px;
    border: 1px solid rgb(var(--c-modal-hairline) / .18);
    border-radius: var(--radius-control);
    background: transparent;
    color: var(--muted);
    cursor: pointer;
    font-family: 'Overpass Mono', ui-monospace, monospace;
    font-size: 14px;
}

.bloc-action-close:hover:not([disabled]),
.bloc-action-close:focus-visible {
    color: var(--text);
    border-color: var(--accent-secondary);
    outline: none;
}

.bloc-action-body {
    padding: 18px 20px 16px;
}

.bloc-action-headline {
    margin: 0 0 6px;
    font-size: 16px;
    font-weight: 700;
}

.bloc-action-copy {
    margin: 0;
    color: var(--muted);
    line-height: 1.55;
}

.bloc-action-plan {
    display: grid;
    gap: 8px;
    margin-top: 16px;
}

.bloc-action-step {
    display: grid;
    grid-template-columns: 52px 1fr;
    align-items: center;
    gap: 10px;
    padding: 9px 10px;
    border: 1px solid var(--surface-600);
    background: var(--surface-700);
    border-radius: var(--radius-control);
}

.bloc-action-step-time {
    font-family: 'Overpass Mono', ui-monospace, monospace;
    font-size: 11px;
    letter-spacing: .12em;
    text-transform: uppercase;
    color: var(--accent);
}

.bloc-action-step-text {
    color: var(--text);
    line-height: 1.35;
}

.bloc-action-footer {
    display: flex;
    justify-content: flex-end;
    gap: 10px;
    padding: 14px 20px 18px;
    border-top: 1px solid var(--surface-600);
    background: rgb(var(--c-modal-footer) / .72);
}

.bloc-action-btn {
    appearance: none;
    min-height: 38px;
    border: 1px solid var(--muted);
    border-radius: var(--radius-control);
    background: transparent;
    color: var(--text);
    padding: 8px 13px;
    cursor: pointer;
    font-family: 'Overpass Mono', ui-monospace, monospace;
    font-size: 11px;
    letter-spacing: .1em;
    text-transform: uppercase;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
}

.bloc-action-btn:hover:not([disabled]),
.bloc-action-btn:focus-visible {
    outline: none;
    border-color: var(--accent-secondary);
    color: var(--accent-secondary);
}

.bloc-action-btn--primary {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--ground);
    font-weight: 700;
}

.bloc-action-btn--primary:hover:not([disabled]),
.bloc-action-btn--primary:focus-visible {
    color: var(--ground);
    filter: brightness(1.08);
    border-color: var(--accent);
}

.bloc-action-btn--danger {
    background: var(--danger);
    border-color: var(--danger);
    color: var(--danger-ink);
}

.bloc-action-btn[disabled],
.bloc-action-close[disabled] {
    opacity: .5;
    cursor: wait;
}

.bloc-action-spinner {
    width: 12px;
    height: 12px;
    border: 2px solid rgb(var(--c-modal-surface) / .35);
    border-top-color: rgb(var(--c-modal-surface) / .95);
    border-radius: 50%;
    animation: bloc-action-spin .75s linear infinite;
}

.bloc-action-progress {
    position: relative;
    height: 4px;
    margin-top: 16px;
    overflow: hidden;
    background: rgb(var(--c-modal-hairline) / .12);
}

.bloc-action-progress::before {
    content: "";
    position: absolute;
    inset: 0 auto 0 0;
    width: 42%;
    background: var(--accent);
    animation: bloc-action-progress 1.1s ease-in-out infinite;
}

@keyframes bloc-action-spin {
    to { transform: rotate(360deg); }
}

@keyframes bloc-action-progress {
    0% { transform: translateX(-110%); }
    100% { transform: translateX(260%); }
}

@media (prefers-reduced-motion: reduce) {
    .bloc-action-spinner,
    .bloc-action-progress::before {
        animation: none;
    }
}

@media (max-width: 560px) {
    .bloc-action-modal-backdrop { align-items: end; padding: 12px; }
    .bloc-action-footer { flex-direction: column-reverse; }
    .bloc-action-btn { width: 100%; }
}

/* --- Flash container (mounted into by showFlash in server-actions.js) --- */

.bloc-flash-container { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; }
/* base.html renders the host on every signed-in page, so most of the time it
   is empty - and an empty flex box still spends its margin. On /dashboard that
   margin lands against `.bloc-dashboard`'s -1.5rem top margin (the negative
   trick that bleeds the ground into <main>'s padding) and nudges the whole
   page down 14px for nothing. Collapse it until it has something to say. */
.bloc-flash-container:empty { display: none; }
.bloc-flash {
    border: 1px solid;
    border-radius: var(--radius-control);
    padding: 10px 12px;
    font-size: 13px;
    font-family: 'Overpass Mono', ui-monospace, monospace;
}
.bloc-flash--error { border-color: var(--danger); color: var(--danger-text); background: rgb(var(--c-danger) / .08); }
.bloc-flash--success { border-color: var(--accent); color: var(--accent); background: rgb(var(--c-accent) / .08); }

/* --- Avatar fallback --------------------------------------------------
   The players and whitelist grids load skins from Crafatar. When that
   fetch fails, both used to swap the src for an inline SVG data-URI
   painting a flat square, and that square's colour was a stock Tailwind
   grey written as a percent-encoded hex literal inside the URI. The
   encoding is exactly why it survived every earlier pass of
   tests/test_token_contract.py while the panel went indigo around it;
   the test now reads that encoding too. The colour belongs here, where
   tokens.css can reach it. The onerror handler only adds this class and
   blanks the image, so Crafatar's happy path is untouched. */

.avatar-fallback {
    background: var(--surface-600);
}


/* ===================================================================
   The page primitives — spec §3.3, §3.5, §4.2.

   "Build these once, in shell.css and template partials, then use them
   everywhere. Anything defined ad hoc in a page template is a defect."
   That is the spec's own wording, and the eleven raw-Tailwind pages are
   what it was written about: four different card-heading styles, six
   copies of the same empty state, and every table drawing its own rules.

   They live in shell.css rather than dashboard.css because base.html
   links this file on EVERY signed-in page and links dashboard.css on
   five. A primitive the whole panel uses cannot be styled by a
   stylesheet most of the panel never loads — that mistake already
   shipped once, with the confirmation modal.
   =================================================================== */

/* --- The page head ---------------------------------------------------
   Every panel page leads with its own nav glyph and one heading. The
   glyph is the SAME one the sidebar lights for that page: `nav_icon` in
   _nav.html reads it out of ICON_PATHS by key, so renaming a glyph moves
   the sidebar and the page head together and neither can drift.

   The 22px comes down as an inline style from the macro's `size`
   parameter, not from a rule here, and that is deliberate. `.nav-icon`
   hardcodes 16px; a `.page-head .nav-icon { width: 22px }` rule would
   win on specificity over ANY size a caller passed, so the parameter
   would be decoration. Inline style beats both, which leaves exactly one
   thing deciding the box.

   Colour IS a rule, and it is a descendant selector on purpose: at
   (0,2,0) it beats a Tailwind utility whatever order the CDN's runtime
   <style> lands in. `.page-head { color }` would be (0,1,0), tie with the
   h1's own `text-surface-100`, and which one won would depend on the
   document. The h1 keeps its utilities; only the glyph is coloured here.
   -------------------------------------------------------------------- */
.page-head { display: flex; align-items: center; gap: 10px; }
.page-head .nav-icon { color: var(--surface-300); }

/* --- The one card heading --------------------------------------------
   Spec §3.3: "One heading style". The panel had four — `text-lg
   font-semibold text-white` (11 sites), `text-base font-semibold
   text-white` (2), `text-sm font-medium` muted (players' Server Info)
   and four more on the player-stats page — so a card's title told you
   which year it was written in rather than what it said.

   The values ARE Tailwind's `text-lg font-semibold`, written out: both
   halves of `text-lg`, because it sets font-size and line-height
   together and porting one without the other moves every card heading's
   baseline.

   The three modifiers are not decoration. "Update Available", "Warning"
   and "Up to Date" are STATES, which is the one thing spec §2 keeps the
   semantic families for. They are modifier classes rather than Tailwind
   utilities on the element because a utility and the base rule have the
   same specificity, and which one wins would depend on whether the
   Tailwind CDN's runtime <style> or this file came last in the document
   — the same trap the top-bar block above documents.
   -------------------------------------------------------------------- */
.card-head {
    font-size: 1.125rem;
    line-height: 1.75rem;
    font-weight: 600;
    color: var(--surface-100);
}
.card-head--warning { color: rgb(var(--c-warning-400)); }
.card-head--danger { color: rgb(var(--c-danger-400)); }
.card-head--accent { color: rgb(var(--c-bloc-400)); }

/* --- The empty state -------------------------------------------------
   Six copies of the same five lines across the panel: a card, a 40px
   glyph, a primary line and a muted second line. One block now.

   The icon's colour is a class, NOT a `.empty-state svg` descendant
   rule, and the difference is load-bearing: a descendant rule would be
   (0,1,1) and would silently beat the offline variant's own colour, so
   "the server is off" and "nobody is online" would draw the same
   picture. The modifier is a sibling class at equal specificity,
   declared after, so the cascade decides it here and not in the markup.

   `display: block` is stated rather than inherited from Tailwind's
   Preflight (`svg { display: block }`), because `margin: 0 auto` does
   nothing to an inline box and this block should not depend on a reset
   it does not own.
   -------------------------------------------------------------------- */
.empty-state {
    border-radius: var(--radius-card);
    background: var(--surface-800);
    border: 1px solid var(--surface-600);
    padding: 48px;
    text-align: center;
}
.empty-state-icon {
    display: block;
    width: 40px;
    height: 40px;
    margin: 0 auto;
    color: var(--surface-400);
}
/* The server is DOWN, which is not the same fact as an empty server, and
   the glyph is the only thing on the card that says which.

   danger-400, not danger-900. On the `.empty-state` card (surface-800) the
   900 step measured 1.07:1 — the glyph was drawn and could not be seen, so
   the one thing on the card that says down-vs-empty said nothing. 400
   measures 5.50:1 and keeps the semantic red; 500 would also clear the 3:1
   graphic floor at 3.86:1, and the wider margin wins for a glyph that IS
   the message. Pinned in tests/test_contrast_floors.py. */
.empty-state-icon--offline { color: rgb(var(--c-danger-400)); }
.empty-state-line { margin-top: 12px; font-size: 1.125rem; line-height: 1.75rem; color: var(--surface-300); }
.empty-state-hint { margin-top: 4px; font-size: .875rem; line-height: 1.25rem; color: var(--surface-300); }

/* --- Definition tables -----------------------------------------------
   Spec §3.5, generalised from `.bloc-crash-table` rather than reinvented
   (Ruling C6): that table is already the target treatment, and a second
   implementation of it would be a second opinion about the same pixel.

   Three things move with the generalisation. The separator is
   surface-500, the spec's hairline — the crash table draws surface-600
   today and comes across with it in Task 4. There is no separator under
   the last row: every Family-A table draws one, so each table ends in a
   line that separates it from nothing. And there is no zebra striping,
   because none exists anywhere in this panel and the spec forbids adding
   it.

   Column labels are surface-300, and that is a deliberate departure from
   the brief's surface-400. Measured on a surface-800 card: surface-300
   reads 5.58:1 and surface-400 reads 3.38:1, under the 4.5:1 floor the
   release gate applies to text. surface-400 is the scale's
   disabled/placeholder step; spec §3.5 names surface-300 for the label,
   and it is also what all six tables draw today, so the passing value is
   also the non-regressing one.

   NOTHING CONSUMES THIS YET. Task 4 moves the six table pages and the
   crash table onto it. It is defined here because Task 4 consumes it
   verbatim, and it is covered by a CSS-text pin in
   tests/test_shell_contract.py plus a synthetic render, which is all the
   evidence a primitive with no call site can have.
   -------------------------------------------------------------------- */
.def-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.def-table th {
    text-align: left;
    padding: 12px 14px;
    font-size: 11px;
    font-weight: 500;
    letter-spacing: .05em;
    text-transform: uppercase;
    color: var(--surface-300);
    border-bottom: 1px solid var(--surface-500);
}
.def-table td {
    padding: 14px;
    vertical-align: top;
    border-bottom: 1px solid var(--surface-500);
}
/* A ROW header pads like the cell beside it, not like a column header.
   `.def-table th` above pads `12px 14px` because it is styled for a `<thead>`
   eyebrow, and `td` pads `14px`, so in a definition row the label's content box
   started 2px higher than its own value's. Measured in a browser on the pack
   card, before and after: the th-minus-td content-box top delta was -2px on all
   four rows and is 0 on all four now.

   `14px`, matching `td` exactly, NOT `14px 12px`: the horizontal was ALREADY
   14px on both sides of both cells, so touching it would have moved the label
   2px sideways to fix a vertical offset — a second defect in payment for the
   first. Measured, not assumed.

   HONEST LIMIT, also measured: this aligns the content BOXES and does not move
   the painted label. `th` computes `vertical-align: middle` (the UA default)
   while `.def-table td` sets `top`, and these rows are 48px against a 16.5px
   label line box — so the label is centred in the row and its padding does not
   decide where its text lands. The label-to-value text-top delta measured 1.5px
   both before and after this rule. That residue is `vertical-align` plus the
   11px/13px font-size difference, not padding; changing it is a design decision
   about where a row label should sit and has no ruling behind it.

   Scoped to `tbody th[scope="row"]` so it reaches definition rows only. The
   Installed pack card is the sole consumer today: it has 6 tbody `<th>` and
   every other def-table page has 0, putting its headers in `<thead>`. */
.def-table tbody th[scope="row"] { padding: 14px; }

/* `th` as well as `td`. Both carry the hairline, so clearing only `td` leaves
   HALF a separator under the last row: a line under the label cell that stops
   dead where the value cell begins. It stayed invisible until Task 5 because
   every consumer until then put `<th>` in `<thead>` only — the Installed pack
   card is the first with `<th scope="row">` in tbody, and it is the card spec
   §3.5 names when it says "no separator on the last row". */
.def-table tr:last-child th,
.def-table tr:last-child td { border-bottom: 0; }
/* The right-aligned column header, and the reason it is a rule here rather
   than a bang in the markup. `.def-table th` sets `text-align: left` at (0,1,1)
   and Tailwind's `.text-right` is (0,1,0), so the bare utility loses no matter
   where the CDN's runtime <style> lands — measured in a browser on the rig: a
   `th.text-right` inside `.def-table` computed `left`. Nine headers across five
   pages carried `!text-right` to force it. Naming both classes here is (0,2,1),
   which beats the base rule on specificity rather than on `!important`, so the
   markup asks for its alignment the ordinary way.

   The `th` earns its place by SCOPE, not by specificity — `.def-table
   .text-right` would be (0,2,0) and would still outrank the base rule, but it
   would also catch every right-aligned CELL in these tables, which is a
   different decision from aligning a header. It is deliberately unscoped by
   page: five of the six tables render inside no `.bloc-*` scope at all, so a
   `.bloc-dashboard`-prefixed copy would reach exactly one of them. */
.def-table th.text-right { text-align: right; }
/* Spec §4.2: mono for data columns. `tabular-nums` is the other half —
   a mono FACE can still serve proportional figures from a variable font,
   and a column of timestamps that wobbles is the thing mono was for. */
.def-mono {
    font-family: 'Overpass Mono', ui-monospace, monospace;
    font-variant-numeric: tabular-nums;
}
/* Spec §3.5 puts the VALUE on the right. Same face, and the alignment
   stated separately so a left-aligned mono column does not have to opt
   out of one to get the other. */
.def-num {
    font-family: 'Overpass Mono', ui-monospace, monospace;
    font-variant-numeric: tabular-nums;
    text-align: right;
}
