/*
    One stylesheet, no framework, no CDN.

    The factory network may be restricted, and a page that loses its layout
    because a CDN is unreachable is worse than one that never depended on it.
    System fonts for the same reason.

    Colour is never the only signal: every badge carries its status as text, and
    every chart has a <details>-collapsed data table fallback. A colour-blind
    technician and a black-and-white printout must both be able to read this.

    Palette: a light, neutral "data powerhouse" theme -- rich and information-
    dense without tipping into flashy or consumer-app territory. A persistent
    dark-navy sidebar carries navigation; the content area stays light, white-
    panelled, with one blue accent and the same pass/fail/error/inconclusive
    status family as before. Monospace is reserved for actual data (IDs,
    hashes, technical values); every heading and label uses the system sans
    stack.

    ---- how this file is meant to be extended -------------------------------

    Everything structural reads from a token in :root -- colour, spacing, type
    size, radius, control height. A new component picks tokens; it does not
    invent a 13.5px or a 22px or a 7px radius of its own. That is the whole
    reason this file has scales at the top: before they existed the product had
    eighteen distinct font sizes, seven border radii and four button heights
    across five pages, and the result read as a collection of separately styled
    parts rather than one designed product. Adding a value to a scale is a
    deliberate act; reaching past the scales is what caused the drift.
*/

:root {
    /* ---- colour --------------------------------------------------------- */

    --page: #f6f5f3;
    --panel: #ffffff;
    --panel-raised: #f7f6f4;
    --line: #e7e4e1;
    --line-soft: #efece9;
    --ink: #201e1b;
    --muted: #6b665f;
    /* Darkened from #8a847b, which measured 3.7:1 against the panel and so
       failed AA for the small text that actually uses it. Checked against
       --page, not just --panel: the footer and the sign-in footnote sit on the
       page background, which is the tighter of the two -- 4.7:1 there and
       5.1:1 on a panel. Still lighter than --muted, so the hierarchy between
       secondary and tertiary text survives. */
    --muted-dim: #726d63;

    --accent: #3a5fc8;
    --accent-bright: #34549f;
    --accent-deep: #2c4788;
    --accent-soft: #e9eefb;

    --pass: #1f8a4c;
    --pass-line: #1f8a4c;
    --pass-bg: #e4f6ec;
    --fail: #c8372f;
    --fail-line: #c8372f;
    --fail-bg: #fbeae8;
    --error: #b3791f;
    --error-line: #b3791f;
    --error-bg: #faf1de;
    --inconclusive: #5850c4;
    --inconclusive-line: #5850c4;
    --inconclusive-bg: #edecf9;

    /* The sidebar carries its own dark palette -- everything else in the
       product stays light, so this set is scoped to .sidebar rather than
       joining the tokens above. */
    --sidebar-bg: #161c2b;
    --sidebar-raised: #212a3d;
    --sidebar-line: #2b3448;
    --sidebar-ink: #e6e9f1;
    --sidebar-muted: #99a1b5;
    --sidebar-active-bg: #29334c;
    --sidebar-active-ink: #ffffff;

    /* ---- type ------------------------------------------------------------
       Nine steps, each with a job. Half-pixel sizes (13.5px, 15.5px) are gone:
       they were never deliberate, they were the residue of nudging one
       component at a time, and side by side they read as sloppy rather than
       as hierarchy. */

    --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    --mono: ui-monospace, "SF Mono", Consolas, "Liberation Mono", monospace;

    --text-2xs: 11px;   /* table headers, micro labels, rail badge */
    --text-xs: 12px;    /* field labels, captions, badges, chips, legends */
    --text-sm: 13px;    /* table cells, secondary and supporting copy */
    --text-base: 14px;  /* interactive controls: buttons, inputs */
    --text-md: 15px;    /* body */
    --text-lg: 16px;    /* card headings, topbar title */
    --text-xl: 18px;    /* section headings between cards */
    --text-2xl: 20px;   /* standalone-page titles */
    --text-3xl: 24px;   /* page h1 */
    --text-4xl: 28px;   /* KPI figures */

    --weight-medium: 500;
    --weight-semibold: 600;
    --weight-bold: 700;

    --leading-tight: 1.25;
    --leading-normal: 1.5;
    --leading-relaxed: 1.6;

    /* ---- spacing ---------------------------------------------------------
       A 4px base. --space-5 (20px) is the page's single vertical rhythm step:
       the gap between any two top-level blocks in .main and the gap inside
       every grid, on both axes. Everything else is a deliberate exception with
       a rule of its own further down. */

    --space-1: 4px;
    --space-2: 8px;
    --space-3: 12px;
    --space-4: 16px;
    --space-5: 20px;
    --space-6: 24px;
    --space-8: 32px;
    --space-10: 40px;
    --space-12: 48px;

    /* ---- layout ---------------------------------------------------------- */

    --gutter: 28px;         /* the page's left/right inset, shared by topbar,
                               main and footer so all three align */
    --card-pad: 20px;
    --content-max: 1520px;
    --sidebar-w: 248px;
    /* Wide enough for a 10px nav caption under the icon. At 64px -- the width
       from when the rail showed icons alone -- "Quarantine" had nowhere to go.
       Still only a fifth of a 375px phone. */
    --sidebar-rail-w: 76px;
    --topbar-h: 64px;

    /* ---- shape -----------------------------------------------------------
       One radius scale, used everywhere, rather than each component picking
       its own -- a handful of different corner roundings side by side is what
       makes a page read as blocky even when each shape is fine alone. */

    --radius-xs: 4px;
    --radius-sm: 6px;
    --radius: 10px;
    --radius-lg: 14px;
    --radius-pill: 999px;

    /* Two steps only, and both are used: --shadow-sm for panels that sit in
       the page flow, --shadow-lg for the one that floats over it. A third,
       unused step is how a scale starts drifting. */
    --shadow-sm: 0 1px 2px rgba(20, 18, 14, .05);
    --shadow-lg: 0 4px 16px rgba(20, 18, 14, .08), 0 1px 3px rgba(20, 18, 14, .05);

    /* ---- controls --------------------------------------------------------
       Three heights, and no component sets its own. Before this, a submit
       button, a link styled as a button and the text input beside them
       rendered at 36px, 41px and 32px in the same filter row. */

    --control-h-sm: 30px;
    --control-h: 36px;
    --control-h-lg: 42px;

    --transition: .12s ease;

    /* Native controls (checkboxes, scrollbars, focus rings) follow the app's
       own palette rather than the browser's, and stay light even when the OS
       is in dark mode -- this product has one theme. */
    color-scheme: light;
    accent-color: var(--accent);

    scrollbar-color: #d4d0ca var(--page);
    scrollbar-width: thin;
}

* { box-sizing: border-box; }

body {
    margin: 0;
    background: var(--page);
    color: var(--ink);
    font-family: var(--sans);
    font-size: var(--text-md);
    line-height: var(--leading-normal);
    -webkit-font-smoothing: antialiased;
}

/* Form controls do not inherit the document font on their own: left alone,
   <button> and <input> render in the browser's UI font while an <a> styled as
   a button renders in the stack above, so two buttons sitting side by side in
   the same toolbar were visibly different typefaces. */
button, input, select, textarea { font: inherit; color: inherit; }

a { color: var(--accent); text-underline-offset: 2px; }
a:hover { color: var(--accent-bright); }

h1, h2, h3, h4 { font-family: var(--sans); }

::selection { background: var(--accent); color: #fff; }

:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-xs); }

