/* Gump console — the application shell.
 *
 * STAGE 0 of the design-fidelity pass (2026-07-31). Every rule in the shell section below is
 * transplanted VERBATIM from the v5 design bundle (`project/screens/billing.html`, the most
 * complete shell frame — it is the only one that also draws `.shell-who`). Values were pulled
 * mechanically, not retyped, so they cannot drift from the source by transcription.
 *
 * The shell lives here rather than in the extracted per-page sheets for one hard reason: the
 * extractor emits it per screen, so `page-journey.css` and `page-pixel.css` — pages the design
 * never drew — carried NO shell at all, and `.shell-who` existed in only two of fourteen sheets.
 * A shell that is on every page has to be defined once, in a sheet every page loads.
 *
 * WHAT THE PRODUCT CHANGES, and why (the design is a review document, not a running app):
 *   - `.shell` is a bordered, fixed-height PREVIEW FRAME in the bundle (`--shell-preview-h`,
 *     820px here, eight different values across eleven files). The product is the viewport, so
 *     it takes the bundle's own `--shell-h: 100dvh` token and drops the frame border/radius.
 *     dvh over vh: on mobile Safari 100vh is the height with the URL bar hidden.
 *   - `container-type: inline-size` is KEPT, because the design's own pane-collapse is a
 *     container query and it stops working without it.
 *   - The rail's ITEM LIST is extrapolated. Every frame in the bundle draws a representative
 *     rail of three or four items (billing: Dashboard, Visitors, Billing; admin: Dashboard,
 *     Visitors, Content, Platform admin) — the design never draws all nine sections anywhere.
 *     The geometry, states and icon treatment are the design's; the nine items are ours.
 *   - The below-1024 behaviour is ours (see the note at the foot).
 */

/* The hidden attribute must always win. The UA default (display:none) loses to ANY author
   display rule - so `.banner { display:flex }` was rendering banners the pages had marked
   hidden (found by the 1.2 gate: an empty "Upgrade plan" banner showed permanently). This is
   the standard reset the extracted components.css does not carry. */
[hidden] { display: none !important; }

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

