/* ============================================================================
   components.css — buttons, tables, forms, panels, pills, menus, disclosure,
   toasts, swatch grounds, verdicts. Written from the SpectroFit tokens
   (design system §Components + brief §5). Behaviors (menu/disclosure) live in
   app/static/js/behaviors/*.js.
   ============================================================================ */

/* ── Buttons (§6.1) ──────────────────────────────────────────────────────── */
/* Phase F: Bootstrap CSS is unlinked, so the base rule finally lands and the
   design button SHAPE (40px height, 600 weight, 1.5px edge, press scale) applies
   everywhere instead of Bootstrap's 400/8px/0.15s. */
.btn {
    display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
    height: var(--ctrl-h);
    padding: 0 var(--ctrl-pad-x);
    font-family: var(--font-body);
    font-size: 0.9rem; font-weight: 600; line-height: 1;
    text-decoration: none; white-space: nowrap;
    border: 1.5px solid transparent;
    border-radius: var(--radius-sm);
    cursor: pointer;
    isolation: isolate;                     /* contains the primary's sweep */
    transition: background-color var(--t-fast), border-color var(--t-fast),
                color var(--t-fast), transform var(--t-press);
}
.btn:active:not(:disabled)  { transform: scale(0.98); }
.btn:disabled, .btn[aria-disabled="true"] { opacity: 0.52; cursor: not-allowed; }
.btn--sm { height: var(--ctrl-h-sm); padding: 0 0.8rem; font-size: 0.82rem; }
.btn--lg { height: var(--ctrl-h-lg); padding: 0 1.6rem; font-size: 1rem; }
.btn--block { width: 100%; }
.btn .fa-solid, .btn .fa-regular { font-size: 0.9em; }

.btn-primary { background: var(--violet); color: var(--paper); position: relative; isolation: isolate; }
.btn-primary:hover { background: var(--ink); color: var(--paper); text-decoration: none; }
/* The light sweep — the design system's optional flourish. Primaries only, and
   never on a button inside a table row (dense toolbars opt out).
   The pseudo NEVER leaves the button box: it stays at inset:0 and the gradient
   travels inside it via background-position. It used to rest at
   translateX(-110%) — a full button-width to the LEFT, unclipped — so it lay on
   top of whatever button preceded it and swallowed that button's hover.
   `isolation` on .btn contains the STACKING (which is what lets a z-index:-1
   pseudo paint above the violet ground), not the geometry. */
.btn-primary::before {
    content: ''; position: absolute; inset: 0; z-index: -1;
    pointer-events: none;
    background: linear-gradient(100deg, transparent 40%,
                rgba(255,255,255,0.22) 50%, transparent 60%)
                no-repeat 150% 0 / 250% 100%;
}
.btn-primary:hover::before { background-position: -50% 0; transition: background-position 0.6s var(--ease); }
.data-table .btn-primary::before { display: none; }

.btn-outline { border-color: var(--edge); color: var(--ink); background: var(--paper); }
.btn-outline:hover {
    border-color: var(--accent); color: var(--accent);
    background: var(--accent-wash); text-decoration: none;
}
.btn-outline:active:not(:disabled) { background: var(--accent-wash-2); }
.btn-outline:disabled { border-color: var(--rule); color: var(--gray-500); }

.btn-ghost { background: transparent; color: var(--gray-600); }
.btn-ghost:hover { color: var(--ink); background: var(--paper-50); text-decoration: none; }

.btn-danger { background: var(--fail); color: #fff; }
.btn-danger:hover { filter: brightness(0.92); color: #fff; text-decoration: none; }

/* Row-level destructive TRIGGER: ghost until hovered (§6.1 rule-3 note). The
   solid .btn-danger is reserved for the confirm button inside the modal. */
.cell-danger { color: var(--gray-600); }
.cell-danger:hover, .cell-danger:focus-visible { color: var(--fail); background: rgba(196,43,43,0.08); }

/* Transitions + press now live on the base `.btn` rule above (Phase F); every
   variant in the templates carries `btn` alongside it, so the duplicated
   variant-level selectors were removed. */

/* Sizes (§6.1) now live on the base `.btn` block above (Phase F) — this
   duplicate set existed only to supply flex centring while `.btn` was deferred.
   `.btn--block` keeps `display: flex` so a full-width button stretches. */
.btn--block { display: flex; }

/* Submit spinner (§7.4) — a 14px arc. currentColor so it reads on a violet fill
   and on white alike; the conic gap is what makes the rotation legible. The
   sf-spin rotation lives in motion.css (that file owns every animation). */
.btn__spinner {
    display: inline-block; width: 14px; height: 14px; border-radius: 50%; flex: none;
    background: conic-gradient(from 0turn, transparent 0 25%, currentColor 100%);
    -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 0);
    mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 0);
}

/* ── Panels (replaces .card + the inline #242552 borders) ────────────────── */
.panel {
    background: var(--paper);
    border: 1px solid var(--rule);
    border-radius: var(--radius-md);
}
.panel__body { padding: 1.5rem; }
.panel__title {
    font-family: var(--font-display); font-weight: 700;
    font-size: 1.1rem; color: var(--ink); margin: 0 0 1rem;
}
.panel__head {
    display: flex; align-items: baseline; justify-content: space-between; gap: 1rem;
    padding: 1.1rem 1.5rem; border-bottom: 1px solid var(--rule);
}
.panel__head .panel__title { margin: 0; }
.panel--inset { background: var(--paper-50); }
.panel--flush .panel__body { padding: 0; }        /* a panel wrapping a table */

/* ── Data table — no zebra striping (rule 2), real thead (brief §6.4) ────── */
.data-table { width: 100%; border-collapse: collapse; font-size: 0.92rem; }
.data-table caption {
    position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0);
}
/* ponytail: sticky `top` stays 0. §6.4 wants calc(--nav-height + --ctx-height),
   and Phase B did ship the context bar — but only medidas/colores/estructura
   carry one, so a single offset would leave a --ctx-height gap above every
   header on equipos/admin. Ceiling: the header slides under the chrome instead
   of stopping below it. Upgrade path: a `--sticky-top` custom property set on
   <body> per surface (0 + --nav-height, plus --ctx-height where a bar renders),
   which is one token and no new rule — an owner call, not a Phase-G one. */
.data-table thead th {
    position: sticky; top: 0; z-index: 20;
    background: var(--paper-50);
    font-family: var(--font-mono);
    font-size: 0.7rem; letter-spacing: 0.14em; text-transform: uppercase;
    color: var(--gray-500); font-weight: 500;
    text-align: left; padding: 0.6rem 0.8rem;
    box-shadow: inset 0 -1px 0 var(--rule);        /* border-bottom fails on sticky th */
}
.data-table thead th::after {                       /* the surface accent, 2px, on the header */
    content: ''; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px;
    background: var(--accent); opacity: 0.22;
}
.data-table tbody td {
    padding: 0.55rem 0.8rem;
    border-bottom: 1px solid var(--rule);
    height: var(--row-h); vertical-align: middle;
}
.data-table tbody tr { transition: background-color var(--t-fast); }
.data-table tbody tr:hover,
.data-table tbody tr:focus-within { background: var(--paper-50); }
/* `height` on a <td> is a MINIMUM, so this binds only where a row's own content is
   shorter than 48px — in practice only /reports/ (48.00 -> 40.00). Anywhere a row
   carries a 32px button or a swatch it is already taller, and all that lands is the
   2.4px x 2 padding (/clients/, /auth/, /issues/: 50.09 -> 45.28). Below 640px it
   does NOTHING: the card rule at the bottom of this file has the same specificity
   (0,1,2) and is declared later. Never rely on this for a phone — see the Lab scan
   mode block, which uses (0,3,x) selectors for exactly that reason. */
.data-table--dense tbody td { height: var(--row-h-dense); padding: 0.4rem 0.8rem; }
td.cell-num, th.cell-num { text-align: right; }
.cell-actions { text-align: right; white-space: nowrap; }
.cell-actions > * + * { margin-left: 0.35rem; }
.cell-select { width: 1%; white-space: nowrap; }