::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--page); }
::-webkit-scrollbar-thumb { background: #d4d0ca; border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted-dim); }

/* ---- shell: sidebar + topbar + content ---------------------------------
   A left sidebar gives room for more sections than three flat links, and
   frees the top of each page for page-specific context instead of global nav.

   The sidebar is sticky: the phase list on a details page and the GPIO trace
   on a long run both scroll well past a screen, and navigation that scrolls
   away means going back to the top to get anywhere. The topbar is not -- see
   the note on it below. */

.shell { display: flex; min-height: 100vh; width: 100%; }

.sidebar {
    width: var(--sidebar-w);
    min-width: var(--sidebar-w);
    background: var(--sidebar-bg);
    color: var(--sidebar-ink);
    display: flex;
    flex-direction: column;
    border-right: 1px solid var(--sidebar-line);
    position: sticky;
    top: 0;
    height: 100vh;
    align-self: flex-start;
}

/* A blue ring on dark navy is close to unreadable; inside the sidebar the
   focus ring switches to the one colour guaranteed to contrast with it. */
.sidebar a:focus-visible { outline-color: #fff; }

/* Brand, nav icons and the footer note all start at the same 20px inset. The
   nav's own 8px padding plus the link's 12px is what puts its icons on that
   line -- before, the icons sat 4px right of the brand mark above them. */
.sidebar__brand {
    padding: var(--space-5) var(--space-5) var(--space-4);
    border-bottom: 1px solid var(--sidebar-line);
    display: flex;
    align-items: center;
    gap: var(--space-3);
    text-decoration: none;
    color: inherit;
    flex-shrink: 0;
}

.sidebar__mark {
    width: 28px;
    height: 28px;
    border-radius: var(--radius-sm);
    background: var(--accent);
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
}

.sidebar__mark i { width: 10px; height: 10px; background: #fff; border-radius: 2px; display: block; }

/* Named classes, not a bare `.sidebar__brand span`. That descendant selector
   also matched the mark -- which is a <span> -- and its display:block beat the
   mark's own display:flex on specificity, so the white square inside the logo
   was pinned to the top-left corner of the tile instead of centred in it. */
.sidebar__brand-text { display: block; min-width: 0; }
.sidebar__brand-name { display: block; font-size: var(--text-lg); font-weight: var(--weight-semibold); color: #fff; letter-spacing: -.01em; line-height: var(--leading-tight); }
.sidebar__brand-sub { display: block; font-size: var(--text-2xs); color: var(--sidebar-muted); letter-spacing: .04em; margin-top: 2px; line-height: var(--leading-tight); }

.sidebar__nav {
    flex: 1;
    padding: var(--space-3) var(--space-2);
    display: flex;
    flex-direction: column;
    gap: 2px;
    overflow-y: auto;
}

.sidebar__nav a {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-2) var(--space-3);
    min-height: var(--control-h);
    border-radius: var(--radius-sm);
    text-decoration: none;
    font-size: var(--text-sm);
    font-weight: var(--weight-medium);
    color: var(--sidebar-muted);
    position: relative;
    transition: background-color var(--transition), color var(--transition);
}

.sidebar__nav a svg { flex-shrink: 0; opacity: .85; }
.sidebar__nav a .sidebar__label { flex: 1; min-width: 0; }

.sidebar__nav a:hover { background: var(--sidebar-raised); color: var(--sidebar-ink); }

.sidebar__nav a.is-current {
    background: var(--sidebar-active-bg);
    color: var(--sidebar-active-ink);
    font-weight: var(--weight-semibold);
}

.sidebar__nav a.is-current svg { opacity: 1; }


.sidebar__foot {
    padding: var(--space-3) var(--space-5);
    border-top: 1px solid var(--sidebar-line);
    font-size: var(--text-2xs);
    color: var(--sidebar-muted);
    flex-shrink: 0;
}

.content { flex: 1; min-width: 0; display: flex; flex-direction: column; }

/* Deliberately not sticky, unlike the sidebar. A bar pinned across the top of
   the content column overlays whatever sits at the top of the viewport, which
   breaks click targets that a browser has just scrolled into view. It carries
   the page title and sign-out; the <h1> below repeats the title anyway, so
   there is little to keep on screen and a real hazard in keeping it. */
.topbar {
    height: var(--topbar-h);
    flex-shrink: 0;
    border-bottom: 1px solid var(--line);
    background: var(--panel);
}

/* The bar spans the viewport; its contents sit in the same centred, capped
   column as the page below it. Without this inner wrapper the topbar title was
   pinned to the left edge while the <h1> naming the same page sat wherever the
   centred content column began -- 16px apart on a 1800px monitor and further
   on anything wider. */
.topbar__inner {
    height: 100%;
    max-width: var(--content-max);
    margin: 0 auto;
    padding: 0 var(--gutter);
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-4);
}

.topbar__title { font-size: var(--text-lg); font-weight: var(--weight-semibold); color: var(--ink); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.topbar__user { display: flex; align-items: center; gap: var(--space-3); flex-shrink: 0; }

.topbar__avatar {
    width: 30px;
    height: 30px;
    border-radius: var(--radius-pill);
    background: var(--accent-soft);
    color: var(--accent);
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: var(--text-xs);
    font-weight: var(--weight-bold);
    flex-shrink: 0;
    letter-spacing: .02em;
}

.topbar__who { font-size: var(--text-sm); color: var(--ink); font-weight: var(--weight-medium); white-space: nowrap; }
.topbar__role { color: var(--muted); font-weight: 400; }

.topbar__user form { margin: 0; display: flex; }

.main {
    flex: 1;
    width: 100%;
    /* margin: 0 auto is load-bearing, not decorative -- without it a max-width
       block with no auto margins sticks to the start edge of its flex
       container, so on any monitor wider than the sidebar plus --content-max
       the page reads as shoved into the top-left corner with a dead gap down
       the right side. */
    max-width: var(--content-max);
    margin: 0 auto;
    padding: var(--space-6) var(--gutter) var(--space-8);
}

/* ---- the page's vertical rhythm ----------------------------------------
   One rule, one step. Every top-level block in .main is spaced by
   --space-5, and no block carries a bottom margin of its own -- which is what
   used to produce gaps of 8, 12, 15, 16, 18.7, 20, 22 and 24px down a single
   page, and a grid whose rows sat 32px apart while its columns sat 16px apart.

   The exceptions below are deliberate pairings, not tuning: a breadcrumb
   belongs to the heading under it, consecutive notices are one group, and a
   section heading needs more air above it than below.

   Written as :not(:first-child) rather than * + * on purpose. The two select
   the same elements, but * + * ties on specificity with any component that
   sets a margin of its own -- .notice does -- and whichever rule happens to
   appear later in this file then wins. That is not a rhythm, it is a
   coincidence. :not(:first-child) outranks a single class, so the page's
   spacing is decided here and a component cannot silently opt out of it. */

.main > * { margin-top: 0; margin-bottom: 0; }
.main > :not(:first-child) { margin-top: var(--space-5); }

.main > .page__head + * { margin-top: var(--space-6); }
.main > .section-title { margin-top: var(--space-8); }
.main > .section-title + * { margin-top: var(--space-4); }
.main > .breadcrumb + .page__head { margin-top: var(--space-2); }
.main > .notice + .notice { margin-top: var(--space-3); }

.page__eyebrow {
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
    letter-spacing: .06em;
    color: var(--muted);
    text-transform: uppercase;
    line-height: var(--leading-tight);
}

.page__head h1 {
    font-weight: var(--weight-semibold);
    font-size: var(--text-3xl);
    line-height: var(--leading-tight);
    letter-spacing: -.01em;
    margin: var(--space-2) 0 0;
    /* The badge a details page puts inside its h1 is an inline-flex box; on a
       text baseline it hangs low against a 24px heading. */
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    /* Roughly a word space at this size. The flex gap replaces the collapsed
       whitespace between the heading's parts, so anything larger reads as a
       double space between the title and the identifier after it. */
    gap: var(--space-2);
}

.page__eyebrow + h1, .breadcrumb + h1 { margin-top: var(--space-1); }

/* A measure, not the full 1520px. A one-line description stretched across a
   wide monitor is hard to read and leaves the page looking half-empty. */
.page__head p { margin: var(--space-2) 0 0; color: var(--muted); max-width: 72ch; }

/* Trailing footnotes -- the "ordered by received, not test date" kind of
   remark several pages close with. Matched on .muted rather than on p alone:
   a bare `.main > p` also caught the breadcrumb and, worse, every top-level
   <p class="notice">, which greyed a warning down to secondary text and cut it
   to a measure narrower than the cards it sits above. */
.main > p.muted { color: var(--muted); font-size: var(--text-sm); max-width: 84ch; }

.page__toolbar { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.page__toolbar--split { justify-content: space-between; }
.page__toolbar-meta { font-size: var(--text-xs); color: var(--muted); }

.section-title { font-size: var(--text-xl); font-weight: var(--weight-semibold); line-height: var(--leading-tight); letter-spacing: -.01em; }

.breadcrumb { color: var(--muted); font-size: var(--text-sm); display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.breadcrumb a { color: var(--muted); }
.breadcrumb a:hover { color: var(--accent); }

.footer { border-top: 1px solid var(--line); color: var(--muted-dim); font-size: var(--text-xs); }

.footer__inner {
    max-width: var(--content-max);
    margin: 0 auto;
    padding: var(--space-5) var(--gutter);
}

.footer p { margin: 0; max-width: 84ch; }

.muted { color: var(--muted); }
.mono { font-family: var(--mono); }

/* Read by a screen reader, absent from the screen and from a printout. The
   sidebar rail hides its brand text the same way inline; this is the shared
   utility for the cases that are not the rail. Never display:none, which takes
   the text out of the accessibility tree along with the layout. */
.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
}

/* A sortable column header. Only one column is -- see ListOrder for why -- so
   this stays a link styled to sit inside a <th> rather than a general
   sorting-affordance system for a table that cannot use one. The arrow is
   aria-hidden and a visually-hidden sentence carries the same fact in words,
   because a glyph alone is not a state a screen reader can announce. */
.data__sort {
    /* Positioned so the visually-hidden sentence inside it has a containing
       block here rather than the initial one. Without this the span -- which is
       position:absolute -- is laid out against the viewport at its static x,
       which inside a horizontally scrolled table is several hundred pixels off
       the right-hand edge. It escapes .table-scroll's clipping and drags the
       document's scrollWidth out with it, so the whole page scrolls sideways on
       a phone because of a 1px element nobody can see. */
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    color: inherit;
    text-decoration: none;
    white-space: nowrap;
}

.data__sort:hover { color: var(--accent); text-decoration: underline; }

/* A factory-floor tablet is as narrow as this gets. Below that, a 248px
   sidebar sitting next to any real content forces horizontal scroll, so it
   collapses to an icon-only rail instead of reflowing to a top bar -- that
   keeps every nav target one tap away without the page ever losing its
   left-hand orientation. */
@media (max-width: 760px) {
    :root { --gutter: 16px; --card-pad: 16px; }

    .sidebar { width: var(--sidebar-rail-w); min-width: var(--sidebar-rail-w); }
    /* Positioned for the same reason as .data__sort: the brand text below is
       visually hidden with position:absolute and needs a containing block that
       is not the viewport. */
    .sidebar__brand { padding: var(--space-4) 0; justify-content: center; position: relative; }

    /* Hidden from the eye, kept for the accessible name. `display: none` here
       left the brand link -- and, below, every nav link -- announcing as a bare
       "link" with only a URL, because the icons are aria-hidden and nothing
       else inside carried text. */
    .sidebar__brand-text {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip-path: inset(50%);
        white-space: nowrap;
        border: 0;
    }

    .sidebar__nav { padding: var(--space-3) var(--space-1); }

    /* Icon over caption, not icon alone.

       The rail used to hide the labels outright, which left six abstract
       glyphs -- two of them near-identical bar shapes -- with no text, no
       title and no accessible name. A tooltip would not have fixed it: the
       nav scrolls, so anything escaping the rail is clipped, and a hover
       tooltip never appears on the factory tablet this breakpoint exists for.
       A 10px caption fits, survives a screen reader, and is readable by touch.

       The one word that does not fit on a line wraps to two, which is why the
       rail is sized for it rather than the labels being abbreviated into a
       second set of names nobody was taught. */
    .sidebar__nav a {
        flex-direction: column;
        justify-content: center;
        gap: 2px;
        padding: var(--space-2) 2px;
        min-height: 0;
        text-align: center;
    }

    .sidebar__nav a .sidebar__label {
        flex: none;
        font-size: 10px;
        line-height: 1.15;
        letter-spacing: -.01em;
    }

    .sidebar__foot { display: none; }


    .main { padding: var(--space-5) var(--gutter) var(--space-6); }
    .topbar__who { display: none; }
}

/* ---- cards -------------------------------------------------------------
   A card owns its inside and nothing outside it: no margin, because the page
   rhythm above and the grid gaps below already decide how far apart cards sit,
   and a card that also carried 16px of its own produced 32px between grid rows
   against 16px between grid columns.

   It is a flex column so a trailing action (.card__foot) can be pushed to the
   bottom edge, which is what keeps the shorter of two side-by-side cards from
   ending in a pool of dead white space. */

.card {
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: var(--shadow-sm);
    padding: var(--card-pad);
    display: flex;
    flex-direction: column;
    min-width: 0;
}

.card > * { margin-top: 0; margin-bottom: 0; }
.card > :not(:first-child) { margin-top: var(--space-4); }

.card__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    flex-wrap: wrap;
}

.card__head h2, .card__head h3 { margin: 0; font-weight: var(--weight-semibold); font-size: var(--text-lg); line-height: var(--leading-tight); }

/* A subtitle belongs to the heading above it, so it sits tight against it and
   the normal step falls after the pair instead of between its halves. */
.card > .card__head + .card__subtitle { margin-top: var(--space-1); }
.card__subtitle { font-size: var(--text-xs); color: var(--muted); max-width: 72ch; }

/* Pushed to the bottom of the card rather than left floating in the middle of
   it when a sibling card in the same grid row is taller. */
.card > .card__foot { margin-top: auto; padding-top: var(--space-4); }

/* The unit beside a KPI figure. The cards count devices and the trend line
   beneath counts runs, and without the word on the figure itself the two read
   as one number disagreeing with itself. Baseline-aligned to the figure so it
   reads as an annotation rather than as a second value. */
.kpi-card__unit {
    align-self: baseline;
    margin-left: var(--space-1);
    font-size: var(--text-xs);
    color: var(--muted);
    white-space: nowrap;
}

.card__note { font-size: var(--text-sm); color: var(--muted); max-width: 84ch; }

.card h3 { font-size: var(--text-lg); font-weight: var(--weight-semibold); line-height: var(--leading-tight); }

/* A second section inside one card -- "Serial loopback" under the GPIO trace.
   Slightly more air above it than the standard step, so it reads as a new
   subsection rather than as the next paragraph. */
.card > .card__section { margin-top: var(--space-6); }

.card ul, .card ol { padding-left: var(--space-5); }
.card li + li { margin-top: var(--space-2); }

/* ---- KPI cards ----------------------------------------------------------
   Not just a number: a sparkline (real recent history where one exists) and
   a trend label give a card something to say, not just something to show. */

.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(210px, 100%), 1fr)); gap: var(--space-5); }