html, body {
  height: 100%;
  margin: 0;
  background: var(--canvas);
  color: var(--ink-body);
  font-family: var(--font-sans);
  font-size: var(--fs-md);
  line-height: var(--lh-md);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* ── shell ──────────────────────────────────────────────────────────────────── */
/* design: display:flex;flex-direction:column;height:var(--shell-preview-h);background:var(--canvas);
   border:1px solid var(--hairline);border-radius:var(--r-lg);overflow:hidden;container-type:inline-size */
.shell {
  display: flex; flex-direction: column;
  height: var(--shell-h, 100dvh);
  min-height: 0;
  background: var(--canvas);
  overflow: hidden;               /* the shell itself never scrolls — only .content does */
  container-type: inline-size;    /* the design's pane collapse is a @container query */

  /* Every property the review frame sets is RESET here, explicitly, rather than left undeclared.
     Twelve page sheets carry a second `.shell` rule that centres the frame in the review page —
     `width: min(100vw - 32px, 1280 + 56 + 2)`, `margin-left: calc(50% - …)`, plus the border and
     radius — and equal specificity means the page sheet wins whatever order it loads in for any
     property this rule does not mention. Undeclared is not neutral: it rendered the console as an
     820px-tall bordered card inset 51px from the left of a 1440px window. */
  width: 100%; margin: 0; border: 0; border-radius: 0;
}

.shell-head {
  height: var(--h-header); background: var(--surface);
  border-bottom: 1px solid var(--hairline);
  display: flex; align-items: center; justify-content: space-between;
  padding: 0 var(--sp-5); gap: var(--sp-5); flex-shrink: 0;
}
/* The design's brandmark is a <div> — it is a review frame, so it has nowhere to go. Ours is the
   link home, which means it needs the underline off and the ink stated: the shared `a` rule paints
   every anchor accent-coloured and underlined, so the wordmark rendered as underlined link text. */
.shell-brandmark {
  display: flex; align-items: center; gap: var(--sp-2);
  text-decoration: none; color: var(--ink);
}
.shell-logo {
  width: 26px; height: 26px; border-radius: var(--r-md);
  background: var(--accent-action); color: var(--accent-on-fill);
  display: grid; place-items: center;
  font-weight: var(--fw-bold); font-size: var(--fs-md);
}
.shell-name { font-size: var(--fs-md); font-weight: var(--fw-semibold); letter-spacing: var(--tr-lg) }
.shell-who {
  display: flex; align-items: center; gap: var(--sp-2);
  font-size: var(--fs-xs); color: var(--ink-quiet);
}
.shell-main { flex: 1; display: flex; min-height: 0 }

/* ── rail (56px, icon-only, aria-labelled) ──────────────────────────────────── */
.rail {
  width: var(--w-rail); background: var(--surface);
  border-right: 1px solid var(--hairline);
  display: flex; flex-direction: column; align-items: center;
  padding: 10px 0; gap: 3px; flex-shrink: 0;
}
.rail-item {
  width: 36px; height: 34px; border-radius: var(--r-sm);
  display: grid; place-items: center;
  color: var(--ink-quiet); text-decoration: none;
}
.rail-item:hover { background: var(--neutral-bg); color: var(--ink) }
.rail-item[aria-current="page"] { background: var(--accent-tint); color: var(--accent-text-on-tint) }
.rail-spacer { margin-top: auto }
/* Ours: the icon inside a rail item. The design's items contain a bare 16px <svg>; giving it a
   class lets the sign-out BUTTON match the anchors without duplicating their geometry. */
.rail-icon { width: 16px; height: 16px; flex: 0 0 auto }
button.rail-item { background: none; border: 0; padding: 0; cursor: pointer; font: inherit }
button.rail-item:hover { background: var(--neutral-bg); color: var(--ink) }

/* ── the rail expands (ours; the design's rail is icon-only and fixed) ──────── */
/* An icon-only rail with nine destinations asks the operator to learn nine glyphs. The design
   never had to answer that — it draws three or four items per frame and is a document, not
   something anyone navigates twice a day. Two additions, both requested by the owner:
     • every item carries a `title`, so hovering names it (markup, not CSS);
     • a toggle expands the rail to show the labels, and the choice is remembered.
   Collapsed is the default and remains pixel-identical to the design's 56px rail, so nothing
   above this point changes for anyone who never touches the control. */
.rail-label {
  display: none;
  font-size: var(--fs-sm); font-weight: var(--fw-medium);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Two selectors for one state, deliberately. `html[data-rail="open"]` is set by the blocking
   script in <head>, so the rail is already the right width at first paint; `.shell.is-rail-open`
   is set by app.js and is what the toggle flips at runtime. Without the first there is a visible
   width jump on every navigation; without the second the control does nothing until reload. */
:root[data-rail="open"] .rail,
.shell.is-rail-open .rail {
  width: var(--w-rail-open, 196px);
  align-items: stretch;
  padding: 10px var(--sp-3);
}
:root[data-rail="open"] .rail-item,
.shell.is-rail-open .rail-item {
  width: auto; height: 34px;
  display: flex; align-items: center; gap: var(--sp-3);
  padding: 0 10px;
}
:root[data-rail="open"] .rail-label,
.shell.is-rail-open .rail-label { display: block }
:root[data-rail="open"] .rail form,
.shell.is-rail-open .rail form { display: contents }

/* The toggle sits at the head's leading edge, over the rail column, so the control that changes
   the rail's width lines up with the thing it changes. */
.shell-headleft { display: flex; align-items: center; gap: var(--sp-2); min-width: 0 }

/* ── 46.8 · the screen's name and filters, beside the logo, when scrolled ──────────────────────
   The owner's own shape: a pipe after the wordmark, then "Dashboard: fliplink.me · 27 Jul 2026 –
   25 Aug 2026 · UTC-daily", smaller than the title it echoes.
   ⚠ IT MUST NOT PUSH THE IDENTITY OFF THE BAR. `.shell-head` is a flex row with the signed-in
   name at the other end; an un-truncated filter line on a narrow window would win the space and
   shove it out. `min-width: 0` on the left group plus an ellipsis here is what keeps the head one
   row at every width — the same pairing `.rank-label` needs, for the same reason. */
.headctx {
  display: flex; align-items: baseline; gap: var(--sp-2); min-width: 0;
  font-size: var(--fs-xs); color: var(--ink-quiet);
}
.headctx[hidden] { display: none }
.headctx-div { color: var(--hairline-strong); flex: 0 0 auto }
.headctx-name { color: var(--ink-body); font-weight: var(--fw-semibold); flex: 0 0 auto }
.headctx-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0 }
/* Below the pane-collapse width the head has no room for a second sentence; the screen's own
   heading is a scroll away and says the same thing. */
@media (max-width: 900px) { .headctx { display: none } }
.rail-toggle { flex: 0 0 auto }
.rail-toggle .rail-icon { transition: transform .12s ease }
:root[data-rail="open"] .rail-toggle .rail-icon,
.shell.is-rail-open .rail-toggle .rail-icon { transform: rotate(180deg) }
@media (prefers-reduced-motion: reduce) { .rail-toggle .rail-icon { transition: none } }

/* ── pane (204px secondary nav, per screen) ─────────────────────────────────── */
.pane {
  width: var(--w-pane); background: var(--surface);
  border-right: 1px solid var(--hairline);
  display: flex; flex-direction: column; flex-shrink: 0; overflow-y: auto;
}
.pane-title {
  padding: 14px 14px 8px;
  font-size: var(--fs-xs); letter-spacing: var(--tr-micro); text-transform: uppercase;
  color: var(--ink-quiet); font-weight: var(--fw-semibold); margin: 0;
}
.pane-list {
  list-style: none; margin: 0; padding: 0 var(--sp-3);
  display: flex; flex-direction: column; gap: 1px;
}
.pane-link {
  display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
  font-size: var(--fs-sm); font-weight: var(--fw-medium);
  border-radius: var(--r-sm); padding: 8px 10px;
  color: var(--ink-body); text-decoration: none;
}
.pane-link:hover { background: var(--neutral-bg); color: var(--ink) }
.pane-link[aria-current="page"] {
  background: var(--accent-tint); color: var(--accent-text-on-tint);
  font-weight: var(--fw-semibold);
}
.pane-count { font-family: var(--font-mono); font-size: var(--fs-micro); color: var(--ink-quiet) }
/* On the CURRENT row the count sits on the accent tint, not on the surface, and the design's
   quiet grey measures 4.32:1 there - under the 4.5 AA threshold for 10px text. Its sibling
   label already switches to the on-tint ink for exactly this reason; the count was the one
   child that kept the surface colour after its background changed underneath it. Measured by
   contrast-gate on /content, the first screen to render a pane count at all. */
.pane-link[aria-current="page"] .pane-count { color: var(--accent-text-on-tint) }
/* A pane item that OPENS something rather than navigating is a <button>, and a button carries
   the UA's border, background and centred text. Same treatment `button.rail-item` already gets
   in the rail: the element differs, the affordance must not. Content's "Image library" is the
   first of these - the design marks it as a panel precisely because it is not a route. */
/* pane-note — design v5 (visitors.html), verbatim. The pane's footer sentence. Emitted by the
   extractor into page-visitors.css only, and the pane is shell furniture used by more than one
   screen, so it belongs beside the rest of the pane rules. */
.pane-note {
  margin-top: auto; padding: var(--sp-4) 14px;
  border-top: 1px solid var(--hairline-soft);
  font-size: var(--fs-xs); line-height: 1.55; color: var(--ink-quiet);
}
button.pane-link {
  background: none; border: 0; font: inherit; cursor: pointer;
  text-align: left; width: 100%;
}

/* ── content ────────────────────────────────────────────────────────────────── */
.content {
  flex: 1; overflow-y: auto; min-width: 0;
  overscroll-behavior: contain;
  scrollbar-gutter: stable;      /* ours: no 15px shift when content grows past one screen */
  /* Pane anchors glide instead of jumping. It goes HERE, on the scroll container, because
     .content is what scrolls — the window never does, so scroll-behavior on :root or html would
     animate nothing. Doing it in CSS rather than with a click handler is deliberate: the anchor
     stays a real anchor, so the browser keeps the URL hash, back/forward, and — the part a JS
     preventDefault silently costs — moving the sequential focus point to the target, which is
     what stops a keyboard user tabbing from a pane link and landing back at the top of the page. */
  scroll-behavior: smooth;
}
/* Landing flush against the container's top edge reads as a clipped section. --sp-5 is the value
   the design already uses for exactly this (billing's .regionhead carries scroll-margin-top:
   var(--sp-5)), so both panes agree rather than each inventing an offset. Every pane target on
   both screens is a .card with an id, which is what makes this one rule enough. */
.content .card[id] { scroll-margin-top: var(--sp-5); }

/* Smooth scrolling is motion, and motion is something people switch off for real reasons —
   vestibular disorders among them. The OS setting must win; the anchor still works, it just
   arrives instantly. */
@media (prefers-reduced-motion: reduce) {
  .content { scroll-behavior: auto; }
}
/* The content column is CAPPED and CENTRED. `--w-content-max` (1280px) is the design's own
   token and this is the standard responsive shape: fill the column up to the cap, then sit in
   the middle of whatever is left.

   Recorded because I got here twice. The cap's value is exactly the design's frame width (1338)
   minus its rail (56) minus its borders, so inside the bundle it can never bite and every screen
   there shows content filling edge to edge. I read that as "the design means fill", removed the
   cap, and at 1920 every table stretched the full width - which the owner correctly called out
   as worse than the dead space it replaced. The token is a cap; the frame is just narrow enough
   that the bundle never demonstrates it.

   `max-width` is STATED rather than inherited: twelve page sheets carry their own
   `.content-inner` and shell.css loads last, so a property this rule omits is whatever the page
   sheet says, not neutral. `width: 100%` keeps it filling below the cap. */
/* ⚠ THE BLOCK BELOW IS HISTORY, NOT CSS — AND FOR A WHILE IT WAS BOTH. Its `/*` opener was lost
   when the inline quote `.content-inner { max-width: var(--w-content-max) }` was expanded into
   the full rule, which put the SUPERSEDED capped rule back on the live sheet and — because CSS
   error recovery consumes everything from the stray backtick to the next `{` — swallowed the
   `max-width: none` rule that replaces it, selector and all. Measured 2026-08-23: the parsed
   sheet carried `.content-inner { max-width: var(--w-content-max) }` and no second rule, so
   EVERY page sat at 1280px (dashboard/pixels/report x=341 w=1280, visitors x=443 w=1280) while
   the comment below claimed the cap was gone and said it had been measured. A dead rule and a
   live one had swapped places in silence.

.content-inner {
  padding: 20px 24px 40px;
  display: flex; flex-direction: column; gap: var(--sp-4);
  max-width: var(--w-content-max);
  width: 100%;
  margin-inline: auto;
}` reads like a readability cap, but the
   number gives it away: 1280px is exactly the design's own frame width (1338) minus its rail
   (56) minus its two borders. Inside the bundle the cap can never bite, so what every screen
   there actually shows is content FILLING its column edge to edge. Reproducing the rule instead
   of the result put ~600px of dead canvas to the right of every table on a 1920px monitor
   (owner, 2026-07-30); centring it merely split the same dead space in two.
   The design does cap for readability - on the PROSE, not the container: `.screen-sub` is
   76ch, `.banner-text` wraps, `.clamp2` truncates. Those are untouched, so long-line
   readability is still handled where the design handles it. */
.content-inner {
  /* 40.1 — named, because the visitors grid cancels it with a negative margin to run edge to edge.
     A literal `-24px` there would be a second copy of this number, free to drift the moment anyone
     adjusts the content padding, and nothing would look wrong until someone measured. */
  --pad-content: 24px;
  /* Named so the frozen head can pay back exactly what it borrows — see the note there. */
  --pad-content-top: 20px;
  padding: var(--pad-content-top) var(--pad-content) 40px;
  display: flex; flex-direction: column; gap: var(--sp-4);
  /* ⚠ CAPPED AGAIN AT 1560px IN REDESIGN PHASE 1, WHICH REVERSES 40.1 ON PURPOSE.
     HANDOFF.md §2.3: "Content column: max-width 1560px. Below that it is fluid; a wider display
     gains margin, not stretched cells." 40.1 removed the cap because the design's own number was
     1280 — the frame width minus its rail — and holding a table to 1280px left roughly 600px of
     dead canvas on a 1920 monitor, which the owner reported. 1560 is a different number, not the
     same decision retaken: at 1920 it leaves 296px beside the rail rather than 600, and the
     alternative it replaces is a 1,850px-wide row of seven columns.
     ⚠ STATED, not omitted. Twelve page sheets carry the design's own
     `.content-inner { max-width: var(--w-content-max) }`; a property this rule does not mention
     is not neutral, it is whatever the page sheet says. */
  max-width: var(--w-content-max);
}

/* Banners (quota, suppression, announcement) sit above the content and scroll with it —
   `div.app-banners` inside `.content-inner` is the design's own placement. */
.app-banners { display: flex; flex-direction: column; gap: var(--sp-3) }
.app-banners:empty { display: none }

/* ── the design's own pane collapse ─────────────────────────────────────────── */
/* Verbatim from the bundle, including the container query it is written against. Below the
   pane-collapse width the pane keeps its links but drops to rail width and hides its labels,
   titles and counts — so the secondary nav survives as icons instead of eating the content. */
@container (max-width: 1180px) {
  .pane { width: var(--w-rail); overflow: hidden }
  .pane-title, .pane-count { display: none }
  .pane-link { justify-content: center; padding: 8px 0 }
  .pane-link .pane-label { display: none }
  /* Ours: an expanded rail below the collapse width would take 196px from a content column the
     design has already decided is too narrow for a pane. The rail follows the pane down. */
:root[data-rail="open"] .rail,
  .shell.is-rail-open .rail { width: var(--w-rail); align-items: center; padding: 10px 0 }
  :root[data-rail="open"] .rail-item,
  .shell.is-rail-open .rail-item { width: 36px; display: grid; place-items: center; padding: 0 }
  :root[data-rail="open"] .rail-label,
  .shell.is-rail-open .rail-label { display: none }
}

/* ── skip link ──────────────────────────────────────────────────────────────── */
/* The design has no skip link. With a rail plus a pane before the content, a keyboard or
   screen-reader user otherwise traverses both on every page - and in a multi-page app that is
   every single navigation, not once per session as it was in the SPA. */
.skip-link {
  position: absolute; left: var(--sp-4); top: -100px; z-index: 100;
  padding: var(--sp-3) var(--sp-4);
  background: var(--surface); color: var(--ink);
  border: 1px solid var(--hairline); border-radius: var(--r-md);
  box-shadow: var(--shadow-raised);
}
.skip-link:focus { top: var(--sp-4); }

/* ── below the supported width (module 5.1, Decision 5) ─────────────────────── */
/* KEPT as ours rather than adopting the bundle's version, and the reason is concrete: the
   design hides the content at `@container (max-width:1000px)` while also declaring
   `.shell { min-width: var(--w-min-supported) }` (1024px). Together those mean the shell
   refuses to shrink and the PAGE scrolls sideways instead — which is the one thing
   breakpoint-gate exists to prevent. A fixed panel at the same 1024px floor says the same
   thing to the user without shearing the layout. The AUTH documents are exempt and responsive
   to 360px (auth.css); they never carry this shell. */
.too-narrow { display: none; }
@media (max-width: 1023px) {
  .too-narrow {
    display: grid; place-items: center; text-align: center;
    position: fixed; inset: 0; z-index: 200;
    background: var(--canvas); color: var(--ink-body);
    padding: var(--sp-6);
  }
  .too-narrow-inner { max-width: 36ch; display: flex; flex-direction: column; gap: var(--sp-3); }
  .too-narrow-title { font-size: var(--fs-lg); font-weight: var(--fw-semibold); color: var(--ink); }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}

/* ══ the always-visible footer (31.3) ═══════════════════════════════════════════════════════
   Feedback point 3: "The footer should always be visible. Currently, it only appears at the end
   of the scroll." There was no footer before this, so what the reader met after a long scroll was
   simply the end of the page.

   ⚠ THE SCROLLING MOVES INSIDE. `.content` is the scroll container, so a footer placed after it
   scrolls away with everything else. Making `.content` a column and letting only the inner part
   scroll pins the footer to the bottom of the viewport WITHOUT position:fixed — which would have
   floated it over the last row of every table, hiding the thing the reader had scrolled to reach. */
/* ⚠ THE FOOTER IS STICKY INSIDE THE SCROLLER, NOT A SECOND SCROLLER.
   The first attempt made `.content` `overflow-y: hidden` and handed the scrolling to
   `.content-inner`, which put the footer permanently below a scrolling column. It also broke
   every pane anchor in the console: the rule 130 lines above states that `.content` is what
   scrolls, and the scrollspy reads `.content`.scrollTop — which is 0 forever once .content
   cannot scroll. `screens-gate` caught it ("scrollTop 0"). So `.content` keeps the scrolling and
   the footer sticks to the bottom of the scrollport instead; the effect is identical and the
   invariant the rest of the shell is written against survives.
   `flex: 1 0 auto` on the inner column is what puts the footer at the BOTTOM of a short page
   rather than immediately under three lines of content. */
.content { display: flex; flex-direction: column; }
.content > .content-inner { flex: 1 0 auto; }

.app-foot {
  position: sticky; bottom: 0; z-index: 5;
  flex: 0 0 auto;
  display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4);
  padding: var(--sp-3) var(--sp-6);
  border-top: 1px solid var(--hairline);
  background: var(--surface);
  font-size: var(--fs-xs); color: var(--ink-quiet);
}