/* Selected / targeted row: 2px accent leading bar, no fill. */
.data-table tbody tr[aria-current="true"],
.data-table tbody tr:target { box-shadow: inset 2px 0 0 var(--accent); }

/* Density toggle for the operator scanning a shift (§6.4). */
.table-tools { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.6rem; }

/* The chart canvas keeps its own size (OVERRIDE-2): only its cell is styled.
   min-width is 0, not the old 240px: the cell is a grid item inside a
   .specimen-card now, and a min-width floor on a grid item is the classic track
   blowout. `height: auto` only governs the pre-hydration box — Chart.js writes
   inline width/height once it runs, which outranks this. */
.chart-cell { min-width: 0; }
.chart-cell canvas { max-width: 100%; height: auto; display: block; }

/* ── The /colors/ specimen grid (replaces the data-table on that screen) ──── */
/* A colour reference is its patch plus its reflectance curve; the table gave the
   patch 22×22px and the curve a 240×120 box, and spent 29% of its width on the
   figures DERIVED from that curve. The card leads with the two and lets the
   figures caption them.
   min(21rem, 100%) is load-bearing twice over: the min() is what stops a bare
   21rem min track overflowing a 343px phone, and 21 rather than 22 is measured —
   at 768px (iPad portrait) a 22rem track needs 724px and the grid has 705, so it
   fell to ONE 705px-wide card. Columns now: 3 at ≥~1050px of grid, 2 from ~700,
   1 below. This also retired the 640–1099px horizontal-scroll container in
   app.css, which existed only because this table needed a 1050px column. */
.specimen-grid {
    display: grid; gap: 1.25rem;
    grid-template-columns: repeat(auto-fill, minmax(min(21rem, 100%), 1fr));
    list-style: none; margin: 0 0 1rem; padding: 0;
}
.specimen-card {
    display: flex; flex-direction: column;
    border: 1px solid var(--rule); border-radius: var(--radius-md);
    background: var(--paper);
    overflow: hidden;                     /* the specimen is full-bleed to the edge */
}
/* Soft-deleted (State=Disabled, admin-only). The strike lives on the name — see
   the template: text-decoration propagates, and striking six figures is noise. */
.specimen-card--deleted { opacity: 0.62; }

.specimen-card__body { padding: 0.9rem 1rem 1rem; }
/* The head sits ABOVE the specimen and is a direct child of the card, so it
   carries its own padding — the body's does not reach it. The values are optical,
   not symmetric: .check offsets its box by 5px and .btn-ghost carries 12.8px of
   its own, so 0.7rem/0.2rem is what lands the checkbox and the ⋮ label on the
   body's 1rem gutter. No bottom margin: the specimen is full-bleed and starts
   directly under the band. */
.specimen-card__head {
    display: flex; align-items: center; gap: 0.55rem;
    padding: 0.6rem 0.2rem 0.55rem 0.7rem;
}
.specimen-card__head .check { margin: 0; }
.specimen-card__head .menu { margin-left: auto; }
.specimen-card__name {
    margin: 0; font-size: 1rem; font-weight: 600; color: var(--ink);
    overflow-wrap: anywhere;              /* a long reference name must not overflow */
}
.specimen-card__meta {
    margin: 0.6rem 0 0; padding-top: 0.6rem;
    border-top: 1px solid var(--rule);
    font-family: var(--font-mono); font-size: 0.72rem;
    color: var(--gray-500);
}

/* Soft-deleted rows disclosure (D-16): the panel is a second <tbody>, and
   disclosure.js sets an inline `display:grid` for its 260ms open animation —
   which would blockify a table row group mid-flight and break every column.
   !important is the only way to outrank an inline style, so this group opens and
   closes instantly and stays a row group. */
tbody.rows-deleted { display: table-row-group !important; }
tbody.rows-deleted[hidden] { display: none !important; }
@media (max-width: 639px) {          /* the ≤639px card layout blockifies tables */
    tbody.rows-deleted { display: block !important; }
}

/* The colour figures (§10.4.6 item 3, D-18): two label/value columns so the six
   figures read as one block instead of a 6-row ladder. The old `min-width: 15rem`
   is gone with the table — it kept a narrow TABLE CELL from collapsing, and inside
   a .specimen-card it is only an overflow floor on a 320px phone. auto-fit already
   drops to one column when the card cannot hold two. */
.spec-row-list--cell {
    display: grid; grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
    column-gap: 1.5rem;
}
/* No hairlines in this one: .spec-row carries a border-bottom and the <dl> only
   clears it on :last-child — which in a 2-column grid is one of the TWO bottom
   cells, so the other kept a stray half-width rule under it. A tight 2×3 block
   does not need row rules anyway; the rule-4 mono alignment is the separator. */
.spec-row-list--cell .spec-row { border-bottom: 0; padding: 0.22rem 0; }

/* The DEC-7 flag-gated .spectrofit file flows keep their disclosure group; it
   only needs to clear the table below it. */
.files-group { margin-bottom: 1rem; }

/* ── Tables → cards ≤639px (§9.3, DEC-3, D-13) ───────────────────────────── */
/* One markup, two layouts: no JS, no duplicate templates. Each <td> carries
   data-label with its column's Spanish header and becomes a label/value row.
   This REPLACES `is-hidden-sm` (deleted from base.css): a phone gets every
   column, stacked, instead of eleven columns deleted and a table that still
   overflowed by 424px. */
@media (max-width: 639px) {
    /* thead stays in the a11y tree — the labels come from it conceptually. */
    .data-table thead {
        position: absolute; width: 1px; height: 1px;
        overflow: hidden; clip: rect(0,0,0,0);
    }
    .data-table, .data-table tbody, .data-table tr, .data-table td {
        display: block; width: auto;
    }
    .data-table tbody tr {
        border: 1px solid var(--rule); border-radius: var(--radius-md);
        margin-bottom: 0.85rem; padding: 0.35rem 0;
        background: var(--paper);
    }
    .data-table tbody td {
        display: grid; grid-template-columns: 8.5rem 1fr;
        gap: 0.75rem; align-items: baseline;
        height: auto; min-height: 2.1rem; padding: 0.3rem 0.9rem;
        border-bottom: 0; text-align: left;
    }
    .data-table tbody td::before {
        content: attr(data-label);
        font-family: var(--font-mono); font-size: 0.62rem; letter-spacing: 0.12em;
        text-transform: uppercase; color: var(--gray-500);
    }
    .data-table tbody td:empty { display: none; }
    .data-table tbody td[data-label=""]::before { display: none; }
    /* A table that has not been given data-label yet (the admin surfaces, Phase E)
       degrades to a plain stack instead of an 8.5rem dead column. */
    .data-table tbody td:not([data-label]) { display: block; }
    .data-table tbody td:not([data-label])::before { display: none; }
    .cell-num { text-align: left; }
    /* A chip is a specimen, not a bar: the grid column would stretch it. */
    .data-table tbody td > .density-chip,
    .data-table tbody td > .cell-swatch,
    .data-table tbody td > .pill,
    .data-table tbody td > .verdict { justify-self: start; }
    .cell-actions {
        display: flex; flex-wrap: wrap; gap: 0.4rem;
        padding: 0.6rem 0.9rem 0.35rem; border-top: 1px solid var(--rule);
    }
    .cell-actions::before { display: none; }
    .cell-actions .btn { flex: 1 1 auto; min-height: 40px; }
    .cell-actions > * + * { margin-left: 0; }

    /* The Lab card is special: Ref./Eje/Muestra are value STACKS, not single
       values, so on a phone they collapse into one 3-column comparison — which
       is arguably clearer than the desktop row. */
    .lab-table tbody tr {
        display: grid;
        grid-template-areas:
            "name  name  name"
            "delta delta delta"
            "ref   eje   sample"
            "dev   dev   dev"
            "date  date  date";
        grid-template-columns: 1fr auto 1fr;
    }
    .lab-table td.lab-cell--name   { grid-area: name; }
    .lab-table td.cell-delta       { grid-area: delta; }
    .lab-table td.lab-cell--ref    { grid-area: ref; }
    .lab-table td.lab-eje          { grid-area: eje; }
    .lab-table td.lab-cell--sample { grid-area: sample; }
    .lab-table td.lab-cell--device { grid-area: dev; }
    .lab-table td.lab-cell--date   { grid-area: date; }
    /* The three comparison cells keep their own alignment and wear their column
       header as a caption above the stack (the §9.3 REF. / EJE / MUESTRA row). */
    .lab-table td.lab-cell, .lab-table td.lab-eje { display: block; }
    .lab-table td.lab-cell::before, .lab-table td.lab-eje::before { display: block; }
    .lab-table td.lab-cell--ref, .lab-table td.lab-cell--ref::before { text-align: right; }
    .lab-table td.lab-eje, .lab-table td.lab-eje::before { text-align: center; }
    .lab-table td.lab-cell--sample, .lab-table td.lab-cell--sample::before { text-align: left; }
    .lab-table .lab-chip-row { min-height: 32px; }   /* swatch size: app.css ≤640 */
    /* Δ becomes a full-width band: verdict + value + formula, then the track. */
    .lab-table td.cell-delta {
        display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
        white-space: normal;
    }
    /* Δ sits inline before the verdict pill, not orphaned on its own line. */
    .lab-table td.cell-delta::before { flex: 0 0 auto; }
    .lab-table td.cell-delta .delta-track { flex: 1 0 100%; width: 100%; margin-top: 0; }
}