.kpi-card {
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: var(--shadow-sm);
    padding: var(--card-pad);
    min-width: 0;
}

.kpi-card__label {
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
    color: var(--muted);
    text-transform: uppercase;
    letter-spacing: .04em;
    line-height: var(--leading-tight);
}

/* Figure and sparkline share this row; the trend line sits under it with the
   whole card to itself. Nested inside the row it had to share the width with a
   64px sparkline, which wrapped "14 runs vs yesterday" onto two lines and made
   Import Health's full explanatory sentence read as a cramped column. */
.kpi-card__row { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-3); margin-top: var(--space-3); }

/* line-height 1 clipped the digits' own box: at 28px the glyphs need 32px, and
   the browser reported the value overflowing its line box on every card. */
.kpi-card__value { font-size: var(--text-4xl); font-weight: var(--weight-semibold); color: var(--ink); letter-spacing: -.02em; line-height: var(--leading-tight); font-variant-numeric: tabular-nums; }
.kpi-card__value.muted { color: var(--muted); }

/* No white-space: nowrap here on purpose. Most trend text is short ("No
   change", a delta), but Import Health reuses this same card for a full
   explanatory sentence when nothing has been polled yet -- nowrap on that
   would force the card wider than its grid siblings instead of wrapping. */
.kpi-card__trend { font-size: var(--text-xs); margin-top: var(--space-2); font-weight: var(--weight-semibold); line-height: var(--leading-normal); }
.kpi-card__trend--up { color: var(--pass); }
.kpi-card__trend--down { color: var(--fail); }
.kpi-card__trend--flat { color: var(--muted); }
/* Shrinkable rather than fixed: on a narrow tile a rigid 64px sparkline pushes
   the figure beside it past the card's edge. It scales instead. */