.app-foot-links { display: flex; gap: var(--sp-4); }
/* 217.23 — the bar sits on the line's own axis and never makes the footer taller than its text. */
.app-foot-quota { display: inline-flex; align-items: center; gap: var(--sp-3); min-width: 0; }
.app-foot .foot-meter { width: 120px; flex: none; height: 4px; margin: 0; }
.app-foot .foot-meter .meter-fill { height: 100%; }
.app-foot-links a { color: var(--ink-quiet); text-decoration: none; }
.app-foot-links a:hover { color: var(--accent-text); text-decoration: underline; }

/* ── the collapsed rail names itself, to the right ─────────────────────────────────────────────
   Owner, 2026-08-22: "can we have the right tooltip instead of title on the main menu". The
   native `title` takes about a second to appear, lands wherever the OS puts it, and is drawn in a
   font that belongs to no design.

   ⚠ THE TEXT COMES FROM `aria-label`, which every rail item already carries and which is the
   accessible name a screen reader reads. One source, so the bubble can never say something
   different from what the item announces — and the `title` attributes are removed, or both would
   show, a second apart, in two places.

   ⚠ CSS-ONLY IS SAFE HERE BECAUSE THE RAIL DOES NOT CLIP. Checked before writing it: `.rail` sets
   no overflow, so an absolutely positioned child escapes it. In a scrolling container this would
   have to be a positioned element in JavaScript instead.

   ⚠ AND IT KEEPS ITS DISTANCE FROM THE EXPANDED RAIL. When the labels are showing, the item says
   its own name — a bubble repeating it would be noise, so `content: none` switches it off. */