/* ── Lab scan mode: what Compacta actually means here (§6.4) ─────────────── */
/* .data-table--dense tbody td on its own moves 4.81px on this table and 0px on a
   phone — measured. A Lab row is 227.69px of stacked content, 4.7x the 48px that
   rule sets, and `height` on a <td> is a minimum.
   The row is tall because six figures STACK, and they stack because their labels
   live in a separate column that has to align row-for-row with them. So Compacta
   breaks that coupling: each label moves ONTO its value (data-axis, rendered by the
   ::before below) and the Eje column folds away. Measured 227.69px -> 61.84px:
   3.95 -> 14.55 readings per 900px screen, one page 4601px -> 1285px.
   NOTHING is hidden — every figure is still on screen, and the mechanism is the one
   the phone cards already use (content: attr(data-label), a few rules up).
   Every selector below carries BOTH table classes: at (0,3,x) it outranks the ≤639px
   card rules, which is the whole reason the old toggle died on a phone. No
   !important anywhere — a weak selector is a selector to strengthen, not to shout. */
.lab-table.data-table--dense .lab-cell,
.lab-table.data-table--dense .lab-eje { line-height: 1.35; padding: 0.35rem 0.6rem; }
.lab-table.data-table--dense td.lab-eje,
.lab-table.data-table--dense th.lab-th-eje { display: none; }
/* The swatch joins the value run instead of owning a 48px band above it. */
.lab-table.data-table--dense .lab-cell .lab-chip-row {
    display: inline-flex; min-height: 0; vertical-align: middle; margin-right: 0.4rem;
}
.lab-table.data-table--dense .lab-swatch i { width: 44px; height: 18px; }
.lab-table.data-table--dense .lab-val {
    display: inline; font-size: 0.82rem; margin-right: 0.5rem;
    white-space: nowrap;                  /* "L* 66.44" is one token — never split it */
}
/* [data-axis], not .lab-val: the nm caption has no axis and must stay bare. */
.lab-table.data-table--dense .lab-val[data-axis]::before {
    content: attr(data-axis) '\2009';     /* thin space */
    color: var(--gray-500); font-size: 0.7rem;
}
.lab-table.data-table--dense .lab-den-nm { font-size: 0.66rem; }
/* The Δ cell is deliberately NOT touched here. It first shipped as
   `display: flex` — copied from the ≤639px card block, where it is correct
   because that block blockifies the whole table first. On a real table it is a
   bug: `display: flex` REPLACES `display: table-cell`, the cell stops sizing to
   its content, and the browser wraps it in an anonymous cell. Measured on
   /logs/: the box was 40px while its children ran 95.7px, so the tolerance track
   and the CIE2000 caption painted 62.1px BELOW the cell, on top of the next two
   rows. The cell's own stacked layout already fits (55.9px of content) and it is
   no longer the tallest cell in the row, so there was nothing to gain from the
   rule in the first place. Never change `display` on a <td> in a live table. */

@media (max-width: 639px) {
    /* The card drops the Eje column too, so Ref | Muestra take one row each.
       Equipo and Fecha stay FULL WIDTH: pairing them into "dev date" reads tidier
       and overflowed the document by 149px at 375px — a nowrap date cannot fit a
       half column. Measured 430.91px -> 314.50px with them full width, overflow 0. */
    .lab-table.data-table--dense tbody tr {
        grid-template-columns: 1fr 1fr;
        grid-template-areas:
            "name  name"
            "delta delta"
            "ref   sample"
            "dev   dev"
            "date  date";
        margin-bottom: 0.5rem;
    }
}

/* ── Forms (brief §5.1, design system §Forms) ───────────────────────────── */
.field { margin-bottom: 1.1rem; }
.field__label, .field label {
    display: block;
    font-family: var(--font-mono);
    font-size: 0.7rem; letter-spacing: 0.14em; text-transform: uppercase;
    color: var(--gray-500);
    margin-bottom: 0.4rem;
}
/* Phase F: the bare `.field input/select/textarea` fallback (kept for unmigrated
   Bootstrap `form-control`s) is GONE. It was more specific (0,1,1) than the
   field__* classes (0,1,0), so its `background` shorthand was resetting
   `.field__select`'s chevron background-image to none. Every control now carries
   a field__* class, so the class selectors alone are correct — and the
   specificity workaround this file used to need at the bottom is gone too. */
.field__input, .field__select, .field__textarea {
    width: 100%;
    padding: 0.7rem 0.85rem;
    font-family: var(--font-body);
    font-size: 0.94rem;
    color: var(--ink);
    background: var(--paper);
    border: 1px solid var(--edge);          /* was --rule: 1.25:1, invisible (§2.5) */
    border-radius: var(--radius-sm);
    transition: border-color var(--t-fast), box-shadow var(--t-fast);
}
/* The explicit design-system classes take the fixed control height (§4). */
.field__input, .field__select { height: var(--ctrl-h); padding: 0 0.85rem; }
.field__textarea { height: auto; padding: 0.7rem 0.85rem; min-height: 5rem; }

.field__input:hover, .field__select:hover { border-color: var(--edge-strong); }
.field__input:focus, .field__select:focus, .field__textarea:focus {
    border-color: var(--violet); box-shadow: 0 0 0 3px var(--accent-wash);
}
.field__input::placeholder, .field__textarea::placeholder { color: var(--gray-500); }
.field__input:disabled, .field__input[readonly] { background: var(--paper-50); color: var(--gray-500); }
.field__input:user-invalid { border-color: var(--fail); }   /* NOT :invalid — fires before typing */

/* Numeric inputs match the figures they will be compared against (rule 4). */
.field__input[type="number"], .field__input.num, input[inputmode="decimal"] {
    font-family: var(--font-mono); font-variant-numeric: tabular-nums;
}
/* Width classes so a 3-character tolerance is not 1113px wide (D-23). */
.field--xs { max-width: 7rem; }  .field--sm { max-width: 12rem; }
.field--md { max-width: 22rem; } .field--lg { max-width: 34rem; }

.field__select {
    appearance: none;
    padding-right: 2rem;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23807D8C' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 0.75rem center;
}
.field .hint, .field__hint { font-size: 0.8rem; color: var(--gray-500); margin-top: 0.35rem; }
.field .error, .field__error { font-size: 0.8rem; color: var(--fail); margin-top: 0.35rem; }

/* Repeated checks (the report builder's colour checklist) flow inline and wrap;
   the select-all box owns its own line above them. */
.check { display: inline-flex; align-items: center; gap: 0.55rem; margin: 0 1.1rem 0.35rem 0; }
.check--all { display: flex; margin-right: 0; }
.check__box { width: 1.05rem; height: 1.05rem; accent-color: var(--violet); flex: none; }
.check__label { font-size: 0.92rem; color: var(--ink); }
/* A label may WRAP its input rather than point at it with `for` (D-21) — that is
   the only association that survives this partial being included twice. It still
   needs to be the row. */