.kpi-card__spark { flex: 0 1 64px; min-width: 0; }
.kpi-card__spark svg { display: block; width: 100%; max-width: 64px; height: auto; }

/* The status accent is an inset shadow rather than a thicker border. As a
   3px border it made the bordered cards' content box 2px shorter than their
   plain neighbours', so in a row of six the labels on "Passed" and "Failed"
   sat 2px below the label on "Total devices". */
.kpi-card--pass { box-shadow: inset 0 3px 0 var(--pass-line), var(--shadow-sm); }
.kpi-card--fail { box-shadow: inset 0 3px 0 var(--fail-line), var(--shadow-sm); }
.kpi-card--error { box-shadow: inset 0 3px 0 var(--error-line), var(--shadow-sm); }
.kpi-card--inconclusive { box-shadow: inset 0 3px 0 var(--inconclusive-line), var(--shadow-sm); }

/* ---- badges ------------------------------------------------------------ */

.badge {
    display: inline-flex;
    align-items: center;
    vertical-align: middle;
    gap: var(--space-1);
    padding: 3px 9px;
    border-radius: var(--radius-pill);
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
    line-height: var(--leading-normal);
    letter-spacing: .01em;
    border: 1px solid transparent;
    white-space: nowrap;
}

.badge__icon { flex: none; }

.badge--pass { color: var(--pass); background: var(--pass-bg); border-color: var(--pass-line); }
.badge--fail { color: var(--fail); background: var(--fail-bg); border-color: var(--fail-line); }
.badge--error { color: var(--error); background: var(--error-bg); border-color: var(--error-line); }
.badge--inconclusive { color: var(--inconclusive); background: var(--inconclusive-bg); border-color: var(--inconclusive-line); }
.badge--unknown { color: var(--muted); background: var(--panel-raised); border-color: var(--line); }

/* Chips sit in a flex row that owns the gap. They used to carry their own
   `margin: 0 4px 4px 0`, which left an uneven 4px hanging below the last row
   of every group. */
.chips { display: flex; flex-wrap: wrap; gap: var(--space-2); }

.chip {
    display: inline-flex;
    align-items: center;
    padding: 2px 9px;
    border-radius: var(--radius-pill);
    background: var(--panel-raised);
    border: 1px solid var(--line);
    color: var(--muted);
    font-size: var(--text-xs);
    line-height: var(--leading-normal);
    white-space: nowrap;
}

.chip--failing { background: var(--fail-bg); border-color: var(--fail-line); color: var(--fail); font-weight: var(--weight-semibold); }

/* What changed between one run of a device and the one before it. The same
   status family as every other badge on the site, deliberately: a recovery is
   green because PASS is green, a regression is red because FAIL is red, and a
   reader who has learnt the colours once should not have to learn a second
   vocabulary for this row. Neutral for a change that is neither -- a firmware
   bump is a fact, not a verdict, and colouring it would invent one. */
.chip--recovered { background: var(--pass-bg); border-color: var(--pass-line); color: var(--pass); font-weight: var(--weight-semibold); }
.chip--regressed { background: var(--fail-bg); border-color: var(--fail-line); color: var(--fail); font-weight: var(--weight-semibold); }
.chip--changed { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-deep); font-weight: var(--weight-medium); }

/* ---- tables ------------------------------------------------------------
   A table bleeds to the card's inner edge and pays the card's padding back as
   padding on its first and last cells. That way the header band spans the card
   the way a band should, and the first column's text starts on exactly the
   same line as the card's heading -- which it did not when the table sat
   inside the card's padding and added 14px of its own on top. */

.table-scroll { overflow-x: auto; scrollbar-width: thin; }

.card > .table-scroll,
.card > details > .table-scroll { margin-inline: calc(var(--card-pad) * -1); }

table.data { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }

table.data th {
    text-align: left;
    padding: var(--space-2) var(--space-3);
    background: var(--panel-raised);
    color: var(--muted);
    font-size: var(--text-2xs);
    font-weight: var(--weight-semibold);
    letter-spacing: .05em;
    text-transform: uppercase;
    border-bottom: 1px solid var(--line);
    white-space: nowrap;
}