.rail-item { position: relative; }

.rail-item::after {
  content: attr(aria-label);
  position: absolute; left: calc(100% + 10px); top: 50%; transform: translateY(-50%);
  background: var(--surface-inverse); color: #fff;
  font-size: var(--fs-xs); line-height: 1.4; font-weight: var(--fw-medium);
  border-radius: var(--r-md); padding: 6px 9px;
  width: max-content; max-width: 220px; white-space: nowrap;
  box-shadow: var(--shadow-overlay); z-index: 70;
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease);
}
/* The little arrow, the same way `.tip-bubble` draws its own. */
.rail-item::before {
  content: ""; position: absolute; left: calc(100% + 5px); top: 50%; transform: translateY(-50%);
  border: 5px solid transparent; border-right-color: var(--surface-inverse);
  z-index: 70; opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease);
}
/* ⚠ `:focus-visible`, NOT `:focus` — a keyboard user gets the name, and a mouse click does not
   leave a bubble stuck open behind the pointer. */
.rail-item:hover::after, .rail-item:hover::before,
.rail-item:focus-visible::after, .rail-item:focus-visible::before {
  opacity: 1; visibility: visible;
}

.shell.is-rail-open .rail-item::after, .shell.is-rail-open .rail-item::before,
html[data-rail="open"] .rail-item::after, html[data-rail="open"] .rail-item::before {
  content: none;
}