.check > .check__label { display: inline-flex; align-items: center; gap: 0.55rem; }

/* The label-column form (§6.2, D-23). For Configuración and any form with more
   than six fields. */
.form-rows { display: grid; gap: 0; }
.form-row {
    display: grid; grid-template-columns: var(--label-col) 1fr;
    gap: 2rem; align-items: start;
    padding: 1rem 0; border-bottom: 1px solid var(--rule);
}
.form-row > .field { margin-bottom: 0; }
.form-row > .eyebrow { margin: 0; align-self: start; }
/* Several controls sharing one label row (the report builder's CMYK line): they
   wrap instead of each spanning the panel (D-23). */
.row-fields { display: flex; flex-wrap: wrap; gap: 0.75rem 1rem; align-items: flex-end; }
.row-fields > .field { margin-bottom: 0; }
.check-list { display: flex; flex-wrap: wrap; align-items: baseline; }
.check-list .check--all { flex: 1 0 100%; }
@media (max-width: 860px) { .form-row { grid-template-columns: 1fr; gap: 0.4rem; } }

/* Selection bar (§10.4.3) — owns the form; the checkboxes live in the cells. */
.selbar {
    display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
    padding: 0.6rem 0.8rem; margin: 0 0 0.6rem;
    background: var(--paper-50);
    border: 1px solid var(--rule); border-radius: var(--radius-md);
}
/* Select-all: it lived in the table's <th> until the specimen grid removed the
   header row. Same control, same data-sf-check-scope — checkall.js resolves its
   members through form.elements, so the move needed no JS change. */
.selbar__all { margin: 0; }
.selbar__count { margin: 0 auto 0 0; font-size: 0.86rem; color: var(--gray-600); }
.selbar__count .num { color: var(--ink); font-weight: 600; }

/* ── List filter bar (search-by-name on /colors/, /clients/, /clients/jobs/) ── */
/* Its own row above the list, NOT a slot in the context bar: measured, a field in
   the context bar takes it from 126.5px to 166.5px at 375px, i.e. masthead + bar =
   232px of permanently pinned chrome on an 812px phone. The bar belongs to the list,
   so it scrolls with the list. */
.listbar { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin: 0 0 0.9rem; }
.listbar__search {
    display: flex; align-items: center; gap: 0.5rem;
    flex: 1 1 18rem; min-width: 0; max-width: 26rem;   /* 18rem basis wraps the count
                                                          onto its own row ≤~640px */
    height: var(--ctrl-h); padding: 0 0.85rem;
    background: var(--paper); border: 1px solid var(--edge);
    border-radius: var(--radius-sm);
    transition: border-color var(--t-fast), box-shadow var(--t-fast);
}
/* :focus-within, not :focus — the ring belongs to the box, the focus to the input. */
.listbar__search:focus-within { border-color: var(--violet); box-shadow: 0 0 0 3px var(--accent-wash); }
.listbar__icon  { flex: none; color: var(--gray-500); font-size: 0.9rem; line-height: 1; }
.listbar__input {
    flex: 1; min-width: 0; height: 100%;
    border: 0; background: none; outline: none;        /* the ring is on the wrapper */
    font-family: var(--font-body); font-size: 0.94rem; color: var(--ink);
}
.listbar__count {
    margin: 0 0 0 auto;                                /* `margin: 0` matters — a <p>
                                                          default margin made this 97px */
    font-family: var(--font-mono); font-size: 0.78rem;
    letter-spacing: 0.08em; text-transform: uppercase;
    color: var(--gray-500); white-space: nowrap;
}
.listbar__count .num { color: var(--ink); font-weight: 500; font-variant-numeric: tabular-nums; }
/* The one bold move: a live filter recolours its own figure with the surface accent,
   so "this list is filtered" is legible without reading the sentence. */
.listbar__count.is-filtered .num { color: var(--accent); }

/* ── Pick list (the finder, _picknext.html) ──────────────────────────────── */
/* The empty region's list of the next step's options. It exists ONLY while the
   results region has nothing in it, so it costs zero once a path is chosen — and it
   reuses .listbar + behaviors/filter.js verbatim, because at 100 clients a list is
   usable only if it is searchable, while at 4 the search just matches everything.
   max-height is what stops 100 rows (44px each = 4400px) becoming a wall on landing;
   the search above it is how you avoid scrolling at all. */
.picklist {
    list-style: none; margin: 0; padding: 0;
    max-height: 22rem; overflow-y: auto;
    border: 1px solid var(--rule); border-radius: var(--radius-sm);
}
.picklist__item {
    display: block; min-height: 44px;                 /* the shop-floor touch floor */
    padding: 0.6rem 0.85rem;
    color: var(--ink); text-decoration: none;
    border-bottom: 1px solid var(--rule);
    transition: background-color var(--t-fast);
}
.picklist li:last-child .picklist__item { border-bottom: 0; }
.picklist__item:hover,
.picklist__item:focus-visible { background-color: var(--accent-wash); }
.picklist__meta { display: block; font-size: 0.78rem; color: var(--gray-600); }
/* REQUIRED: there is no global [hidden] rule in this codebase (see the note below the
   .listbar block) — every filter target declares its own. `display: block` above
   would otherwise beat the UA rule and filter.js would hide nothing. */
.picklist__item[hidden], .picklist li[hidden] { display: none; }
/* The recents list is the same rows without the box: it is a short, already-known set,
   so a scroll container and a border would be furniture around three links. */
.picknext-recent { margin: 0 0 1.4rem; }
.picknext-recent .eyebrow { display: flex; margin-bottom: 0.4rem; }
.picklist--recent { max-height: none; overflow: visible; border: 0; }
.picklist--recent .picklist__item { border-bottom: 0; padding-left: 0; }

/* [hidden] has NO global author rule in this codebase — every component declares its
   own kill rule (.menu__list[hidden], .sheet[hidden], …). Both filter targets set
   `display` themselves, so the UA's [hidden] rule loses the cascade and the filter
   would hide NOTHING. Specificity beats both owners (0,2,0 / 0,2,2), so no !important. */
.specimen-card[hidden] { display: none; }
.data-table tbody tr[hidden] { display: none; }

/* ── Toasts / flashes (rule 3: --ink ground, no green; error = red edge) ─── */
.toast {
    background: var(--ink); color: #fff;
    border-radius: var(--radius-sm);
    padding: 0.8rem 1rem; margin-bottom: 0.8rem;
    font-size: 0.92rem;
}
.toast--ok { }                                   /* ink ground, no green */
.toast--error { border-left: 3px solid var(--fail); }   /* red as an edge, not a fill */

/* ── Disclosure (replaces Bootstrap collapse; behavior: behaviors/disclosure.js)
   §6.10 / D-11: a real affordance — border, hover wash, animated chevron. */
.disclosure { border: 1px solid var(--rule); border-radius: var(--radius-md); overflow: hidden; }
.disclosure__item + .disclosure__item { border-top: 1px solid var(--rule); }
.disclosure__trigger {
    display: flex; align-items: center; gap: 0.6rem; width: 100%;
    padding: 0.85rem 1.1rem;
    font-family: var(--font-body); font-size: 0.92rem; font-weight: 600;
    /* transparent, not --paper (§6.10): a trigger inside .panel--inset must keep
       the panel's recessed ground instead of punching a white bar into it. */
    color: var(--ink); background: transparent; border: 0; cursor: pointer;
    text-align: left;
    transition: background-color var(--t-fast), color var(--t-fast);
}
.disclosure__trigger:hover { background: var(--accent-wash); color: var(--accent); }
.disclosure__trigger::after {
    content: ''; margin-left: auto; width: 8px; height: 8px; flex: none;
    border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
    transform: rotate(45deg) translateY(-2px);
    transition: transform var(--t-panel);
}
.disclosure__trigger[aria-expanded="true"]::after { transform: rotate(225deg) translateY(-2px); }
.disclosure__panel { padding: 0 1.1rem 1.1rem; }
.disclosure__panel[hidden] { display: none; }