table.data td { padding: var(--space-3); border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
table.data tr:last-child td { border-bottom: none; }
table.data tbody tr:hover { background: var(--panel-raised); }

.card > .table-scroll th:first-child,
.card > .table-scroll td:first-child,
.card > details > .table-scroll th:first-child,
.card > details > .table-scroll td:first-child { padding-left: var(--card-pad); }

.card > .table-scroll th:last-child,
.card > .table-scroll td:last-child,
.card > details > .table-scroll th:last-child,
.card > details > .table-scroll td:last-child { padding-right: var(--card-pad); }

table.data a { font-weight: var(--weight-medium); }

/* ---- notices ----------------------------------------------------------- */

.notice {
    margin: 0;
    padding: var(--space-3) var(--space-4);
    border-radius: var(--radius-sm);
    border-left: 3px solid var(--muted);
    background: var(--panel-raised);
    font-size: var(--text-sm);
    line-height: var(--leading-relaxed);
}

.notice--identity { border-left-color: var(--fail-line); background: var(--fail-bg); }
.notice--clock { border-left-color: var(--error-line); background: var(--error-bg); }
.notice--divergence { border-left-color: var(--inconclusive-line); background: var(--inconclusive-bg); }
.notice--truncated { border-left-color: var(--error-line); background: var(--error-bg); }

.notice strong { font-weight: var(--weight-semibold); }

/* asp-validation-summary renders a bare <ul>, which arrives with the browser's
   40px indent and its own block margins -- inside a notice that reads as a
   misaligned bullet floating in a coloured box. */
.notice ul { margin: 0; padding-left: var(--space-5); }
.notice li + li { margin-top: var(--space-1); }
.notice > :last-child { margin-bottom: 0; }

/* asp-validation-summary always renders the element; ASP.NET Core only
   toggles this class on and off to say whether it currently holds an error.
   Without this rule the login form's validation-summary div -- styled as a
   notice so a real error stands out -- shows as an empty red bar on every
   ordinary visit, not just a failed one. */
.validation-summary-valid { display: none; }

/* ---- definition rows --------------------------------------------------- */

/* No column-gap. Each cell draws its own bottom border, so a gap between the
   columns punches a hole straight through the middle of every row's rule --
   the separation has to come from the label's padding instead, which leaves
   the two borders touching as one continuous line. */
dl.facts { margin: 0; display: grid; grid-template-columns: minmax(120px, 200px) 1fr; column-gap: 0; }

dl.facts dt {
    padding: var(--space-2) var(--space-4) var(--space-2) 0;
    color: var(--muted);
    font-size: var(--text-sm);
    border-bottom: 1px solid var(--line-soft);
}

dl.facts dd {
    margin: 0;
    padding: var(--space-2) 0;
    text-align: right;
    font-weight: var(--weight-medium);
    font-size: var(--text-sm);
    border-bottom: 1px solid var(--line-soft);
    min-width: 0;
    /* anywhere, not break-word. Only `anywhere` shrinks the value's
       min-content contribution, and without that the 1fr grid track cannot
       narrow past a 32-character content hash -- which pushed the whole
       details page 86px wider than a 480px tablet and scrolled it sideways. */
    overflow-wrap: anywhere;
}

dl.facts dt:last-of-type, dl.facts dd:last-of-type { border-bottom: none; }

/* A hash or a source key is long, and at body size it wrapped with a single
   orphaned character on the second line. One step down in the mono scale is
   enough for these to sit on one line in a half-width card. */
dl.facts dd .mono, dl.facts dd.mono { font-size: var(--text-xs); }

/* A fact list stacks on the width of the CARD it sits in, not the width of the
   window.

   The viewport rule below was the only one, and it left a real gap: the
   Provenance card is half-width inside .grid-2, so between roughly 900px and
   1100px the window was far too wide to trigger it while the value column was
   about 150px -- and overflow-wrap:anywhere shredded a source key into four
   lines broken mid-word, "...afc7d0fe- / 3035-4534-ac09- / 44a1bdc7dcf0_fi /
   nal.json". Unreadable, and not fixable by any viewport breakpoint, because
   the viewport was never what was too narrow.

   Progressive enhancement on purpose: a browser without container queries
   ignores this block entirely and still gets the viewport rule underneath, so
   the worst case is the behaviour that shipped before. Only the cards that
   hold a fact list opt in -- container-type brings layout containment with it,
   and there is no reason to impose that on every card in the product. */
.card--facts { container-type: inline-size; }

@container (max-width: 420px) {
    dl.facts { grid-template-columns: minmax(0, 1fr); }
    dl.facts dt { padding: var(--space-2) 0 0; border-bottom: none; }
    dl.facts dd { text-align: left; padding: 2px 0 var(--space-2); }
    dl.facts dt:last-of-type { border-bottom: none; }
}

/* On a phone the two-column ledger has nothing left to give: the label column
   alone takes its 120px floor and the value column is squeezed under the width
   of its own copy button. Stacked, label over value, it reads better and stops
   pushing the document sideways. */
@media (max-width: 560px) {
    dl.facts { grid-template-columns: minmax(0, 1fr); }
    dl.facts dt { padding: var(--space-2) 0 0; border-bottom: none; }
    dl.facts dd { text-align: left; padding: 2px 0 var(--space-2); }
    dl.facts dt:last-of-type { border-bottom: none; }
}

/* ---- form controls -----------------------------------------------------
   Inputs and buttons share one height so a filter row reads as a row. */

.input, .filters input[type=text], .filters input[type=search], .filters select {
    height: var(--control-h);
    padding: 0 var(--space-3);
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    font-size: var(--text-base);
    background: var(--panel);
    color: var(--ink);
    transition: border-color var(--transition), box-shadow var(--transition);
}

.filters input[type=text]:hover, .filters select:hover { border-color: var(--muted-dim); }
.filters input[type=text]:focus, .filters select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); outline: none; }
.filters input::placeholder { color: var(--muted-dim); }

/* Defined once here rather than left to the browser, so a control that is
   disabled later reads as disabled everywhere instead of only wherever
   somebody remembered to style it. */
input:disabled, select:disabled, textarea:disabled {
    background: var(--panel-raised);
    color: var(--muted-dim);
    border-color: var(--line-soft);
    cursor: not-allowed;
}

.filters {
    display: flex;
    gap: var(--space-4);
    flex-wrap: wrap;
    align-items: flex-end;
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: var(--shadow-sm);
    padding: var(--space-4);
}

.filters > div:not(.filters__actions), .filters fieldset { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }

/* The actions travel together and sit at the end of the bar, so the fields
   wrap among themselves without ever stranding one button on a line of its
   own. */
.filters__actions { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; margin-left: auto; }

.filters label { display: block; font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--muted); }

.filters input[type=text] { min-width: 200px; }
.filters fieldset { border: none; margin: 0; padding: 0; }
.filters legend { font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--muted); padding: 0; float: left; }

/* The checkbox group is a control, so it stands as tall as the inputs beside
   it and its row centres inside that height instead of hanging off the bottom
   edge of the filter bar. */
.filters .checks { display: flex; gap: var(--space-4); flex-wrap: wrap; align-items: center; min-height: var(--control-h); clear: both; }
.filters .checks label { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); font-weight: 400; color: var(--ink); margin: 0; cursor: pointer; }
.filters .checks input[type=checkbox] { width: 15px; height: 15px; margin: 0; flex: none; cursor: pointer; }

/* ---- buttons -----------------------------------------------------------
   inline-flex with an explicit height, so <a class="button"> and
   <button class="button"> are the same size. As inline-block with vertical
   padding they were 41px and 36px respectively, side by side in the same row. */

.button {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    height: var(--control-h);
    padding: 0 var(--space-4);
    border: 1px solid var(--accent);
    border-radius: var(--radius-sm);
    background: var(--accent);
    color: #fff;
    font-family: var(--sans);
    font-size: var(--text-base);
    font-weight: var(--weight-semibold);
    line-height: 1;
    white-space: nowrap;
    cursor: pointer;
    text-decoration: none;
    transition: background-color var(--transition), border-color var(--transition), color var(--transition);
}

.button:hover { background: var(--accent-bright); border-color: var(--accent-bright); color: #fff; }
.button:active { background: var(--accent-deep); border-color: var(--accent-deep); }

.button--quiet { background: var(--panel); color: var(--ink); border-color: var(--line); }
.button--quiet:hover { background: var(--panel-raised); border-color: var(--muted-dim); color: var(--ink); }
.button--quiet:active { background: var(--line-soft); }

.button--danger { background: var(--panel); color: var(--fail); border-color: var(--fail-line); }
.button--danger:hover { background: var(--fail-bg); color: var(--fail); border-color: var(--fail-line); }
.button--danger:active { background: #f7d9d6; }

.button--sm { height: var(--control-h-sm); padding: 0 var(--space-3); font-size: var(--text-sm); }
.button--lg { height: var(--control-h-lg); padding: 0 var(--space-5); }

.button:disabled, .button[aria-disabled="true"] {
    opacity: .5;
    cursor: not-allowed;
    pointer-events: none;
}

/* ---- pager ------------------------------------------------------------- */

.pager { display: flex; gap: var(--space-3); align-items: center; font-size: var(--text-sm); }

/* ---- charts -------------------------------------------------------------
   Every chart here is inline server-rendered SVG, geometry computed in a
   Razor code block -- no charting library, no CDN. Each has a
   <details>-collapsed data table right below it, so the same numbers are
   readable by a screen reader and survive a black-and-white photocopy. */

.chart { width: 100%; height: auto; display: block; }
.chart .bar--pass { fill: var(--pass-line); }
.chart .bar--fail { fill: var(--fail-line); }
.chart .bar--error { fill: var(--error-line); }
.chart .bar--inconclusive { fill: var(--inconclusive-line); }
.chart text { font-size: var(--text-2xs); fill: var(--muted); font-family: var(--sans); }

/* margin: 0 because the legend's only home is a .card__head flex row, where a
   top margin does not centre it -- it pushed the legend 5px below the heading
   it sits beside. */
.legend { display: flex; gap: var(--space-4); margin: 0; font-size: var(--text-xs); color: var(--muted); flex-wrap: wrap; align-items: center; }
.legend span { display: inline-flex; align-items: center; gap: var(--space-2); }
.legend i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; flex: none; }

details > summary {
    cursor: pointer;
    font-size: var(--text-sm);
    color: var(--accent);
    width: fit-content;
    padding: 2px 0;
    font-weight: var(--weight-medium);
}

details > summary:hover { color: var(--accent-bright); }
details[open] > summary { margin-bottom: var(--space-3); }

/* Bar-list: a labelled horizontal bar per row -- phase failures, loopback
   mismatch rate, and similar ranked breakdowns. */
.bar-list { display: flex; flex-direction: column; gap: var(--space-3); max-width: 720px; }
.bar-list__row-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-4); font-size: var(--text-xs); margin-bottom: var(--space-1); }
.bar-list__name { color: var(--ink); font-weight: var(--weight-medium); min-width: 0; overflow-wrap: anywhere; }
.bar-list__figure { color: var(--muted); font-family: var(--mono); white-space: nowrap; }
.bar-list__track { height: 8px; background: var(--line-soft); border-radius: var(--radius-xs); overflow: hidden; }
.bar-list__fill { height: 100%; border-radius: var(--radius-xs); }