/* 213.11 — a section this person's role cannot open: still in the rail, dimmed, with a lock over its icon. The label
   (and so the bubble) carries the reason. */
.rail-item[data-locked] { opacity: .55; cursor: not-allowed; }
.rail-item[data-locked]:hover { background: none; }
.rail-lock { position: absolute; right: 6px; bottom: 5px; width: 10px; height: 10px; color: var(--ink-quiet); }

/* Motion is a preference. The bubble still appears; it simply stops fading. */
@media (prefers-reduced-motion: reduce) {
  .rail-item::after, .rail-item::before { transition: none; }
}

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   FOCUS — redesign HANDOFF.md §2.2, added in Phase 0.

   Keyboard rings stay, mouse rings go. `:focus-visible` is what draws; the bare `*:focus` rule
   below is the belt to that braces, so an element that reaches focus by pointer never paints a
   ring even if some future sheet reaches for `:focus`.

   ⚠ WHAT THIS DOES **NOT** REACH, MEASURED RATHER THAN ASSUMED. 38 element-scoped
   `:focus-visible` rules live in the generated page sheets (`.bugshot:focus-visible`,
   `.switch input:focus-visible + .switch-track`, and so on). Those have (0,2,0) specificity and
   beat `*:focus-visible` (0,1,0) whatever the load order, so they keep their 2px ring until the
   phase that rewrites their screen retires them. They are NOT the wrong colour, because
   `--focus` is rebound to the accent in the token layer — only the width differs, 2px against
   1px. `demo/keyboard-gate.mjs` counts the survivors so the number can only go down.

   The handoff's premise for this section — "the existing product shows a 2px accent ring on
   MOUSE CLICK" — did not hold in this codebase when it was checked (2026-09-08): every ring was
   already `:focus-visible`, and `--focus` was #0A2540 navy rather than the accent. The rules are
   applied as written regardless; the complaint behind them is about the ring's weight and
   colour, and both change here.
   ══════════════════════════════════════════════════════════════════════════════════════════════ */