/* ── Menu (replaces Bootstrap dropdown; behavior: behaviors/menu.js) ────── */
.menu { position: relative; display: inline-block; }
.menu__trigger {
    background: none; border: 0; color: inherit; cursor: pointer;
    font: inherit; display: inline-flex; align-items: center; gap: 0.4rem;
    padding: 0.4rem 0.6rem; border-radius: var(--radius-sm);
}
.menu__list {
    position: absolute; right: 0; top: calc(100% + 0.4rem);
    min-width: 12rem;
    background: var(--paper);
    border: 1px solid var(--rule);
    border-radius: var(--radius-md);
    box-shadow: 0 10px 26px -16px rgba(0,0,0,0.25);
    list-style: none; margin: 0; padding: 0.4rem;
    z-index: 50;
}
.menu__list[hidden] { display: none; }
.menu__list { animation: sf-menu-in var(--t-panel) both; }
@media (max-width: 639px) {
    /* The ≤639px table-card layout left-packs .cell-actions, so its trigger sits
       at the card's LEFT edge and desktop's `right: 0` hangs the 12rem list off
       the screen (measured on /devices/ at 375px: left edge at −24px). Anchor to
       the trigger's left there, capped to the viewport so a long menu cannot
       overflow the right either.
       SCOPED to .cell-actions: the /colors/ specimen card puts its ⋮ at the card's
       RIGHT edge (margin-left: auto), where `right: 0` is already correct. */
    .cell-actions .menu__list {
        right: auto;
        left: 0;
        max-width: calc(100vw - 2rem);
    }
}
/* A menu item is an <a> OR a <button> (§6.6): reset the button chrome so both
   read identically, and never rely on an id. */
.menu__item {
    display: block; width: 100%; text-align: left;
    padding: 0.5rem 0.7rem;
    font: inherit; font-size: 0.9rem; color: var(--ink);
    background: none; border: 0; border-radius: var(--radius-sm);
    cursor: pointer; text-decoration: none;
    transition: background-color var(--t-fast), color var(--t-fast);
}
.menu__item:hover, .menu__item:focus-visible {
    background: var(--accent-wash); color: var(--accent); text-decoration: none;
}
.menu__item--danger { color: var(--fail); }
.menu__item--danger:hover, .menu__item--danger:focus-visible {
    background: rgba(196,43,43,0.08); color: var(--fail);
}
.menu__sep { height: 1px; margin: 0.35rem 0; background: var(--rule); }
/* A <form> may wrap the item inside its <li>; it must not add layout. */
.menu__list li > form { margin: 0; }

/* ── Pills (issue read/unread, subscription state — NOT verdict colours) ─── */
.pill {
    display: inline-flex; align-items: center;
    white-space: nowrap;                     /* D-07: SIN LEER must never wrap */
    font-family: var(--font-mono);
    font-size: 0.66rem; letter-spacing: 0.08em; text-transform: uppercase;
    padding: 0.2rem 0.55rem; border-radius: 100px;
    background: var(--paper-100); color: var(--gray-700);
}
.pill--muted { background: var(--paper-100); color: var(--gray-500); }
.pill--alert { background: rgba(196, 43, 43, 0.12); color: var(--fail); }
.pill--warn  { background: rgba(166, 106, 0, 0.12); color: var(--warn); }
.pill--ok    { background: var(--accent-wash); color: var(--accent); }

/* ── Swatch grounds (rule 2) + verdicts (rule 3) ────────────────────────── */
.booth {
    background: #35353A; border-radius: var(--radius-md); padding: 1.5rem;
    --ink: #FFFFFF;
    --gray-600: rgba(255,255,255,0.68);
    --gray-500: rgba(255,255,255,0.58);
    --rule: rgba(255,255,255,0.18);
    --paper: #35353A; --paper-50: #2A2A2E;
}
/* OVERRIDE-1: the owner has decided AGAINST the neutral surround chip. The
   background below STAYS COMMENTED OUT — swatches sit directly on --paper, and an
   inset hairline (§6.4) is what keeps a light swatch from bleeding into it. */
.cell-swatch {
    display: inline-grid; place-items: center;
    padding: 4px; border-radius: var(--radius-sm);
    /*background: #35353A;              /* neutral surround, always */
}
.cell-swatch i {
    width: 22px; height: 22px; border-radius: 2px; display: block;
    background: var(--sw, transparent);
    box-shadow: inset 0 0 0 1px rgba(0,0,0,0.14);   /* inset: no added box size */
}
/* The /colors/ specimen: the same swatch, full-bleed across the card head. The
   inset hairline is what keeps a near-white reference from bleeding into --paper
   now that it is 118px tall instead of 22 (§6.4) — it is also the ONLY separation,
   per OVERRIDE-1 (no neutral ground). */
.cell-swatch--field { display: block; padding: 0; border-radius: 0; }
.cell-swatch--field i { width: 100%; height: 118px; border-radius: 0; }

.verdict {
    display: inline-flex; align-items: center; gap: 0.4rem;
    font-family: var(--font-mono);
    font-size: 0.68rem; letter-spacing: 0.1em; text-transform: uppercase;
    padding: 0.2rem 0.55rem; border-radius: 100px;
}
.verdict::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.verdict.is-pass { color: var(--pass); background: rgba(30,127,75,0.1); }
.verdict.is-warn { color: var(--warn); background: rgba(166,106,0,0.1); }
.verdict.is-fail { color: var(--fail); background: rgba(196,43,43,0.1); }