/* Bar-compare: grouped vertical bars -- pass rate by firmware/hardware.

   A grid, not a row of flex columns, and the reason is the bars' baseline. As
   flex columns bottom-aligned with align-items:flex-end, each column's height
   depended on how many lines its own label wrapped to -- so one long firmware
   version pushed its bar 36px higher than its neighbour's and the chart could
   no longer be read by comparing bar heights, which is its entire job.

   With grid-auto-flow:column every column shares the same four rows, so the
   figures, the bars' tops and bottoms, the labels and the sample sizes each
   line up across the whole chart no matter what any label does. */
.bar-compare {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: 88px;
    grid-template-rows: auto auto auto auto;
    gap: var(--space-2) var(--space-6);
    justify-content: start;
    align-items: end;
    overflow-x: auto;
    scrollbar-width: thin;
}

/* The wrapper contributes no box of its own; its four children become the
   grid's items so they land in the shared rows. */
.bar-compare__col { display: contents; }

.bar-compare__pct { font-size: var(--text-xs); font-weight: var(--weight-semibold); color: var(--ink); font-variant-numeric: tabular-nums; text-align: center; }
.bar-compare__track { width: 48px; justify-self: center; background: var(--line-soft); border-radius: var(--radius-xs) var(--radius-xs) 0 0; height: 96px; display: flex; align-items: flex-end; }
.bar-compare__fill { width: 100%; border-radius: var(--radius-xs) var(--radius-xs) 0 0; }
.bar-compare__label { font-size: var(--text-xs); color: var(--muted); font-family: var(--mono); text-align: center; overflow-wrap: anywhere; align-self: start; }
.bar-compare__n { font-size: var(--text-2xs); color: var(--muted-dim); font-variant-numeric: tabular-nums; text-align: center; align-self: start; }

/* Ranked list: reason codes and similar rank + code + badge + count rows. */
.rank-list { display: flex; flex-direction: column; }
/* Wraps rather than overflows: the rank number, the badge and the count are all
   fixed-width, and on a phone they add up to more than the card. */
.rank-list__row { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; border-bottom: 1px solid var(--line-soft); flex-wrap: wrap; }
.rank-list__row:last-child { border-bottom: none; }
.rank-list__n { width: 24px; flex: none; font-size: var(--text-xs); color: var(--muted-dim); font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; }
/* The code and its gloss are one block, so the explanation stays with the
   string it explains when the row wraps. */
.rank-list__reason { flex: 1; min-width: 0; display: block; }
.rank-list__code { display: block; font-family: var(--mono); font-size: var(--text-xs); color: var(--ink); min-width: 0; overflow-wrap: anywhere; }
.rank-list__gloss { display: block; margin-top: 2px; font-size: var(--text-xs); color: var(--muted-dim); max-width: 52ch; }
.rank-list__count { font-size: var(--text-sm); color: var(--muted); width: 44px; flex: none; text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }

.analytics-ranges { display: flex; gap: 2px; background: var(--panel-raised); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 3px; }

.analytics-ranges a {
    display: inline-flex;
    align-items: center;
    padding: 0 var(--space-3);
    height: var(--control-h-sm);
    border-radius: var(--radius-xs);
    font-size: var(--text-sm);
    font-weight: var(--weight-semibold);
    text-decoration: none;
    color: var(--muted);
    transition: background-color var(--transition), color var(--transition);
}

.analytics-ranges a:hover { color: var(--ink); }
.analytics-ranges a.is-current { color: var(--ink); background: var(--panel); box-shadow: var(--shadow-sm); }

/* min(320px, 100%) rather than a bare 320px. A plain minmax() floor is a hard
   minimum: below a 400px viewport the track stayed 320px wide inside a 264px
   column and pushed the document sideways. The min() lets the track give way
   once the container is narrower than the floor. */
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); gap: var(--space-5); align-items: stretch; }

/* ---- the device cell on the OBU list -------------------------------------

   Identity above, run count below, in one cell rather than two columns. The
   count is a property of the identifier -- "this unit, five runs" -- and split
   across columns it read as an unrelated number that happened to sit beside an
   IMEI. Keeping them stacked also spends the width on the identifier, which is
   fifteen digits and the thing being scanned for. */

.obu-cell { display: flex; flex-direction: column; gap: 2px; min-width: 0; }

/* Never wrapped. An IMEI is fifteen digits read as one token, and breaking it
   across three lines to save a narrow column turns the identifier into
   something nobody can scan or compare. The table sits in .table-scroll, so the
   container gives way instead -- which is what that container is for. */
.obu-cell__id {
    font-family: var(--mono);
    font-size: var(--text-sm);
    font-weight: var(--weight-semibold);
    white-space: nowrap;
}

/* A unit whose modem never answered. Still a link and still legible -- these
   are the rows most worth opening -- but not set in mono, because there is no
   identifier here to read digit by digit. */
.obu-cell__id--anonymous { font-family: var(--sans); font-style: italic; font-weight: var(--weight-medium); }

.obu-cell__runs { font-size: var(--text-xs); color: var(--muted); }

/* ---- the OBU summary header on a device's history -----------------------

   Sticky, unlike the topbar. The trade the topbar loses -- an overlay across
   the top of the viewport can cover a click target the browser has just
   scrolled to -- comes out differently here: this card carries the identity of
   the unit whose runs are being scrolled, it holds one link rather than a
   navigation bar, and the timeline beneath it is read by comparing entries to
   each other, which is exactly the reading that needs the header still on
   screen. It unsticks itself on a short viewport below, where a header holding
   a third of the screen would cost more than it gives. */

.obu-summary {
    position: sticky;
    top: var(--space-4);
    z-index: 5;
    box-shadow: var(--shadow-lg);
}

.obu-summary__head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--space-5);
    flex-wrap: wrap;
}

.obu-summary__identity { min-width: 0; display: flex; flex-direction: column; gap: var(--space-1); }

.obu-summary__eyebrow {
    font-size: var(--text-2xs);
    font-weight: var(--weight-semibold);
    letter-spacing: .06em;
    text-transform: uppercase;
    color: var(--muted-dim);
}