*:focus { outline: none; }

*:focus-visible {
  outline: 1px solid var(--color-accent-700, #0a6fa6);
  outline-offset: 1px;
}

/* A field says it has focus with its own border, not with a ring floating outside it, and never
   with a box-shadow halo — the halo is what made a focused input look like an error state. */
input:focus, textarea:focus, select:focus {
  outline: none;
  border-width: 1px;
  border-color: var(--color-accent-700, #0a6fa6);
  box-shadow: none;
}

button:focus-visible, a:focus-visible {
  outline: 1px solid var(--color-accent-700, #0a6fa6);
  outline-offset: 2px;
}

/* ⚠⚠ 221.2 (Changes 07 C2) — "MOUSE NEVER, KEYBOARD KEEPS", the owner's words. Chrome counts focus moved BY SCRIPT as
   keyboard focus, so a dropdown that puts focus on its button after a mouse choice (the website picker, the filter lists,
   the date range, the menus) drew the ring until the next click elsewhere. app.js marks the last input on the root —
   `data-input="pointer"` on a press, `keyboard` on a navigation key — and while the last input was the pointer, nothing
   draws a ring, whoever moved the focus. A Tab or an arrow key brings every ring back. Written with the plain focus
   pseudo-class, never the visible-focus one, so it adds nothing to the survivors `keyboard-gate` counts (that gate reads
   this sheet as text, so not even this comment may name the other one). */
:root[data-input="pointer"] *:focus,
:root[data-input="pointer"] *:focus + .switch-track,
:root[data-input="pointer"] :is(.segopt, .daybox, .assettile):has(:focus) { outline: none !important; }

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   THE THREE-GROUP SIDEBAR — redesign Phase 1 (HANDOFF.md §2.4, SPEC-SCREENS.md §0).

   Fourteen destinations in Analyze / Engage / Settings, then a utility block. The flat
   ten-item rail asked the operator to learn ten glyphs in one undifferentiated column; three
   groups turn that into three short lists with a stated subject.

   ⚠ COLLAPSED IS 64px AND EVERY CHILD CENTRES AT cx=32. `.rail` is `align-items: center`, so a
   36px item lands at 32 on its own — but a group HEADING is text, and text at 64px either
   overflows or drags the column off axis. The heading is therefore replaced by a hairline when
   the rail is collapsed rather than shrunk: the grouping stays visible, the axis stays true, and
   nothing is announced twice (the rule is decorative, and the group keeps its accessible name
   through `aria-labelledby` pointing at the heading, which is hidden from sight but not from the
   accessibility tree).
   ══════════════════════════════════════════════════════════════════════════════════════════════ */
.rail-group { display: flex; flex-direction: column; align-items: inherit; gap: 3px; width: 100% }
.rail-group + .rail-group { margin-top: var(--sp-3) }

.rail-group-head {
  margin: 0 0 2px;
  font-size: 10px; line-height: 1; letter-spacing: .06em; text-transform: uppercase;
  /* ⚠ #697386, not #8792a2. The paler grey measures 3.15:1 on white and is never text
     (HANDOFF.md §2.1); this one measures 4.78:1. A 10px label is the last place to economise. */
  color: var(--color-ink-sub, #697386);
  font-weight: var(--fw-semibold);
  padding: 0 10px;
}

/* Collapsed: the heading becomes a rule. `clip-path` rather than `display: none`, so the element
   stays in the accessibility tree and `aria-labelledby` still resolves to a real name. */
.rail:not(.is-open) .rail-group-head,
:root:not([data-rail="open"]) .rail .rail-group-head {
  height: 1px; margin: var(--sp-2) 0; padding: 0;
  width: 24px; align-self: center;
  background: var(--hairline);
  color: transparent; overflow: hidden;
  clip-path: inset(50%); position: static;
}
:root[data-rail="open"] .rail .rail-group-head,
.shell.is-rail-open .rail .rail-group-head {
  height: auto; width: auto; margin: 0 0 2px; padding: 0 10px;
  background: none; color: var(--color-ink-sub, #697386); clip-path: none; align-self: stretch;
}

/* The utility block sits at the foot, whatever the destinations above it come to. */
.rail-utility { margin-top: auto; padding-top: var(--sp-3); border-top: 1px solid var(--hairline) }

/* The account block: a mark on the axis when collapsed, three lines when open.
   ⚠ THE ROLE BADGE GOES ON ITS OWN ROW. Beside the lines it took the width and truncated the
   name to "Ad…" and the company to "De…" — a block whose whole job is to say which account this
   window is looking at, saying neither. The badge is worth keeping (an admin acting inside an
   account needs to see which hat they are wearing, and /admin is destructive), so the block wraps
   instead of competing. */
/* ⚠ THE HEIGHT OVERRIDE HAS TO MATCH `:root[data-rail="open"] .rail-item`, which sets 34px at
   (0,2,0). A bare `.rail-account { height: auto }` is (0,1,0) and loses, so the wrapped badge
   overflowed the row and printed itself across "Sign out". */
.rail-account { padding-block: 6px; flex-wrap: wrap; row-gap: 4px }
:root[data-rail="open"] .rail-account,
.shell.is-rail-open .rail-account { height: auto; align-items: flex-start }
.rail-avatar {
  width: 22px; height: 22px; border-radius: 999px; flex: 0 0 auto;
  display: grid; place-items: center;
  background: var(--accent-tint); color: var(--accent-text-on-tint);
  font-size: 10px; font-weight: var(--fw-semibold); text-transform: uppercase;
}
.rail-account-lines { display: none; flex-direction: column; gap: 1px; min-width: 0 }
.rail-account-name { font-size: var(--fs-sm); font-weight: var(--fw-medium); color: var(--ink);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis }
.rail-account-sub { font-size: 10px; line-height: 1.3; color: var(--color-ink-sub, #697386);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis }
.rail-account-sub > span + span::before { content: " · " }
:root[data-rail="open"] .rail-account-lines,
.shell.is-rail-open .rail-account-lines { display: flex }
/* The role badge is a word; at 64px there is nowhere to put it. */
.rail-account .badge { display: none }
:root[data-rail="open"] .rail-account .badge,
.shell.is-rail-open .rail-account .badge {
  display: inline-flex; margin-left: 30px;   /* clears the 22px mark + the row gap */
  font-size: 9px; letter-spacing: .04em;
}
/* Two lines and a badge need the row, so the name and company get all of it. */
:root[data-rail="open"] .rail-account-lines,
.shell.is-rail-open .rail-account-lines { flex: 1 1 auto }

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   THE FROZEN SCREEN HEAD — redesign Phase 1 step 5, and SPEC-NEW-FEATURES.md N05.

   ⚠ `.content` IS ALREADY THE SCROLL CONTAINER (`overflow-y: auto`), so this changes nothing
   about which element scrolls — which is the whole risk N05 names ("changing the scroll container
   breaks anything using the document scroller"). Keyboard paging, back-restores-scroll and the
   panel's Esc-restores-focus all keep working because none of them moves.

   ⚠ ONE MEASURED HEIGHT, NOT FOUR HARD-CODED NUMBERS (N05). Phase 3's column bar and pager hang
   off `--head-h`, set here and overridden by the screen if its head grows a second row. Four
   numbers that must agree is four chances to disagree, and the symptom — a column bar tucked one
   pixel under the head — is invisible until somebody scrolls.
   ══════════════════════════════════════════════════════════════════════════════════════════════ */
.content-inner > .screen-head {
  position: sticky; top: 0; z-index: 5;
  /* The head must be opaque or the rows slide visibly beneath it, and it must bleed to the
     container's padding on all four sides or a strip of them shows through around it. */
  background: var(--canvas);
  margin-inline: calc(var(--pad-content) * -1);
  /* ⚠ THE TOP PADDING IS CANCELLED BY AN EQUAL NEGATIVE MARGIN, AND THAT IS THE POINT. A plain
     `padding-top` moved every screen title down by 8px — but only on pages whose head is a DIRECT
     child of `.content-inner`, so /content's heading moved and /content-edit's did not, and
     `editor-layout` went red on "the heading sits exactly where every other page puts its
     heading". That check is the strongest one in this repo's collection: it compares two screens
     rather than naming a number, so it caught a divergence a threshold never would.
     Pulling the box up by the container's own top padding and paying it back as padding leaves
     the title on the same pixel it has always been on, while giving the frozen head a background
     that reaches the scrollport's top edge instead of leaving a 20px window under it. */
  margin-top: calc(var(--pad-content-top) * -1);
  padding: var(--pad-content-top) var(--pad-content) 0;
}

/* ⚠ NO HAIRLINE UNDER IT, AND NO BOTTOM PADDING — I ADDED BOTH AND THE ARITHMETIC SAYS NEITHER
   CAN STAY. `SPEC-NEW-FEATURES.md` N05 specifies exactly one thing here: `.screen-head
   { position: sticky; top: 0 }`. The rule and the 8px above it were mine, and every way of paying
   for them breaks one of two cross-screen invariants that both encode something the owner found
   by looking at a screen:
     · 39.5 (`dashboard-shell`) — every gap between the dashboard's bands is the SAME number. Add
       9px of furniture without paying it back and the first gap becomes 37 against 28.
     · 42.15 (`visitor-page`)  — the visitor page sits its first card under the heading like the
       dashboard does. Pay the 9px back with a negative margin and the head's BORDER BOX ends 9px
       lower, so that comparison reads dashboard 19px against visitor 28px.
   The two gates measure from different reference points, so any bottom furniture satisfies one and
   fails the other. The head is opaque, which is what actually hides the content sliding beneath
   it; a rule is decoration the spec did not ask for. So the geometry is now byte-identical to what
   it was before this phase, and the frozen head costs the layout nothing. If a rule is wanted
   under it, that is a visual decision for the phase that rebuilds these screens. */

/* ── the column bar and the pager, on the ONE measured head height (N05) ───────────────────────
   ⚠ `--head-h` IS MEASURED, NOT TYPED. The screen head is not a fixed height: it carries a title,
   a dateline and a control row, and a second chip row makes it taller. Four hard-coded offsets
   that must agree is four chances to disagree, and the symptom — a column bar tucked one pixel
   under the head, or floating one pixel below it — is invisible until somebody scrolls a long
   grid. app.js sets it once per screen from `getBoundingClientRect()` and again on resize.
   The fallback is the shell head's own height, so a screen whose JS has not run yet sticks in
   roughly the right place instead of at the top of the scroller. */
.content { --head-h: var(--h-header) }

/* ⚠⚠ THE STICKY COLUMN BAR IS NOT HERE, AND THE REASON IS WORTH THE PARAGRAPH — IT SHIPPED
   BROKEN FOR ABOUT AN HOUR AND TWELVE GATES CAUGHT IT.

   HANDOFF.md §3 Phase 1 step 5 asks for it on ONE screen: "On Visitors the column bar freezes
   beneath it and the pager pins to the bottom." I wrote it as `.content-inner .data thead th`,
   which is every table in the console, on the strength of ONE measurement taken on /visitors —
   where `div.table-wrap-scroll` (overflow-x: auto, so a scroll box on both axes) becomes the
   sticky containing block and the rule is inert. I generalised "inert here" to "inert", and
   that inference was the whole error: it is exactly this repo's recurring failure, verifying a
   proxy instead of the property.

   ⚠ ON EVERY OTHER TABLE IT PAINTED THE HEADER ROW ON TOP OF THE DATA. Measured on /sends: the
   wrapper is the sticky scrollport but it does not scroll vertically, so `top: var(--head-h)`
   simply pushed the header 34px DOWN from its own position — header at y=264 over a first row
   at y=231. Visibly broken on nine screens, and `locator.click` timed out on twelve gates with
   "<th>What happened</th> ... intercepts pointer events". A width or a z-index would not have
   shown it; the click did.

   So it belongs to **Phase 3**, with the grid it is specified for, on the day that phase removes
   the wrapper. Written down rather than left as a rule nobody dares delete. */

/* ⚠ AND WHATEVER SCROLLS INTO VIEW MUST CLEAR THE FROZEN HEAD. `scrollIntoView`, an `#anchor`
   and — the one that matters — moving keyboard focus to something below the fold all scroll the
   target to the TOP of `.content`, which is now exactly where the head is. Without this the
   focused row is under the head: the ring is invisible and the click lands on the head. It is a
   keyboard defect first and a test failure second. */
.content-inner > * { scroll-margin-top: calc(var(--head-h) + var(--sp-2)) }

/* The pager pins to the bottom of the scroller: a page holds fifty rows, and a control that is
   fifty rows away is a control nobody uses. */
.content-inner .card-foot[data-pager] {
  position: sticky; bottom: 0; z-index: 4;
  background: var(--surface);
  border-top: 1px solid var(--hairline);
}


/* ── the dashed tile for a location we could not recognise (redesign Phase 2, §1 + step 7) ────
   ⚠ A TILE, NOT A MISSING IMAGE, AND THE DIFFERENCE IS THE WHOLE REQUIREMENT. A blank where a
   flag should be reads as a bug — the reader assumes the picture failed to load and that the
   country IS known. A drawn, deliberately dashed box says the opposite: we looked, and there was
   nothing to find. Same argument as "Not sent" over an empty cell (42.16) and "Direct" over a
   missing referrer.
   Sized from `.flag`'s own box so the rows keep one baseline whichever they draw. */
.flag-unknown {
  flex: 0 0 auto;
  width: 1.15em; height: .82em;
  border: 1px dashed var(--color-ink-soft, #8792a2);
  border-radius: 2px;
  background: transparent;
}

/* ── Devices, operating systems, browsers: one bar list per card (redesign Phase 2, §1 item 5; three cards since 215.3)
   Replaces the three progress rings. A ring answers "which slice is biggest" and nothing else;
   a bar row carries its label, its figure AND its share on one line. */
.devicecol { min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2) }

/* label · bar · figure · share. The bar is the only flexible column, so the figures on the right
   line up down the list whatever the labels are. */
.devbar {
  display: grid; align-items: center; gap: var(--sp-2);
  grid-template-columns: minmax(0, 9em) 1fr auto auto;
  padding: 3px 0; font-size: var(--fs-sm);
}
.devbar-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink) }
.devbar-track { height: 6px; border-radius: 3px; background: var(--neutral-bg); overflow: hidden }
.devbar-fill { display: block; height: 100%; border-radius: 3px; background: var(--color-accent, #0b8fd3) }
.devbar-n { font-variant-numeric: tabular-nums; color: var(--ink) }
/* ⚠ #697386, never #8792a2 — the paler grey measures 3.15:1 on white and is never text (§2.1). */
.devbar-pct {
  font-variant-numeric: tabular-nums; color: var(--ink-quiet);
  min-width: 3.4em; text-align: right;
}