/* Density chips — the four process inks (replaces .Cyan/.Magenta/.Yellow/.Black) */
.density-chip {
    display: inline-block; padding: 0.15rem 0.5rem; border-radius: var(--radius-sm);
    font-size: 0.78rem; color: #fff;
}
.density-chip--c { background: var(--chan-c); color: #002; }
.density-chip--m { background: var(--chan-m); }
.density-chip--y { background: var(--chan-y); color: #2A1C00; }   /* dark glyph on amber */
.density-chip--k { background: var(--chan-k); }

/* ── Tolerance track — the Lab table signature (brief §3) ───────────────── */
/* verdict colour set on the cell so the bar (currentColor) + the pill agree. */
.cell-delta.is-pass { color: var(--pass); }
.cell-delta.is-warn { color: var(--warn); }
.cell-delta.is-fail { color: var(--fail); }
.cell-delta { white-space: nowrap; }
.delta-value { font-weight: 600; }
.delta-track {
    position: relative; display: block;
    height: 6px; margin-top: 0.35rem;
    background: var(--paper-100);
    border-radius: 100px;
    overflow: hidden;
}
.delta-track__bar {
    position: absolute; inset: 0 auto 0 0;
    width: calc(var(--delta-pct) * 1%);
    background: currentColor;              /* inherits the verdict colour */
    border-radius: 100px;
}
.delta-track__limit {
    position: absolute; top: -2px; bottom: -2px;
    left: calc(var(--limit-pct) * 1%);
    width: 1px;
    background: var(--ink);
}
.delta-formula {
    font-family: var(--font-mono); font-size: 0.62rem;
    letter-spacing: 0.1em; text-transform: uppercase; color: var(--gray-500);
}

/* ── Row states ──────────────────────────────────────────────────────────── */
.is-disabled { color: var(--muted-strike); text-decoration: line-through; }
.is-unread { box-shadow: inset 2px 0 0 var(--accent); }

/* ── Text utilities (replaces fw-bold / text-center / text-end) ──────────── */
.t-center { text-align: center; }
.t-right  { text-align: right; }
.t-strong { font-weight: 700; }

/* ── Empty state (design system: no illustration, one sentence, one action) */
.empty { padding: 2.5rem 0; }
/* D-08: the base .eyebrow is inline-flex, so the eyebrow and the sentence that
   follows it render run-together ("SIN MEDIDASNo hay datos…"). flex makes the
   eyebrow own its line without touching the markup. */
.empty .eyebrow { display: flex; margin-bottom: 0.6rem; }
.empty p { color: var(--gray-600); margin: 0 0 1rem; max-width: 60ch; }

/* ── Context bar (§5.3) — the picker becomes chrome ──────────────────────── */
/* Sticky under the masthead. It lives INSIDE the fragment swap target so the
   dependent selects keep being re-rendered server-side (zero client-side option
   building); the loading veil is therefore scoped to .results-body, never to the
   swap target, so this bar stays live and tappable during a swap (§7.3). */
.contextbar {
    position: sticky; top: var(--nav-height); z-index: 30;
    display: flex; align-items: flex-end; gap: 1rem; flex-wrap: wrap;
    min-height: var(--ctx-height);
    margin: 0 -2.5rem 1.5rem;              /* full-bleed inside the padded .content */
    padding: 0.5rem 2.5rem;
    background: var(--paper-50);
    border-bottom: 1px solid var(--rule);
}
.contextbar__form { display: flex; align-items: flex-end; gap: 0.5rem; flex-wrap: wrap; flex: 1; }
.contextbar__step { display: grid; gap: 0.15rem; min-width: 9rem; }
.contextbar__caption {
    font-family: var(--font-mono);
    font-size: 0.62rem; letter-spacing: 0.14em; text-transform: uppercase;
    color: var(--gray-500);
}
/* A bare-looking select that is still a real select: no box until you touch it. */
.contextbar__select {
    height: var(--ctrl-h-sm);
    padding: 0 1.6rem 0 0.5rem;
    font-family: var(--font-body); font-size: 0.9rem; font-weight: 500;
    color: var(--ink);
    background: transparent
        url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23807D8C' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E")
        no-repeat right 0.4rem center / 0.7rem;
    border: 1px solid transparent;
    border-radius: var(--radius-sm);
    appearance: none;
    transition: border-color var(--t-fast), background-color var(--t-fast);
}
.contextbar__select:hover    { border-color: var(--edge); background-color: var(--paper); }
.contextbar__select:disabled { color: var(--gray-500); cursor: not-allowed; }
/* The PENDING step — the one that unblocks the page — is the one control that looks
   like a control. Every satisfied step stays bare, which is the §5.3 thesis (the
   picker is chrome once you have used it) applied honestly: it is only chrome when
   there is something behind it. Measured on an empty /logs/, the live `cliente` and
   the disabled `trabajo` both rendered `—` with a transparent border and ground, so
   the whole difference between the one usable control and two dead ones was a grey
   level (#1E0B3D vs #807D8C) on an identical em-dash.
   Static on purpose: motion.css must stay droppable, so the state that CARRIES the
   information cannot live in an animation. Declared after :hover, which it ties at
   (0,2,0) — hover sets the same two properties, so hovering a pending step is a no-op. */
.contextbar__step--pending .contextbar__caption { color: var(--accent); }
.contextbar__step--pending .contextbar__select {
    border-color: var(--edge); background-color: var(--paper);
}
/* A sticky bar must never be sized by the longest client name. `.contextbar__step`
   sets a min-width but nothing capped the select, so its width is the widest option's
   text. Measured at 375px with 100 realistic options (`Industrias Gráficas San Marcos
   47`), .contextbar height by cap:
     none 244.0 | 12rem 244.0 | 11rem 185.3 | 10rem 126.5 | 9rem 126.5 | 8rem 126.5
   10rem is the knee: the largest cap that restores the bar to the 126.5px it has with
   4 short clients (-117.5px, -48% — and the bar is sticky, so that is 30% -> 15.6% of
   an 812px phone reclaimed on EVERY screen, not just the first). 160px still shows
   ~18 characters. The longest DEV option today is 144px, under the cap, so nothing
   moves at the current data size. */
.contextbar__select { max-width: 10rem; text-overflow: ellipsis; }
.contextbar__sep    { color: var(--gray-500); padding-bottom: 0.45rem; }
.contextbar__counts { margin: 0 0 0.35rem; font-size: 0.82rem; color: var(--gray-600); }
.contextbar__counts .num { color: var(--ink); font-weight: 600; }
/* Tone colours the FIGURE only — rule 3 holds, these are tolerance outcomes. */
.contextbar__counts .num.is-pass { color: var(--pass); }
.contextbar__counts .num.is-fail { color: var(--fail); }
.contextbar__countlabel { font-size: 0.78rem; }
/* The transient "· 4" a refilled caption gains after a cascade (§7.2). */
.contextbar__count-flash { color: var(--accent); animation: sf-fade-out 3s var(--ease) 1 forwards; }
/* The negative margin must track .content's padding or the bar overflows the
   viewport and breaks the no-horizontal-scroll assertion (§9.1). */
@media (max-width: 860px) { .contextbar { margin-inline: -1.5rem; padding-inline: 1.5rem; } }
@media (max-width: 640px) {
    .contextbar { margin-inline: -1rem; padding-inline: 1rem; }
    /* The `›` chain reads as a path on ONE line. Once the form wraps — the normal
       state on a phone at any real client count — a separator strands at a line start
       or end, pointing at nothing. The captions already name every step, so drop them
       rather than try to detect the wrap (CSS cannot). */
    .contextbar__sep { display: none; }
}

/* ── Read progress (§5.5) — 2px spectrum flush under the masthead ────────── */
/* No JS: a scroll-driven animation behind an @supports guard. Where
   animation-timeline is unsupported it stays scaleX(0) — an invisible 2px line.
   The keyframes live in motion.css with the rest of the motion layer.
   Anchored to the masthead's bottom edge (top: --nav-height), NOT to
   --nav-height + --ctx-height: --ctx-height is the context bar's MIN-height, not
   its height — the bar wraps to 67.75px desktop / 126.5px at 375px, so the old
   offset landed the line INSIDE the bar and it was 92% covered (measured on
   /colors/ at 1344px). z-index 41 clears the context bar (30) AND the masthead
   (40) so the masthead's box-shadow does not dim it. pointer-events: none because
   transform: scaleX() shrinks the HIT BOX as well as the paint — without it the
   filled portion would intercept clicks along the bar's top edge and the unfilled
   portion would not (the same dead-strip failure mode as .btn-primary::before in
   5dcfcd1). */
.read-progress {
    position: sticky; top: var(--nav-height); z-index: 41;
    pointer-events: none;
    height: 2px; border: 0; margin: 0;
    background: var(--spectrum);
    transform-origin: left; transform: scaleX(0);
}
@supports (animation-timeline: scroll(root block)) {
    .read-progress {
        animation: sf-read-progress linear both;
        animation-timeline: scroll(root block);
    }
}

/* ── The shift strip (§6.5) — the signature ──────────────────────────────── */
/* Same maths as the per-row tolerance track (annotate_labs): scale = 1.5 × limit,
   pct = min(100, 100·delta/scale), limit_pct = 66.7. No new maths here. */
.strip { margin: 0 0 1.5rem; }
.strip__head {
    display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap;
    margin-bottom: 0.5rem;
}
.strip__formula { font-size: 0.72rem; color: var(--gray-500); }
.strip__counts { margin-left: auto; font-size: 0.82rem; color: var(--gray-600); }
.strip__counts .num { color: var(--ink); font-weight: 600; }
.strip__axis {
    position: relative; height: 26px;
    border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule);
    background: var(--paper-50);
}
.strip__limit {
    position: absolute; top: -3px; bottom: -3px;
    left: calc(var(--limit-pct) * 1%); width: 1px; background: var(--ink);
}
.strip__tick {
    position: absolute; top: 4px; bottom: 4px;
    left: calc(var(--pct) * 1%); width: 3px; margin-left: -1px;
    border-radius: 2px; background: currentColor;
    opacity: 0.72;
    transition: opacity var(--t-fast), transform var(--t-fast);
}
.strip__tick:hover, .strip__tick:focus-visible { opacity: 1; transform: scaleY(1.25); }
.strip__tick.is-pass { color: var(--pass); }
.strip__tick.is-warn { color: var(--warn); }
.strip__tick.is-fail { color: var(--fail); }
/* The two END labels are pinned to the axis ends by the flex row — that is the
   axis convention and it is correct. The MIDDLE label names the limit, so it must
   sit on the limit LINE, and space-between cannot do that: it distributes by item
   count, parking the middle label at ~50% while .strip__limit is at limit_pct
   (always 66.7 — the scale is 1.5 × limit). Measured on /logs/ at 1344px: line at
   x=773.7, label centre at x=571.1 — 202.6px / 17.5% of the axis apart, and the
   error is structural, not data-dependent. So the middle label leaves the flow and
   reads the SAME --at the line reads, which is why the two cannot drift again. */
.strip__scale {
    position: relative;
    display: flex; justify-content: space-between; margin: 0.25rem 0 0;
    font-size: 0.62rem; color: var(--gray-500);
}
/* Out of flow, so the row keeps its natural height (16.36px, measured before and
   after — zero layout shift) and the two end labels still land on the ends. */
.strip__scale .num--at {
    position: absolute; left: calc(var(--at) * 1%); transform: translateX(-50%);
}

/* ── Skeleton (§6.10 / §7.3) ─────────────────────────────────────────────── */
/* The shimmer animation itself is in motion.css; without it these are flat
   --skeleton blocks, which is a correct degradation. */
.skeleton {
    background: var(--skeleton); background-size: 200% 100%;
    border-radius: var(--radius-sm); color: transparent !important;
}
.skeleton-rows { display: grid; gap: 0.5rem; }
.skeleton-rows > i {
    display: block; height: var(--row-h);
    background: var(--skeleton); background-size: 200% 100%;
}

/* ── Modal (§6.7) — replaces window.confirm ──────────────────────────────── */
/* Native <dialog>: showModal() supplies the focus trap, Escape, backdrop and
   inertness for free. One dialog per page, filled from the trigger's
   data-sf-confirm-* attributes by behaviors/modal.js. */
.modal {
    max-width: 560px; width: calc(100% - 2rem);
    padding: 0; border: 0; border-radius: var(--radius-md);
    background: var(--paper); color: var(--gray-700);
    overflow: hidden;
}
.modal::backdrop { background: rgba(35, 35, 38, 0.97); backdrop-filter: blur(8px); }
.modal[open] { animation: sf-modal-in 0.25s var(--ease) both; }
.modal__edge { height: 3px; border: 0; margin: 0; background: var(--fail); }
.modal .eyebrow { padding: 1.25rem 1.5rem 0; }
.modal .eyebrow--bare::before { display: none; }   /* no accent swatch in a dialog */
.modal__body { padding: 0.6rem 1.5rem 1.25rem; margin: 0; max-width: 60ch; color: var(--gray-700); }
.modal__actions {
    display: flex; justify-content: flex-end; gap: 0.5rem;
    padding: 1rem 1.5rem;
    background: var(--paper-50); border-top: 1px solid var(--rule);
}
.modal--wide { max-width: 720px; }

/* ── Nav sheet (§9.2) — small-screen navigation ──────────────────────────── */
/* Full-width, slides down from the masthead; NOT a drawer. behaviors/sheet.js
   owns [hidden], `inert` on .content, the burger glyph and the scroll lock.
   CSS only guarantees it can never appear on a wide viewport. */
.sheet {
    position: fixed; inset: var(--nav-height) 0 0; z-index: 45;
    display: flex; flex-direction: column;
}
.sheet[hidden] { display: none; }
@media (min-width: 861px) { .sheet { display: none; } }
.sheet__scrim { position: absolute; inset: 0; background: rgba(21, 9, 31, 0.55); border: 0; }
.sheet__panel {
    position: relative; z-index: 1;
    max-height: 100%; overflow-y: auto;
    background: var(--paper);
    border-bottom: 1px solid var(--rule);
    box-shadow: 0 18px 40px -24px rgba(21, 9, 31, 0.45);
    animation: sf-menu-in var(--t-panel) both;
}
.sheet__list { list-style: none; margin: 0; padding: 0.5rem 0 1rem; }
.sheet__link {
    display: flex; align-items: center;
    min-height: 48px;                       /* touch target (D-26) */
    padding: 0 1.25rem;
    font-family: var(--font-mono);
    font-size: 0.78rem; letter-spacing: 0.14em; text-transform: uppercase;
    color: var(--ink); text-decoration: none;
    transition: background-color var(--t-fast), color var(--t-fast);
}
.sheet__link:hover, .sheet__link:focus-visible {
    background: var(--accent-wash); color: var(--accent); text-decoration: none;
}
.sheet__link.is-active { color: var(--accent); box-shadow: inset 2px 0 0 var(--accent); }
.sheet__caption {
    padding: 1rem 1.25rem 0.35rem;
    font-family: var(--font-mono);
    font-size: 0.62rem; letter-spacing: 0.16em; text-transform: uppercase;
    color: var(--gray-500);
}
.sheet__sep { height: 1px; margin: 0.5rem 1.25rem; background: var(--rule); }

/* ── Phase E: admin-surface additions ───────────────────────────────────── */

/* The file control (§10.10). The native "Choose File" chrome is the last piece
   of browser UI in a migrated form; a visually-hidden input + a real <label>
   button replaces it with zero JS. The input must PRECEDE the label so the
   keyboard focus ring can be forwarded with the sibling selector. */
.field__file { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.field__file input[type="file"] {
    position: absolute; width: 1px; height: 1px; padding: 0; border: 0;
    overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap;
}
.field__file > label { cursor: pointer; margin: 0; }
.field__file input[type="file"]:focus-visible + label {
    outline: 2px solid var(--violet); outline-offset: 2px;
}
.field__file-name {
    font-family: var(--font-mono); font-size: 0.72rem;
    letter-spacing: 0.06em; color: var(--gray-500);
}

/* Sticky action bar (§10.10) — Guardar stays reachable on a two-screen form.
   `bottom: 0` inside the normal flow: the bar scrolls with the page until it
   would leave the viewport, then pins. */
.form-actions {
    position: sticky; bottom: 0; z-index: 10;
    display: flex; justify-content: flex-end; align-items: center;
    gap: 0.6rem; flex-wrap: wrap;
    margin-top: 1.5rem; padding: 0.9rem 1.5rem;
    background: var(--paper);
    border: 1px solid var(--rule); border-radius: var(--radius-md);
    box-shadow: 0 -12px 26px -22px rgba(0, 0, 0, 0.55);
}

/* The issue backlog dump (§10.9). A single long JSON line must scroll INSIDE
   the panel — otherwise it widens the document and breaks the §9.1 no-overflow
   rule at every breakpoint. */
.backlog {
    margin: 0; max-height: 30rem; overflow: auto;
    padding: 0.9rem 1rem;
    font-family: var(--font-mono); font-size: 0.76rem; line-height: 1.55;
    color: var(--gray-700); background: var(--paper-50);
    border: 1px solid var(--rule); border-radius: var(--radius-sm);
}

/* Phase F: the `.field input` / `.field select` fallback these rules had to
   outrank is deleted, so the plain field__* selectors above now win on their
   own and this specificity workaround is no longer needed. */

/* ── /panel/ fleet activity + .hourband (plan §4–5) ────────────────────────── */
.panel-meta {
    margin: 0.35rem 0 0;
    font-size: 0.85rem; color: var(--gray-600);
}

/* The fleet is ONE chart: every device is a ROW on a shared time axis, not a
   632px card carrying its own axis. --fleet-cols / --fleet-areas are declared
   once on .fleet and consumed by BOTH the scale header and every summary row, so
   the axis is aligned to the bands it labels BY CONSTRUCTION and the three
   responsive tiers only have to redeclare two custom properties. */
.fleet {
    --fleet-cols: minmax(5rem, 12rem) minmax(0, 1fr) 4.25rem 4.25rem 8rem;
    --fleet-areas: "name band total fail last";
    padding: 1.1rem 1.5rem 1.35rem;
}
.fleet__scale,
.fleet__summary {
    display: grid;
    grid-template-columns: var(--fleet-cols);
    grid-template-areas: var(--fleet-areas);
    gap: 0 0.75rem;
    align-items: center;
}
.fleet__scale { padding-bottom: 0.45rem; border-bottom: 1px solid var(--rule); }
/* Five evenly-spaced ticks over a LINEAR 24-cell axis, so space-between is the
   right distribution: 6 hours apart, ends flush with the band's own edges. Label
   width still shifts each centre a little (measured at 375px: 4.4 / 27.6 / 50.8 /
   72.9 / 95.3% against 0/25/50/75/100), which is fine for orientation marks whose
   job is "roughly this far back". It would NOT be fine for a value line — the
   /logs/ tolerance strip had to abandon space-between for exactly this reason, but
   there the error was 17.5% and the label named a specific limit. */
.fleet__axis {
    grid-area: band;
    display: flex; justify-content: space-between;
    margin: 0; font-size: 0.7rem; color: var(--gray-600);
}
.fleet__cap {
    margin: 0; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.09em;
    color: var(--gray-600); text-align: right;
}
.fleet__cap--total { grid-area: total; }
.fleet__cap--fail  { grid-area: fail; }
.fleet__cap--last  { grid-area: last; }

.fleet__rows { list-style: none; margin: 0; padding: 0; }
.fleet__row + .fleet__row { border-top: 1px solid var(--rule); }
.fleet__summary { padding: 0.45rem 0; }

.fleet__name { grid-area: name; margin: 0; min-width: 0; }
.fleet__toggle {
    display: flex; align-items: center; gap: 0.5rem; width: 100%;
    padding: 0.25rem 0; border: 0; background: none;
    font-family: var(--font-display); font-size: 0.95rem; font-weight: 700;
    color: var(--ink); text-align: left;
    overflow-wrap: anywhere;
}
button.fleet__toggle { cursor: pointer; }
button.fleet__toggle:hover { color: var(--accent); }
/* The disclosure chevron, same shape as .disclosure__trigger::after. As a
   pseudo-element of a FLEX container it is a flex item, so margin-left:auto pins
   it to the name column's right edge at every width. */
button.fleet__toggle::after {
    content: ''; margin-left: auto; width: 7px; height: 7px; flex: none;
    border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
    transform: rotate(45deg) translateY(-2px);
    transition: transform var(--t-panel);
}
button.fleet__toggle[aria-expanded="true"]::after { transform: rotate(225deg) translateY(-2px); }

.fleet__summary .hourband { grid-area: band; }

.fleet__fig { margin: 0; text-align: right; font-size: 0.85rem; }
.fleet__fig--total { grid-area: total; }
.fleet__fig--fail  { grid-area: fail; }
/* --gray-600, NOT --gray-500: this carries the one fact an idle device can state
   ("Último uso: hace 44 d") and --gray-500 on --paper measures 4.02:1, under the
   4.5:1 AA floor. --gray-600 is 6.81:1. */
.fleet__fig--last { grid-area: last; font-size: 0.75rem; color: var(--gray-600); }
.fleet__fig .num { font-family: var(--font-mono); font-weight: 600; color: var(--ink); }
.fleet__zero { color: var(--gray-600); }

.fleet__detail { padding: 0 0 1rem; }
.fleet__detail-body { min-height: 0; }   /* the 0fr->1fr open animation clips here */
.fleet__serial { margin: 0 0 0.6rem; font-size: 0.8rem; color: var(--gray-600); }

/* The signature element: 24 hourly cells, normalised per device (plan §5). The
   `4px` floor makes an empty hour a faint tick, so the band reads as a TIMELINE,
   not a row of gaps. --rule is the lightest token available (there is no
   --gray-300). is-fail is declared AFTER is-active so a rejection hour wins on
   equal specificity (0,2,0) — no !important. */
.hourband { display: flex; align-items: flex-end; gap: 2px; height: 34px; }
.hourband__cell {
    flex: 1 1 0; min-width: 0;
    height: calc(4px + (var(--n, 0) * 30px));
    background: var(--rule);
    border-radius: 1px;
}
/* min-height, because the 4px floor and the low end of the scale collided: the
   cells are normalised against the DEVICE's own peak, so on a device peaking at
   100 a 1-reading hour computes to 4.3px — 0.3px above an EMPTY hour — in an
   accent measured at 2.16:1 against --paper (WCAG 1.4.11 wants 3:1 for a
   meaningful graphic). "Did anything happen at 03:00?" was unanswerable. 10px
   reads as a deliberate mark at any count; the heights above it still carry the
   magnitude. is-fail cells are always is-active too (a rejection implies a
   reading), so this covers both. */
.hourband__cell.is-active { background: var(--accent); min-height: 10px; }
.hourband__cell.is-fail   { background: var(--fail); }
/* .hourband__axis is GONE — it was 24 copies of one axis, one per device card.
   The fleet grid carries a single .fleet__axis in the scale header instead. */

.fleet__idle { margin: 0 0 0.6rem; font-size: 0.85rem; color: var(--gray-600); }

/* .fleet__types is ALSO a .data-table, so the ≤639px card block + .cell-num
   alignment + data-label mobile labels all apply for free; this rule only owns
   the desktop column-header tone and a width cap. No `display` change here — the
   table stays a table (review checklist: never flex a live <td>). */
.fleet__types { margin: 0.6rem 0 0; max-width: 26rem; }
.fleet__types th {
    font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.1em;
    color: var(--gray-500);
}

.fleet__jobs { list-style: none; margin: 0.4rem 0 0; padding: 0; }
.fleet__jobs li {
    display: flex; align-items: baseline; gap: 1rem;
    padding: 0.4rem 0; border-bottom: 1px solid var(--rule);
}
.fleet__jobs li:last-child { border-bottom: 0; }
/* The job name is an <a> to /logs/. Ink, not link-blue: it sits in a list of
   names, and the underline on hover/focus is the affordance. */
.fleet__job-name { flex: 1 1 auto; min-width: 0; color: var(--ink); text-decoration: none; }
.fleet__job-name:hover,
.fleet__job-name:focus-visible { text-decoration: underline; }

/* Three tiers, two custom properties each — the scale header and every row read
   the same pair, so they cannot fall out of alignment.

   640–899px: the five columns no longer leave the band a readable width (at 800px
   it was 173px for 24 cells = 7.2px each), so the band takes a full-width second
   row and the figures keep the first.
   ≤639px: the name needs the full width too, so it takes a row of its own and the
   figures drop below the band. */
@media (max-width: 899px) {
    .fleet {
        --fleet-cols: minmax(0, 1fr) 4rem 4rem 7rem;
        --fleet-areas: "name total fail last"
                       "band band  band band";
    }
    .fleet__scale, .fleet__summary { row-gap: 0.3rem; }
    .fleet__summary { padding: 0.7rem 0; }
}
@media (max-width: 639px) {
    .fleet {
        --fleet-cols: minmax(0, 1fr) auto auto;
        --fleet-areas: "name  name  name"
                       "band  band  band"
                       "last  total fail";
    }
    .fleet { padding: 1rem 1.1rem 1.2rem; }
    /* The captions are rows away from their figures once the columns stack, so the
       figures carry their own labels instead — see .fleet__fig-label. */
    .fleet__cap { display: none; }
    .fleet__fig--last { text-align: left; }
    .fleet__fig--total, .fleet__fig--fail { padding-left: 0.9rem; }
}

/* ONE element, both jobs: the accessible label for the figure at every width, and
   the VISIBLE label once .fleet__cap is gone. Never both at once, so a screen
   reader never hears the word twice — which a data-label ::before beside an
   .sr-only span would have caused. (0,2,0) beats .sr-only's (0,1,0). */
@media (max-width: 639px) {
    .sr-only.fleet__fig-label {
        position: static; width: auto; height: auto; margin: 0;
        overflow: visible; clip: auto; white-space: nowrap;
        font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.08em;
        color: var(--gray-600);
    }
}
.fleet__jobs .cell-num { flex: 0 0 auto; }

/* ── /logs/ feed rows (plan §3.4) ──────────────────────────────────────────── */
.picknext-feed { margin: 0 0 1.4rem; }
.picknext-feed .eyebrow { display: flex; margin-bottom: 0.4rem; }
.picklist--feed { max-height: none; overflow: visible; border: 0; }
/* display:flex on an <a> (not a <td>) is safe — the no-flex-a-<td> rule is about
   live table cells. The name takes the line; the meta rides the right edge. */
.picklist--feed .picklist__item {
    display: flex; align-items: baseline; gap: 0.75rem;
    border-bottom: 0; padding-left: 0;
}
.picklist__name { flex: 1 1 auto; min-width: 0; }
/* .picklist__meta already exists (~L557); the feed reuses it verbatim. */