.obu-summary__imei { font-size: var(--text-xl); font-weight: var(--weight-semibold); min-width: 0; }
.obu-summary__imei .mono { font-size: var(--text-xl); letter-spacing: -.01em; }

.obu-summary__runs { font-size: var(--text-sm); color: var(--muted); }

.obu-summary__verdict { display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-2); flex: none; }
.obu-summary__open { font-size: var(--text-sm); }

/* Auto-fit rather than a fixed column count: this strip carries five facts on
   a wide screen and has to fold to one column on a phone without any of them
   being clipped. min() so the floor gives way below a 220px container instead
   of pushing the page sideways. */
.obu-summary__facts {
    margin: 0;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr));
    gap: var(--space-3) var(--space-5);
    padding-top: var(--space-4);
    border-top: 1px solid var(--line-soft);
}

.obu-summary__facts > div { min-width: 0; }

.obu-summary__facts dt {
    font-size: var(--text-2xs);
    font-weight: var(--weight-semibold);
    letter-spacing: .04em;
    text-transform: uppercase;
    color: var(--muted-dim);
}

.obu-summary__facts dd { margin: 2px 0 0; font-size: var(--text-sm); color: var(--ink); min-width: 0; overflow-wrap: anywhere; }
.obu-summary__facts dd .mono, .obu-summary__facts dd.mono { font-size: var(--text-xs); }

.obu-summary__note { font-size: var(--text-xs); color: var(--muted-dim); max-width: 84ch; }

/* A summary pinned on a laptop in landscape, or on a phone where the facts
   stack into a column a screen tall, leaves too little of the timeline visible
   to compare two runs -- which is the whole reason it was pinned. Below either
   bound it scrolls away like anything else. */
@media (max-height: 720px), (max-width: 760px) {
    .obu-summary { position: static; box-shadow: var(--shadow-sm); }
}

/* ---- a day's runs on the history timeline -------------------------------

   Re-testing a unit several times in one shift is the normal case, so a flat
   timeline of thirty entries is thirty near-identical rows. The day is the
   divider that makes it scannable: this morning's three attempts read as one
   episode, and the run a fortnight earlier reads as another. Ordered and
   grouped on the ingest clock, the same one the timeline is sorted by. */

.run-day__head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-3);
    flex-wrap: wrap;
    padding-bottom: var(--space-3);
    border-bottom: 1px solid var(--line-soft);
}

.run-day__label { margin: 0; font-size: var(--text-lg); font-weight: var(--weight-semibold); line-height: var(--leading-tight); letter-spacing: -.01em; }
.run-day__count { font-size: var(--text-xs); color: var(--muted); flex: none; }

/* The change chips sit directly under the entry header, above the phase chips,
   so "what is different about this run" is read before "what failed in it". */
.timeline .run-changes { margin-bottom: var(--space-2); }

/* ---- timeline ---------------------------------------------------------- */

.timeline { list-style: none; margin: 0; padding: 0; }

.timeline li {
    position: relative;
    padding: 0 0 var(--space-5) var(--space-6);
    border-left: 2px solid var(--line);
    margin-left: 5px;
}

.timeline li:last-child { border-left-color: transparent; padding-bottom: 0; }

/* Centred on the first line of the entry it marks. At top:18px the dot sat
   13px below that line, which read as an off-by-one against every row. */
.timeline li::before {
    content: "";
    position: absolute;
    left: -7px;
    top: 4px;
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background: var(--muted-dim);
    border: 2px solid var(--panel);
    box-shadow: 0 0 0 1px var(--line);
}

.timeline li.is-pass::before { background: var(--pass-line); box-shadow: none; }
.timeline li.is-fail::before { background: var(--fail-line); box-shadow: none; }
.timeline li.is-error::before { background: var(--error-line); box-shadow: none; }
.timeline li.is-inconclusive::before { background: var(--inconclusive-line); box-shadow: none; }

/* The timeline entry's own header row. It used to borrow .card__head, which
   also carries card-specific spacing -- a class doing two unrelated jobs is
   how the two drift apart later. */
.timeline__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; margin-bottom: var(--space-3); }
.timeline__when { font-weight: var(--weight-semibold); font-size: var(--text-md); }
.timeline__meta { color: var(--muted); font-size: var(--text-sm); margin-top: 2px; }
.timeline__link { margin-top: var(--space-3); font-size: var(--text-sm); }

/* ---- code / raw payload -------------------------------------------------
   Wrapping is not cosmetic here. A quarantined payload is one long line of
   JSON; unwrapped it made the whole document 4093px wide at a 1430px viewport,
   so the sidebar, the topbar and every other page element scrolled sideways
   with it. It also gets a height cap: these run to tens of kilobytes. */

pre { margin: 0; font-family: var(--mono); }

/* ---- phase measurements -------------------------------------------------

   The figures a phase recorded, open on the page. They were a JSON blob behind
   a disclosure, which put the evidence for a failed phase one click away and
   delivered it unlabelled -- a reader had to already know which key mattered.

   A grid rather than a two-column dl.facts ledger, because each row carries a
   sentence of context under its label as well as a value. Auto-fit so a card
   with one measurement does not stretch it across the full width and a card
   with five wraps them into a readable block at any container size. */

.measurements {
    margin: 0;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
    gap: var(--space-3);
}

.measurements__row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-4);
    padding: var(--space-3);
    background: var(--panel-raised);
    border: 1px solid var(--line-soft);
    border-radius: var(--radius-sm);
}

.measurements dt {
    font-size: var(--text-sm);
    font-weight: var(--weight-medium);
    min-width: 0;
}

/* The context sentence. Its own line under the label rather than a title
   attribute: a tooltip is invisible on a touch screen and absent from a
   printout, and this page gets printed when a unit is escalated. */
.measurements__note {
    display: block;
    margin-top: 2px;
    font-size: var(--text-xs);
    font-weight: 400;
    color: var(--muted-dim);
    max-width: 46ch;
}

.measurements dd {
    margin: 0;
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

.measurements__value {
    font-size: var(--text-xl);
    font-weight: var(--weight-semibold);
}

/* Sized and coloured below the figure so the number reads first and the unit
   reads as an annotation on it rather than as part of the value. */
/* No left margin: the space between the figure and its unit is a real space in
   the markup, so it survives being read aloud and being copied out. */
.measurements__unit {
    font-size: var(--text-xs);
    color: var(--muted);
}

.card > pre, .phase__data pre, .code-block {
    margin: 0;
    padding: var(--space-3) var(--space-4);
    background: var(--panel-raised);
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    font-size: var(--text-sm);
    line-height: var(--leading-relaxed);
    color: var(--ink);
    white-space: pre-wrap;
    overflow-wrap: anywhere;
    overflow-y: auto;
    max-height: 420px;
}

.phase__data pre { margin-top: 0; }

/* ---- empty states ------------------------------------------------------- */

/* Grows to fill the card. Without this the placeholder sat at the top of a
   card that a taller neighbour in the same grid row had stretched, leaving a
   pool of dead white beneath it. */
.card > .empty { flex: 1; display: flex; align-items: center; justify-content: center; }

.empty {
    padding: var(--space-8) var(--space-5);
    text-align: center;
    color: var(--muted);
    font-size: var(--text-sm);
    background: var(--panel-raised);
    border: 1px dashed var(--line);
    border-radius: var(--radius-sm);
    max-width: none;
}

/* ---- standalone pages (sign-in, denied, error) --------------------------
   These pages render before or outside a session -- no sidebar, no shared
   layout dependency, and (deliberately) no query against the database, so
   the error page itself cannot fail because the thing it is reporting on is
   broken. */

.standalone-page {
    min-height: 100vh;
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--space-6);
    font-family: var(--sans);
    background: var(--page);
    color: var(--ink);
}

.standalone-brand { display: flex; align-items: center; gap: var(--space-3); }
.standalone-brand__mark { width: 28px; height: 28px; border-radius: var(--radius-sm); background: var(--accent); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.standalone-brand__mark i { width: 10px; height: 10px; background: #fff; border-radius: 2px; display: block; }
.standalone-brand strong { font-size: var(--text-lg); font-weight: var(--weight-semibold); color: var(--ink); }

/* An actual panel. It was named a card and styled as loose centred text on the
   page background, which read as an unstyled fallback rather than a designed
   state -- the one impression an error page should not give. */
.status-card {
    width: 100%;
    max-width: 440px;
    text-align: center;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-10) var(--space-8);
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-lg);
}

.status-card__icon {
    width: 52px;
    height: 52px;
    border-radius: var(--radius-lg);
    background: var(--fail-bg);
    color: var(--fail);
    display: flex;
    align-items: center;
    justify-content: center;
    flex: none;
}

.status-card__title { font-size: var(--text-2xl); font-weight: var(--weight-semibold); color: var(--ink); line-height: var(--leading-tight); }
.status-card__body { font-size: var(--text-sm); color: var(--muted); line-height: var(--leading-relaxed); }

.status-card__ref {
    font-size: var(--text-xs);
    color: var(--muted);
    font-family: var(--mono);
    background: var(--panel-raised);
    border: 1px solid var(--line-soft);
    padding: var(--space-2) var(--space-3);
    border-radius: var(--radius-sm);
    max-width: 100%;
    overflow-wrap: anywhere;
}

.status-card__actions { display: flex; gap: var(--space-3); flex-wrap: wrap; justify-content: center; margin-top: var(--space-1); }

/* Sign-in: a dark identity panel and a light form panel side by side. */
.signin-split { display: flex; min-height: 100vh; width: 100%; font-family: var(--sans); background: var(--page); }

.signin-split__panel {
    flex: 1 1 44%;
    min-width: 0;
    background: var(--sidebar-bg);
    background-image: radial-gradient(var(--sidebar-line) 1px, transparent 1px);
    background-size: 22px 22px;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    gap: var(--space-8);
    padding: var(--space-12);
    color: var(--sidebar-ink);
}

.signin-split__brand { display: flex; align-items: center; gap: var(--space-3); }
.signin-split__brand strong { font-size: var(--text-2xl); font-weight: var(--weight-semibold); color: #fff; }

.signin-split__status { font-size: var(--text-md); color: var(--sidebar-muted); max-width: 34ch; line-height: var(--leading-relaxed); }

.signin-split__form-wrap { flex: 1 1 56%; min-width: 0; display: flex; align-items: center; justify-content: center; padding: var(--space-10) var(--space-6); }

.signin-split form { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: var(--space-5); }
.signin-split h1 { margin: 0; font-size: var(--text-3xl); font-weight: var(--weight-semibold); color: var(--ink); letter-spacing: -.01em; line-height: var(--leading-tight); }
.signin-split__subtitle { margin: var(--space-2) 0 0; font-size: var(--text-sm); color: var(--muted); }

.signin-split label { display: flex; flex-direction: column; gap: var(--space-2); font-size: var(--text-xs); font-weight: var(--weight-semibold); color: var(--muted); }

.signin-split input[type=text], .signin-split input[type=password] {
    height: var(--control-h-lg);
    padding: 0 var(--space-3);
    border-radius: var(--radius-sm);
    border: 1px solid var(--line);
    background: var(--panel);
    font-size: var(--text-base);
    color: var(--ink);
    font-weight: 400;
    transition: border-color var(--transition), box-shadow var(--transition);
}

.signin-split input:hover { border-color: var(--muted-dim); }
.signin-split input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }

.signin-split .button { width: 100%; height: var(--control-h-lg); }
.signin-split__foot { display: flex; justify-content: space-between; gap: var(--space-4); font-size: var(--text-xs); color: var(--muted-dim); }

/* Below this the two panels cannot both hold their content: the dark panel's
   old 380px floor plus the form squeezed the document 165px wider than a
   480px viewport, so the sign-in page -- the first thing anyone sees --
   scrolled sideways. Stacked, the identity panel becomes a header band. */
@media (max-width: 860px) {
    .signin-split { flex-direction: column; }

    .signin-split__panel {
        flex: 0 0 auto;
        flex-direction: row;
        align-items: center;
        justify-content: space-between;
        gap: var(--space-5);
        padding: var(--space-5) var(--space-6);
    }

    .signin-split__status { font-size: var(--text-sm); max-width: 28ch; text-align: right; }

    /* Left-aligned on the same --space-6 inset as the band above it. Centred,
       the form started 36px right of the wordmark it sits under. */
    .signin-split__form-wrap {
        flex: 1 1 auto;
        padding: var(--space-8) var(--space-6);
        align-items: flex-start;
        justify-content: flex-start;
    }

    .signin-split form { max-width: 400px; }
}

@media (max-width: 560px) {
    .signin-split__panel { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
    .signin-split__status { text-align: left; }
    .signin-split__foot { flex-direction: column; gap: var(--space-1); }
}

/* ---- copy-to-clipboard -------------------------------------------------- */

/* The value must be allowed to shrink and break. As a rigid flex item a 32
   character hash held the row open at its full width whatever the container
   did, and took the page's horizontal scrollbar with it. */
.copyable { display: inline-flex; align-items: center; gap: var(--space-2); min-width: 0; }
.copyable > .mono { min-width: 0; overflow-wrap: anywhere; }

.copy-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: var(--panel);
    border: 1px solid var(--line);
    color: var(--muted);
    border-radius: var(--radius-xs);
    width: 24px;
    height: 24px;
    line-height: 1;
    font-size: var(--text-xs);
    cursor: pointer;
    padding: 0;
    flex: none;
    transition: border-color var(--transition), color var(--transition), background-color var(--transition);
}

.copy-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.copy-btn.is-copied { color: var(--pass); border-color: var(--pass-line); background: var(--pass-bg); }

/* ---- print --------------------------------------------------------------
   These pages get printed for physical escalation tickets on the factory
   floor, so print gets its own explicit, ink-conscious pass rather than
   assuming the screen theme is already print-safe -- badge colours in
   particular are tuned for screen contrast, not for a black-and-white
   photocopy, and the print rule below is what keeps them legible either
   way. Everything here applies only when actually printing. */

@media print {
    body { background: #fff; color: #000; }

    .sidebar, .topbar, .footer, .page__toolbar, .copy-btn,
    .filters, .pager, .button, form button { display: none !important; }

    .shell, .content, .main { display: block; padding: 0; max-width: none; }

    /* A sticky header prints once at the top and then not at all beside the
       runs on later sheets, which is the opposite of what pinning it was for.
       On paper the whole history is visible at once anyway. */
    .obu-summary { position: static; box-shadow: none; }

    .card, .kpi-card { background: #fff; border-color: #ccc; box-shadow: none; display: block; }

    /* The bleed exists to line a table up with a card's padding on screen;
       on paper it just hangs the table off the edge of its box. */
    .card > .table-scroll, .card > details > .table-scroll { margin-inline: 0; }

    .card > pre, .phase__data pre { max-height: none; overflow: visible; }

    details[open] > summary { margin-bottom: var(--space-2); }

    table.data th { background: #f2f2f2; color: #000; border-bottom-color: #ccc; }
    table.data td { border-bottom-color: #ddd; }

    .empty { border-color: #ccc; background: #fff; }

    /* Forced to black rather than kept in colour: a badge colour tuned for
       screen contrast is exactly the case that can fail in a black-and-white
       photocopy. The status text itself still carries the reading either way. */
    .badge, .badge__icon { background: #fff !important; border-color: #000 !important; color: #000 !important; }

    a { color: #000; }
}
