/*
 * VPS Lab — visual identity.
 *
 * Phase 1 established the palette/typography extracted from the original prototype
 * (see VISUAL-IDENTITY.md). This file is the §106 redesign of that layer: same brand,
 * same class names, rebuilt on explicit scales (space / radius / elevation / ink) so
 * hierarchy comes from a system instead of from ad-hoc values.
 *
 * Two decisions here were the user's, not the author's, because VISUAL-IDENTITY.md
 * gates them:
 *   1. The brand face is SENTIC — the typeface of the logo itself, now carrying the
 *      whole hierarchy: titles, subtitles and body copy (VISUAL-IDENTITY.md §2).
 *      Neulis was wired here in §106 and is UNWIRED again, on measured evidence:
 *      it is not the logo face and its lowercase is semi-cursive by design. It is
 *      served from 'self' — no CDN, so the CSP stays 'self' and the PWA works
 *      offline. See the Brand face block for the full finding.
 *   2. The sidebar stays brand navy (deepened), so the white logo variant required by
 *      VISUAL-IDENTITY.md §3 remains the correct one.
 *
 * House rule, from the brief: NOTHING is underlined — not links, not buttons, not text.
 * Affordance is carried by colour, weight, fill and the focus ring instead.
 *
 * No inline styles anywhere in the views: this is what lets core/Security.php drop
 * 'unsafe-inline' from style-src.
 */

/* ---- Brand face ---------------------------------------------------------- */
/* Sentic Text Medium IS the logo's typeface. Verified, not assumed: the wordmark
   art in assets/images/logo-vps.png was cropped and set beside this file at
   matched size, and the letterforms of "Laboratorio de Biometria Vocal" are
   identical — the plain single-storey 'a', the straight 'l', the straight 'r'.
   So the app now speaks in the same voice as the mark above it.

   Neulis was wired here briefly and is now UNWIRED, on evidence: it is not the
   logo face, and its lowercase is decorative by design — a reversed 'r', a
   curled cursive 'l', a hooked 't' ("laboratorio" sets as "ℓaboᴎatoᴎio"). That
   is a display personality, not a text face, and it fails the brief's legibility
   bar. It is not fixable through OpenType either: the family's stylistic sets
   were decoded and ss02/ss03/ss04 only touch uppercase M/Q/J, while 'swsh' adds
   swashes — nothing normalizes the lowercase. The files stay in fonts/ unused.

   ONE real weight ships (Text Medium), and that is deliberate here rather than a
   limitation worked around: 300/400/500 all resolve to the real design with NO
   synthesis, while 600/700 are synthesized by the browser. The type scale below
   is built on exactly that split — see the "Weight strategy" note under Reset /
   base.

   The brief asked for a face that is LEGIBLE and NOT cursive. That is now settled
   by reading the font's own tables rather than by looking at it:

     italicAngle    0.0        post table  — upright, no slant
     macStyle       bit1 = 0               — not flagged italic
     fsSelection    bit0/bit9 = 0          — neither italic nor oblique
     x-height       0.546 em   OS/2        — generous; the single strongest
                                             predictor of small-size legibility
     cap-height     0.773 em
     unitsPerEm     1000

   So Sentic is upright by construction, and its large x-height is why it holds up
   at the 13-15px this app spends most of its type budget on. This is the opposite
   of the Neulis finding above, which was a slanted-feeling DESIGN at italicAngle 0.

   Coverage was read the same way (cmap): 251 codepoints. Every accent the three
   locales need is present — á é í ó ú ü ñ ã õ ç â ê ô à and the ¿ ¡ « » — so no
   Spanish or Portuguese word ever falls back mid-string. Three characters the
   catalogs DO emit are absent — μ (U+03BC), σ (U+03C3) and → (U+2192) — and the
   stack below renders those one glyph from system-ui. Harmless on screen; see
   VISUAL-IDENTITY.md §2 for why the same three are NOT harmless in the PDF. */
@font-face {
  font-family: 'Sentic';
  src: url('fonts/Sentic-Text-Medium.otf') format('opentype');
  /* Declared as the 400-500 RANGE rather than a bare 400, because that is what the
     file actually is: its own OS/2 usWeightClass is 500 (Medium), so a bare 400
     asserted a weight this design does not contain. The range maps BOTH the body
     400 and the heading 500 onto the real outlines explicitly, instead of leaving
     it to each engine's fallback matching to arrive there. 600 still synthesizes,
     deliberately — see Weight strategy. */
  font-weight: 400 500; font-style: normal; font-display: swap;
}

/* ⚠️ ONE BROKEN GLYPH IN THE FONT FILE, ROUTED AROUND HERE (§134). Sentic's `oacute`
   (U+00F3) is DRAWN WITHOUT ITS ACCENT: the file renders "código" as "codigo", "sesión"
   as "sesion", "versión" as "version". Measured, not eyeballed — rendering the file
   through FreeType reproduces it, and Chrome shows the same thing on the live page.

   It is exactly one glyph and nothing else: `´` alone, the uppercase `Ó`, and every
   other accent the three locales use (á é í ú ñ ã õ ç â ê ô à) draw correctly, so the
   whole o-family ò ô õ ö ø is fine. The `cmap` is NOT the fault either — U+00F3 maps to
   its own glyph id (178, distinct from `o`'s 81); that glyph's outline is simply missing
   the mark. This is why the browser's normal fallback never fires: a font only yields a
   character it does not CLAIM, and this one claims it.

   Blast radius: 213 occurrences across 156 lines of the Spanish catalog and 108 across 91
   of the Portuguese — i.e. an orthographic error on nearly every screen in two of the three
   languages. The signed PDF is NOT affected (helpers/ReportPdf.php sets no body face, so
   TCPDF composes it in core Helvetica), which is why this is a screen fix only.

   The fix declares a SECOND face for the same family whose unicode-range is that single
   codepoint. Declaration order is load-bearing: overlapping ranges resolve to the LAST
   matching @font-face, so this must stay BELOW the face above, which claims everything.
   `local()` only — the CSP is 'self' and no CDN is ever allowed; the list covers Windows,
   macOS and the common Linux desktops, and if a client has none of them the face simply
   fails to load and the character falls through to the next family in the stack, which is
   system-ui. Either way the accent is drawn. The trade accepted: that one character is set
   in a neutral grotesque instead of Sentic — a barely visible mismatch at 13-15 px, against
   a misspelling in a forensic tool, which is not a typographic question at all.

   THIS IS A STOPGAP FOR A DEFECTIVE ASSET. The real fix is a corrected font file; nothing
   on this box can rebuild one (no fonttools, no pip3), and hand-patching the CFF charstring
   was rejected — `o` and `oacute` are 3-byte subroutine calls, so a repair means rewriting
   the CharStrings INDEX offsets and every table offset after it, in a binary whose failure
   mode is the entire UI. Delete this block the day the file is replaced. */
@font-face {
  font-family: 'Sentic';
  src: local('Segoe UI'), local('Helvetica Neue'), local('Helvetica'),
       local('Arial'), local('Liberation Sans'), local('DejaVu Sans');
  unicode-range: U+00F3;
  font-weight: 400 500; font-style: normal;
}

:root {
  /* ---- Neutral ramp -----------------------------------------------------
     Tinted toward the brand navy (hue ~218) rather than pure grey, so neutrals
     and brand read as one family instead of two. */
  --n-0:   #ffffff;
  --n-25:  #fbfcfd;
  --n-50:  #f6f8fa;
  --n-100: #eef1f5;
  --n-150: #e4e9ef;
  --n-200: #d7dee7;
  --n-300: #bcc7d4;
  --n-400: #94a3b6;
  --n-500: #6b7c93;
  --n-600: #4e5f78;
  --n-700: #36465e;
  --n-800: #233248;
  --n-900: #141f38;

  /* ---- Brand ------------------------------------------------------------
     #172a4c is contractual: it is the manifest/meta theme-color and the surface
     the white logo variant is designed for. --brand-navy-deep is its darker
     sibling, used for the rail so the rail sits behind the content, not on it. */
  --brand-navy:       #172a4c;
  --brand-navy-deep:  var(--n-900);
  --brand-blue:       #0071bc;
  --brand-blue-600:   #005f9e;
  --brand-blue-700:   #004d80;
  --brand-teal:       #68b7a4;
  --brand-teal-600:   #4f9e8c;

  /* ---- Ink (three levels; hierarchy stops depending on size alone) -------
     ink-1 and ink-3 are their own values, not ramp steps: the primary ink is a
     touch warmer than --n-900 so long-form text does not read as pure chrome, and
     ink-3 is set one notch darker than --n-500 for a measured reason — EVERY use of
     it is small text (11-14px: .muted, .card-label, the uppercase table header and
     section labels, .auth-note), so the 3:1 large-text allowance never applies. It
     clears 4.5:1 against BOTH grounds it lands on: 4.82 on --surface and 4.53 on
     --surface-2. A lighter tertiary ink looks calmer and fails WCAG AA. */
  --ink-1: #16233a;
  --ink-2: var(--n-600);
  --ink-3: #647389;
  --ink-inv: var(--n-0);

  /* ---- Surfaces / lines --------------------------------------------------
     Derived from the ramp so there is one place to retune the neutrals. --canvas
     is deliberately its own value: it sits just below --n-50 so a white card has
     something to lift off. */
  --canvas:        #f4f6f9;
  --surface:       var(--n-0);
  --surface-2:     var(--n-50);
  --surface-sunken:var(--n-100);
  --line:          var(--n-150);
  --line-strong:   var(--n-200);

  /* ---- Semantic: each state is a triplet (ink / tint / edge) --------------
     Modulated toward the neutral hue instead of the raw Bootstrap/Material
     primaries, which is what made the old alerts read as 2016 defaults. */
  --ok-ink:   #0f7a43; --ok-bg:   #eaf6ef; --ok-edge:   #bfe3ce;
  --warn-ink: #8a5a00; --warn-bg: #fdf4e3; --warn-edge: #f0dcae;
  --err-ink:  #b4232b; --err-bg:  #fdeeee; --err-edge:  #f4c9c9;
  --info-ink: #0b5c96; --info-bg: #eaf3fb; --info-edge: #c3ddf1;

  /* Solid grounds for the two destructive button variants. Their own tokens rather
     than raw hex, because the previous values were prototype leftovers that were
     never measured: white on the old .btn-danger #d9534f is 3.96:1, i.e. BELOW the
     4.5:1 WCAG AA floor, and it was the only button variant in the app that failed.
     These are on the same neutral-modulated ramp as the alert inks above.
       white   on --danger-solid #b4232b  6.53:1
       #3a2600 on --warn-solid   #e0a020  6.33:1                                  */
  --danger-solid: #b4232b; --danger-solid-600: #9c1e25; --danger-solid-700: #8f1b21;
  --warn-solid:   #e0a020; --warn-solid-600:   #cc9018; --warn-solid-700:   #c08a14;
  --warn-solid-ink: #3a2600;

  /* ---- Space scale (4px base) ------------------------------------------- */
  --s1: .25rem; --s2: .5rem;  --s3: .75rem; --s4: 1rem;
  --s5: 1.5rem; --s6: 2rem;   --s7: 3rem;   --s8: 4rem;

  /* ---- Radius scale ------------------------------------------------------ */
  --r-xs: 6px; --r-sm: 8px; --r-md: 10px; --r-lg: 14px; --r-xl: 20px; --r-full: 999px;

  /* ---- Elevation --------------------------------------------------------
     Layered and y-offset: a tight contact shadow plus a wide diffuse one, so
     surfaces sit at distinguishable depths. The old single `0 0 10px` had no
     offset and was applied to everything, which is why nothing had elevation. */
  --e1: 0 1px 2px rgba(20, 31, 56, .05), 0 1px 1px rgba(20, 31, 56, .04);
  --e2: 0 2px 4px rgba(20, 31, 56, .05), 0 8px 20px -8px rgba(20, 31, 56, .14);
  --e3: 0 10px 30px -10px rgba(20, 31, 56, .22), 0 2px 6px rgba(20, 31, 56, .06);
  --ring: 0 0 0 3px rgba(0, 113, 188, .22);

  /* ---- Motion ------------------------------------------------------------ */
  --t-fast: 120ms cubic-bezier(.2, 0, .2, 1);
  --t:      180ms cubic-bezier(.2, 0, .2, 1);

  /* ---- Typography -------------------------------------------------------- */
  --font-body:    'Sentic', system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
  --font-heading: 'Sentic', system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
  --font-code:    ui-monospace, 'SF Mono', 'Consolas', 'Courier New', monospace;

  /* Line measure — a measured number, not a taste call. The advance widths were
     parsed straight out of Sentic-Text-Medium.otf (cmap + hmtx, unitsPerEm 1000)
     over real ES/EN/PT sentences from lang/*.php: the face averages 0.5088 em per
     character. At the 15px body size that puts an UNCONSTRAINED paragraph in the
     content column (--content-max, 92rem = 1472px) at ~193 characters per line —
     about three times the 45-75 a reader can track before the return sweep starts
     landing on the wrong line. Tables genuinely need that width; prose never does,
     so prose alone is capped. 36rem = 576px = ~75 characters, derived from the same
     0.5088 em, i.e. the widest setting still inside the comfortable range. */
  --measure: 36rem;

  /* ---- Layout metrics ---------------------------------------------------- */
  --sidebar-width: 258px;
  --content-max: 92rem;
  --topbar-h: 4rem;
  --control-h: 2.75rem;     /* 44px — the minimum comfortable touch target */

  /* ---- Legacy token aliases ---------------------------------------------
     VISUAL-IDENTITY.md documents these names and future code may reach for
     them, so they keep resolving — now onto the system above rather than to
     loose values. --color-text-muted is defined here for the first time: it was
     referenced by .badge-muted and had never existed (it silently fell back to
     a hardcoded literal). */
  --color-primary-navy: var(--brand-navy);
  --color-primary-blue: var(--brand-blue);
  --color-primary-blue-dark: var(--brand-blue-600);
  --color-accent-teal: var(--brand-teal);
  --color-accent-teal-dark: var(--brand-teal-600);
  --color-background-light: var(--canvas);
  --color-white: var(--n-0);
  --color-warning: #e0a020;  --color-warning-dark: var(--warn-ink);
  --color-danger:  #d9534f;  --color-danger-dark:  var(--err-ink);
  --color-success: var(--ok-ink);   --color-error: var(--err-ink);
  --color-info:    var(--info-ink); --color-alert: #e0a020;
  --color-info-cyan: #17849b;
  --color-border-light: var(--line-strong); --color-border-medium: var(--line);
  --color-row-stripe: var(--surface); --color-row-hover: var(--surface-2);
  --color-result-bg: var(--surface-2); --color-result-border: var(--line);
  --color-text-secondary: var(--ink-2); --color-text-placeholder: var(--ink-3);
  --color-text-muted: var(--ink-2);
  --color-waveform-primary: #0097a7; --color-waveform-progress: #ff4081;
  --card-radius: var(--r-lg); --button-radius: var(--r-sm);
  --grid-gap: var(--s5); --scrollbar-width: 12px;
  --shadow-soft: var(--e1);
  --theme-color: #172a4c;
}

/* ---- Reset / base ------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
html, body { height: 100%; }
body {
  margin: 0;
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 15px;
  background: var(--canvas);
  color: var(--ink-1);
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

/* ---- Weight strategy ----------------------------------------------------
   The brand face ships ONE real weight, so every rule below picks a side of a
   deliberate line rather than asking for weights that do not exist:

     300 / 400 / 500  -> resolve to the real Sentic design, NO synthesis.
     600              -> synthesized bold, and only ever on SMALL text.

   The split is not arbitrary: the artefacts of synthetic bold (smeared stems,
   filled counters) scale WITH type size, so they are conspicuous on a 24px
   heading and invisible at 11-15px. Headings therefore take 500 and earn their
   hierarchy from size, colour and letter-spacing — which is how the logo itself
   works — while the places where weight carries MEANING rather than rank keep a
   real 600: <strong> in the scientific disclaimers, table headers, badges and
   section labels. Emphasis stays visible where it is load-bearing; large type
   stays crisp. font-synthesis-weight is pinned off on the large sizes so no
   browser re-introduces the smear at 500.                                     */
h1, h2, h3, h4 {
  font-family: var(--font-heading);
  color: var(--ink-1);
  margin: 0 0 var(--s3);
  font-weight: 500;
  font-synthesis-weight: none;
  letter-spacing: -.015em;
  line-height: 1.25;
  text-wrap: balance;
}
h1 { font-size: 1.5rem; }
h2 { font-size: 1.1875rem; }
h3 { font-size: 1.0625rem; }
/* h4 sits at body size, so size alone cannot mark it as a heading — it is small
   enough for a synthesized 600 to read cleanly, so here weight does the work. */
h4 { font-size: .9375rem; font-weight: 600; font-synthesis-weight: auto; }
/* Every paragraph in the app is running prose, so the measure applies here and the
   exceptions are subtracted below. text-wrap: pretty is the paragraph counterpart of
   the text-wrap: balance already on headings — it keeps a lone word off the last
   line; browsers that don't know it simply ignore it. */
p { margin: 0 0 var(--s4); max-width: var(--measure); text-wrap: pretty; }
p:last-child { margin-bottom: 0; }
/* These two are <p> in the markup but are BOXES, not prose: a flash alert and an
   empty-state placeholder each hold one short sentence and are meant to line up with
   the full-width table or card they sit above. Capping them would leave the box
   looking clipped without making anything easier to read. */
.alert, .empty { max-width: none; }
/* Figure captions carry the "this is a visualization, not a measurement" wording
   under images up to 960px wide, so they need the measure as much as any paragraph. */
figcaption.muted { max-width: var(--measure); }

/* NO UNDERLINE ANYWHERE — house rule. Links read as links through colour and
   weight, and gain a soft tinted plate on hover, which also enlarges the hit
   area without shifting layout. */
a {
  color: var(--brand-blue);
  text-decoration: none;
  font-weight: 500;
  border-radius: var(--r-xs);
  transition: color var(--t-fast), background-color var(--t-fast);
}
a:hover { color: var(--brand-blue-700); text-decoration: none; }
a:focus, a:active { text-decoration: none; }

/* Inline links inside prose (disclaimers, alerts): same rule, plus the tinted
   plate so they stay findable in a paragraph without a rule under them. */
.disclaimer a, .alert a, .empty a, p a {
  padding: .05em .3em; margin: 0 -.05em;
}
.disclaimer a:hover, .alert a:hover, .empty a:hover, p a:hover {
  background: rgba(0, 113, 188, .1);
}

strong, b { font-weight: 600; }
small { font-size: .8125rem; }
code, pre, .mono {
  font-family: var(--font-code);
  font-variant-numeric: tabular-nums;
  font-size: .875em;
}
img { max-width: 100%; height: auto; }
hr { border: 0; border-top: 1px solid var(--line); margin: var(--s5) 0; }
ul, ol { margin: 0 0 var(--s4); padding-left: 1.25rem; }
li { margin-bottom: var(--s1); }

/* One focus ring for the whole app. :focus-visible only, so a mouse click never
   paints a ring but every keyboard path does. */
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
  outline: 2px solid var(--brand-blue);
  outline-offset: 2px;
  text-decoration: none;
}

::selection { background: rgba(0, 113, 188, .16); color: var(--ink-1); }

/* ---- Buttons ------------------------------------------------------------
   FIVE variants, and which one an action takes is decided by the ROLE of the
   action, never by how much the page wants to shout. The mapping is the one
   VISUAL-IDENTITY.md §1 assigns, restored here after the views had drifted into
   using the accent for primary calls to action:

     .btn          brand blue   PRIMARY. The one call to action of a screen, and
                                every form submit: create, update, run, compare,
                                upload, generate, sign, archive, sign in, filter.
                                §1 lists exactly this for #0071bc — "botones
                                primarios, submit".
     .btn-teal     brand teal   SECONDARY, affirmative. A read/fetch offered
                                alongside a primary (download the signed PDF) or a
                                restorative counterpart to a red one (reactivate a
                                user). §1 lists teal as ACCENT, and teal-dark as
                                the hover of the SECONDARY button — so it must not
                                carry a screen's primary action.
     .btn-warning  amber        Destructive but NOT deletion, and irreversible.
                                Today: revoking consent, which no path in the
                                project can set back (§107).
     .btn-danger   red          Deletion, or shutting an account down.
     .btn-ghost    quiet        Escape and tertiary: Cancel, Clear, pagination.

   Why the accent may not double as the primary: --brand-teal IS the sidebar's
   active/hover colour (see .sidebar-nav). When a page's main action wears it too,
   "what do I do here" and "where am I in the app" become the same colour.

   Every foreground/background pair below was measured against WCAG AA (4.5:1),
   including :hover and :active, because a state that only exists while the mouse
   is down is still a state a user reads.

   No transform on hover: the old `scale(1.05)` nudged neighbouring content and
   re-rasterized the label mid-transition. Depth and colour carry the state. */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: .5em;
  min-height: var(--control-h);
  padding: 0 var(--s4);
  border: 1px solid transparent; border-radius: var(--r-sm);
  background: var(--brand-blue); color: var(--ink-inv);
  font-family: inherit; font-size: .9375rem; font-weight: 500; line-height: 1;
  letter-spacing: .005em;
  cursor: pointer; text-decoration: none; white-space: nowrap;
  box-shadow: var(--e1);
  transition: background-color var(--t-fast), border-color var(--t-fast),
              box-shadow var(--t-fast), color var(--t-fast);
}
/* 5.14:1 / 6.71:1 / 8.85:1 on white ink. */
.btn:hover { background: var(--brand-blue-600); color: var(--ink-inv); box-shadow: var(--e2); text-decoration: none; }
.btn:active { background: var(--brand-blue-700); box-shadow: var(--e1); }
.btn:focus-visible { outline: 2px solid var(--brand-blue); outline-offset: 2px; }

/* Teal is light enough that it needs a dark ink, not white. The :active ink is
   darkened rather than the ground lightened: at the pressed #45907f the old
   #08301f ink measured 3.82:1 — the second AA failure in this block — while
   #04180f on the same ground is 4.86:1, so the press stays visibly darker AND
   readable. Hover is 4.54:1, which is why it is not pushed further. */
.btn-teal { background: var(--brand-teal); color: #08301f; }
.btn-teal:hover { background: var(--brand-teal-600); color: #08301f; }
.btn-teal:active { background: #45907f; color: #04180f; }

/* 6.33:1 / 5.20:1 / 4.73:1 on --warn-solid-ink. */
.btn-warning { background: var(--warn-solid); color: var(--warn-solid-ink); }
.btn-warning:hover { background: var(--warn-solid-600); color: var(--warn-solid-ink); }
.btn-warning:active { background: var(--warn-solid-700); color: var(--warn-solid-ink); }

/* 6.53:1 / 8.00:1 / 8.95:1 on white ink — was 3.96:1 before the ramp change. */
.btn-danger { background: var(--danger-solid); color: var(--ink-inv); }
.btn-danger:hover { background: var(--danger-solid-600); color: var(--ink-inv); }
.btn-danger:active { background: var(--danger-solid-700); color: var(--ink-inv); }

/* Quiet variant — the reason toolbars looked rigid was that every action shouted
   equally. Secondary actions (Clear, Back, pagination) take this. */
.btn-ghost {
  background: var(--surface); color: var(--ink-2);
  border-color: var(--line-strong); box-shadow: none;
}
.btn-ghost:hover {
  background: var(--surface-2); color: var(--ink-1);
  border-color: var(--n-300); box-shadow: var(--e1);
}
.btn-ghost:active { background: var(--surface-sunken); }

.btn-block { display: flex; width: 100%; }
.btn:disabled, .btn[aria-disabled="true"] {
  opacity: .5; cursor: not-allowed; box-shadow: none; pointer-events: none;
}
.btn i { font-size: .875em; }

/* ---- App shell (authenticated layout) ----------------------------------- */
.app-body { display: flex; min-height: 100vh; min-height: 100dvh; }

.sidebar {
  width: var(--sidebar-width); flex: 0 0 var(--sidebar-width);
  background: var(--brand-navy-deep); color: rgba(255, 255, 255, .72);
  display: flex; flex-direction: column; padding: var(--s4) 0 var(--s3);
  position: sticky; top: 0; height: 100vh; height: 100dvh; overflow-y: auto;
  border-right: 1px solid rgba(255, 255, 255, .06);
}
.sidebar-brand {
  display: block; text-align: center;
  padding: var(--s2) var(--s4) var(--s5);
  margin-bottom: var(--s2);
  border-bottom: 1px solid rgba(255, 255, 255, .07);
}
.sidebar-brand:hover { background: transparent; }
.sidebar-logo { width: 58%; max-width: 148px; transition: opacity var(--t-fast); }
.sidebar-brand:hover .sidebar-logo { opacity: .82; }

.sidebar-nav { list-style: none; margin: 0; padding: 0 var(--s3); flex: 1; }
.sidebar-nav li { margin: 1px 0; }
.sidebar-nav a, .sidebar-logout {
  position: relative;
  display: flex; align-items: center; gap: var(--s3);
  padding: .625rem var(--s3);
  border-radius: var(--r-sm);
  color: rgba(255, 255, 255, .72);
  font-size: .9063rem; font-weight: 400;
  transition: background-color var(--t-fast), color var(--t-fast);
}
.sidebar-nav a:hover {
  background: rgba(255, 255, 255, .07); color: #fff; text-decoration: none;
}
/* Active: a soft filled pill plus a short teal edge — legible at a glance without
   the full-bleed teal bar, which used to compete with the content area. */
.sidebar-nav a.is-active {
  background: rgba(104, 183, 164, .16); color: #fff; font-weight: 500;
}
.sidebar-nav a.is-active::before {
  content: ""; position: absolute; left: calc(var(--s3) * -1 + 2px);
  top: 50%; transform: translateY(-50%);
  width: 3px; height: 1.25rem; border-radius: var(--r-full);
  background: var(--brand-teal);
}
.sidebar-nav i, .sidebar-logout i {
  width: 1.125rem; text-align: center; font-size: .9375rem;
  opacity: .82; transition: opacity var(--t-fast);
}
.sidebar-nav a:hover i, .sidebar-nav a.is-active i { opacity: 1; }

.sidebar-logout-form { margin-top: auto; padding: var(--s3) var(--s3) 0; }
.sidebar-logout-form::before {
  content: ""; display: block; height: 1px;
  background: rgba(255, 255, 255, .07); margin-bottom: var(--s3);
}
.sidebar-logout {
  width: 100%; border: 0; background: transparent;
  font: inherit; font-size: .9063rem; cursor: pointer;
  color: rgba(255, 255, 255, .6);
}
.sidebar-logout:hover {
  background: rgba(217, 83, 79, .16); color: #ffb4b1; text-decoration: none;
}

/* Backdrop for the off-canvas sidebar (mobile). Inert until .is-open. */
.sidebar-backdrop {
  position: fixed; inset: 0; z-index: 15;
  background: rgba(20, 31, 56, .45);
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity var(--t), visibility var(--t);
  -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px);
}
.sidebar-backdrop.is-open { opacity: 1; visibility: visible; pointer-events: auto; }
body.has-open-sidebar { overflow: hidden; }

.main-panel { flex: 1; min-width: 0; display: flex; flex-direction: column; }

.topbar {
  position: sticky; top: 0; z-index: 10;
  display: flex; align-items: center; gap: var(--s4);
  min-height: var(--topbar-h);
  background: rgba(255, 255, 255, .85);
  -webkit-backdrop-filter: saturate(1.6) blur(10px);
  backdrop-filter: saturate(1.6) blur(10px);
  border-bottom: 1px solid var(--line);
  /* The bar spans the full panel, but its CONTENT has to line up with .content, which is
     capped at --content-max and centred. The topbar's children are direct (no wrapper to
     add without touching the partial), so the gutter absorbs the difference instead. */
  padding-block: var(--s3);
  padding-inline: max(var(--s5), calc((100% - var(--content-max)) / 2 + var(--s5)));
}
@supports not (backdrop-filter: blur(2px)) { .topbar { background: var(--surface); } }
.topbar-title {
  margin: 0; flex: 1; min-width: 0;
  /* The page title, and until now the SMALLEST heading on its own page: it sat at
     1.0625rem while the section headings the views render underneath it are h2 at
     1.1875rem, so the document's top-level title was outranked visually by every
     section inside it. It now matches .auth-card h1, which makes 1.375rem the single
     h1 size across BOTH shells and restores a descending scale: 22 -> 19 -> 17 -> 15.
     The bar does not grow — .topbar is min-height 4rem and its tallest child is the
     user chip (~33px), well above this line box (22 x 1.25 = 27.5px) — and the
     <=860px override drops it back to 1rem for narrow screens. */
  font-size: 1.375rem; font-weight: 500; font-synthesis-weight: none;
  letter-spacing: -.015em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.topbar-right {
  display: flex; align-items: center; gap: var(--s3);
  flex-wrap: nowrap; justify-content: flex-end;
}
.user-chip {
  display: inline-flex; align-items: center; gap: .5rem;
  background: var(--surface-2); color: var(--ink-1);
  border: 1px solid var(--line);
  padding: .375rem .6875rem; border-radius: var(--r-full);
  font-size: .8438rem; font-weight: 500; white-space: nowrap;
  max-width: 15rem; overflow: hidden; text-overflow: ellipsis;
}
.user-chip i { color: var(--brand-blue); font-size: .875rem; }

.content {
  padding: var(--s5);
  flex: 1;
  width: 100%; max-width: var(--content-max); margin-inline: auto;
}
.content > h2:first-child, .content > .toolbar:first-child { margin-top: 0; }

/* ---- Language switcher (es-ES / en-US / pt-BR) --------------------------- */
.lang-switch { display: flex; align-items: center; gap: var(--s1); }
.lang-option {
  display: inline-flex; align-items: center; gap: .4rem;
  padding: .3rem .5rem; border-radius: var(--r-xs);
  border: 1px solid transparent; color: var(--ink-2);
  font-size: .8438rem; font-weight: 500; line-height: 1;
  transition: background-color var(--t-fast), border-color var(--t-fast), color var(--t-fast);
}
.lang-option:hover { background: var(--surface-2); color: var(--ink-1); text-decoration: none; }
.lang-option:focus-visible { outline: 2px solid var(--brand-blue); outline-offset: 2px; }
/* The flag PNGs are 512x512 canvases holding a rounded-rectangle flag with transparent
   padding above and below, so the box is square and `contain` is required: `cover` would
   crop the flag and any change to that padding would crop it differently per language. */
.lang-flag { width: 24px; height: 24px; display: block; object-fit: contain; }

/* ---- Topbar variant: a compact segmented control -------------------------
   The three options are grouped inside one pill so they read as ONE control with three
   states, instead of three loose images floating beside the user chip. Flags only —
   a name per option would crowd the bar — and the ACTIVE one is filled navy while the
   others are muted, so the current language is legible at a glance rather than from a
   hairline border. */
.lang-switch--topbar {
  gap: 2px; padding: 3px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-full);
}
.lang-switch--topbar .lang-option {
  padding: .25rem .3125rem; border-radius: var(--r-full); border-color: transparent;
}
.lang-switch--topbar .lang-flag { width: 19px; height: 19px; }
.lang-switch--topbar .lang-flag,
.lang-switch--topbar .lang-option:hover .lang-flag {
  transition: opacity var(--t-fast), filter var(--t-fast);
}
/* Gentle on purpose: the navy fill is what marks the active option, so the muting only has
   to add a little lift — pushed harder (e.g. .55/.6) the Brazilian flag washes out to a pale
   blob and the flags stop being recognisable, which defeats the point of using flags. */
.lang-switch--topbar .lang-option .lang-flag { opacity: .62; filter: saturate(.8); }
.lang-switch--topbar .lang-option:hover { background: var(--surface); }
.lang-switch--topbar .lang-option:hover .lang-flag { opacity: 1; filter: none; }
.lang-switch--topbar .lang-option.is-active {
  background: var(--brand-navy); border-color: var(--brand-navy);
  box-shadow: var(--e1);
}
.lang-switch--topbar .lang-option.is-active .lang-flag { opacity: 1; filter: none; }
/* The name is hidden VISUALLY but kept in the accessibility tree: `display: none` would
   remove it and leave each link named only by its title attribute — unreliable on touch
   and inconsistently announced by screen readers (the flag img is alt="", decorative). */
.lang-switch--topbar .lang-name {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* ---- Auth variant: full row above the sign-in card ------------------------
   Names visible — the language must be choosable BEFORE reading the form, so it is the
   first control on the page and must be readable without knowing the current one. */
.lang-switch--auth {
  justify-content: center; gap: var(--s2); margin-bottom: var(--s5); flex-wrap: wrap;
}
.lang-switch--auth .lang-option {
  background: rgba(255, 255, 255, .8); border-color: var(--line);
  padding: .4375rem .6875rem; border-radius: var(--r-full);
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
}
.lang-switch--auth .lang-option:hover { border-color: var(--n-300); background: #fff; }
/* Active: filled, so it is distinguishable from the plain siblings by fill and not by
   border colour alone (a 1px hue difference is easy to miss). */
.lang-switch--auth .lang-option.is-active {
  border-color: var(--brand-navy); background: var(--brand-navy); color: #fff;
  box-shadow: var(--e2);
}

/* ---- Auth shell (login / register) -------------------------------------- */
.auth-body {
  display: flex; align-items: center; justify-content: center;
  min-height: 100vh; min-height: 100dvh; padding: var(--s5);
  background:
    radial-gradient(120% 90% at 50% -20%, rgba(0, 113, 188, .12) 0%, transparent 55%),
    radial-gradient(90% 70% at 90% 110%, rgba(104, 183, 164, .14) 0%, transparent 60%),
    var(--canvas);
}
.auth-wrap { width: 100%; max-width: 26rem; }
.auth-card {
  background: var(--surface);
  padding: var(--s6) var(--s5) var(--s5);
  border: 1px solid var(--line);
  border-radius: var(--r-xl);
  box-shadow: var(--e3);
  text-align: center;
}
.auth-card h1 { margin-bottom: var(--s5); font-size: 1.375rem; }
.auth-logo { width: 76px; margin: 0 auto var(--s4); }
.auth-note { color: var(--ink-3); font-size: .8438rem; margin-top: var(--s4); }
.auth-card .form-stack { margin-top: var(--s2); }

/* ---- Cards / dashboard grid ---------------------------------------------
   Was six solid navy blocks competing with the navy rail. Now the container is
   quiet and the NUMBER is the loudest thing in it, which is the actual content. */
.card-grid {
  display: grid; gap: var(--s4);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 13.5rem), 1fr));
}
.card {
  display: grid; gap: var(--s2); align-content: start;
  background: var(--surface); color: var(--ink-1);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: var(--s4) var(--s5) var(--s5);
  box-shadow: var(--e1);
  transition: box-shadow var(--t-fast), border-color var(--t-fast);
}
/* Hover belongs to a card that GOES somewhere. Scoped to the anchor so the rule STATES
   the affordance instead of decorating: before the dashboard cards became links this
   was the only hover in the app that answered a pointer with no destination behind it,
   and a future card without one stays visually inert instead of inheriting the promise. */
a.card:hover { box-shadow: var(--e2); border-color: var(--line-strong); }
.card i {
  display: grid; place-items: center;
  width: 2.125rem; height: 2.125rem;
  border-radius: var(--r-sm);
  background: rgba(0, 113, 188, .09); color: var(--brand-blue);
  font-size: .9375rem;
  margin-bottom: var(--s2);
}
.card .card-value {
  font-size: 2.375rem; font-weight: 300; line-height: 1.05;
  letter-spacing: -.03em; color: var(--ink-1);
  font-variant-numeric: tabular-nums;
}
.card .card-label {
  font-size: .8125rem; font-weight: 500; color: var(--ink-3);
  opacity: 1; letter-spacing: .005em;
}

/* Result / comparison card. Deliberately SOBER, not celebratory: it carries the
   §4 similarity index, which is descriptive and must never read as a verdict. The
   number is large but light-weight, and the disclaimer sits immediately below it. */
.result-card {
  background:
    linear-gradient(160deg, var(--brand-navy) 0%, var(--brand-navy-deep) 62%, #101a30 100%);
  color: #fff;
  border-radius: var(--r-lg);
  padding: var(--s5);
  box-shadow: var(--e2);
  display: grid; gap: var(--s3);
  animation: fadeIn .4s var(--t-fast) both;
}
.result-card .card-value {
  font-size: 3rem; font-weight: 300; line-height: 1;
  letter-spacing: -.035em; color: #fff;
  font-variant-numeric: tabular-nums;
}
.result-card strong { font-weight: 600; color: #fff; }
.result-card small { color: rgba(255, 255, 255, .68); font-size: .8125rem; }
.result-card .muted { color: rgba(255, 255, 255, .6); }
.result-card a { color: #9fd0f2; }
.result-card a:hover { color: #c6e4fa; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }

/* ---- Data tables --------------------------------------------------------
   The header was a solid saturated blue band with white text plus zebra rows —
   the single most dated element in the app. Now: a quiet micro-label header over
   a hairline, no zebra, and hover as the only row emphasis. */
.table-wrap {
  width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--e1);
}
.table-wrap > .data { border: 0; border-radius: 0; box-shadow: none; min-width: 34rem; }

.data {
  width: 100%; border-collapse: collapse;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  overflow: hidden;
  box-shadow: var(--e1);
  font-size: .9063rem;
}
.data thead th {
  background: var(--surface-2); color: var(--ink-3);
  text-align: left; padding: .6875rem var(--s4);
  font-size: .6875rem; font-weight: 600;
  letter-spacing: .07em; text-transform: uppercase;
  white-space: nowrap;
  border-bottom: 1px solid var(--line);
}
.data td {
  padding: .8125rem var(--s4);
  border-top: 1px solid var(--line);
  color: var(--ink-1);
  vertical-align: middle;
}
.data tbody tr:first-child td { border-top: 0; }
.data tbody tr { transition: background-color var(--t-fast); }
.data tbody tr:nth-child(odd) { background: transparent; }
.data tbody tr:hover { background: var(--surface-2); }
.data td.mono, .data td .mono { color: var(--ink-2); font-size: .8438rem; }
.data td .btn { min-height: 2.125rem; padding: 0 .75rem; font-size: .8438rem; }

/* Row-action cluster — the "View / JSON / Edit / Delete" cell of a listing.
   These take .btn-ghost and NOT the solid primary, which is the §108 role rule
   applied to REPETITION: a row action appears once per ROW, so a solid blue here
   would put a screenful of primary-weight buttons on a page whose actual primary
   is the single toolbar call to action above the table. Quiet fill plus an icon
   reads as a button without competing with it. Deletion keeps .btn-danger —
   role beats uniformity, and it is the one row action that must not blend in.
   The flex gap replaces the "·" characters these cells used to be strung
   together with, so spacing is uniform and the cluster wraps on a narrow screen
   instead of stretching the column. */
.row-actions { display: flex; flex-wrap: wrap; gap: var(--s2); align-items: center; }
/* .form-inline carries a bottom margin meant for a standalone filter row; inside a
   table cell it would push the cluster off the row's vertical centre. */
.row-actions .form-inline { margin-bottom: 0; }

/* A row action that is CURRENTLY showing its result below the table (cases/view's spectrogram
   picker). It stays a ghost — it is still a tertiary navigational action, not a call to action —
   and only gains the brand edge and ink, so "which one am I looking at" reads at a glance without
   the row turning into a second primary. */
.btn-ghost.is-active {
  border-color: var(--brand-blue); color: var(--brand-blue);
  background: var(--surface-2); box-shadow: none;
}
.btn-ghost.is-active:hover { border-color: var(--brand-blue-600); color: var(--brand-blue-600); }

/* ---- Forms -------------------------------------------------------------- */
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s5); }
.form-stack { display: grid; gap: var(--s4); text-align: left; }
/* Terminal action row of a form: the primary submit plus the escape hatch beside it.
   The pair lives in a wrapper rather than as two bare .form-stack grid items because a
   grid item STRETCHES — two stacked full-width buttons would read as two equally
   weighted choices, which is exactly the wrong signal for "save" next to "cancel".
   Flex keeps the primary at its intrinsic width with the secondary alongside, and the
   wrap lets the pair drop to a second line on a narrow card instead of overflowing it. */
.form-actions { display: flex; flex-wrap: wrap; gap: var(--s3); align-items: center; }
.field { display: block; min-width: 0; }
/* Label text: <span> inside the label-wrapped fields, <label> where the field is a <div>
   because it also holds a button (partials/password-field.php — a button must not live
   inside a <label>). Both render identically. */
.field > span, .field > label {
  display: block;
  font-weight: 500; font-size: .8125rem; color: var(--ink-2);
  letter-spacing: .01em;
  margin-bottom: .375rem;
}
.field input, .field select, .field textarea {
  width: 100%;
  min-height: var(--control-h);
  padding: .625rem .75rem;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-sm);
  font-family: inherit; font-size: .9375rem; color: var(--ink-1);
  background: var(--surface);
  transition: border-color var(--t-fast), box-shadow var(--t-fast), background-color var(--t-fast);
}
.field textarea { min-height: 6rem; resize: vertical; line-height: 1.55; }
.field input::placeholder, .field textarea::placeholder { color: var(--ink-3); }
.field input:hover, .field select:hover, .field textarea:hover { border-color: var(--n-300); }
.field input:focus, .field select:focus, .field textarea:focus {
  outline: none; border-color: var(--brand-blue); box-shadow: var(--ring);
}
.field input:disabled, .field select:disabled, .field textarea:disabled {
  background: var(--surface-sunken); color: var(--ink-3); cursor: not-allowed;
}
.field input[type="file"] { padding: .5rem .75rem; line-height: 1.6; }
.field input[type="file"]::file-selector-button {
  margin-right: .75rem; padding: .375rem .75rem;
  border: 1px solid var(--line-strong); border-radius: var(--r-xs);
  background: var(--surface-2); color: var(--ink-2);
  font-family: inherit; font-size: .8438rem; font-weight: 500; cursor: pointer;
  transition: background-color var(--t-fast), border-color var(--t-fast);
}
.field input[type="file"]::file-selector-button:hover { background: var(--surface-sunken); border-color: var(--n-300); }
.field select {
  appearance: none; -webkit-appearance: none;
  padding-right: 2.25rem;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23647389' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6.5 8 10.5 12 6.5'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right .6875rem center; background-size: 1rem;
}
.form-span-2 { grid-column: 1 / -1; }

/* ⚠️ A CHECKBOX IS NOT A TEXT FIELD, and `.field input` above did not know that (§134).
   `width: 100%` + `min-height: var(--control-h)` + padding + border applied to a checkbox render
   it as a 44 px empty square, centred inside the full width of the card, with its label orphaned
   on the line below — which is exactly how the voice bank's MANDATORY consent gate shipped. The
   defect survived §132: that increment styled `.param-option input[type="checkbox"]` for the
   comparison picker and swept no further, and those two are the only checkboxes in the app.
   Hence the reset lives on `.field input[type="checkbox"]`, i.e. on the SHAPE, so the next
   checkbox added anywhere inside a .field inherits a sane one instead of the text-input look. */
.field input[type="checkbox"], .field input[type="radio"] {
  width: auto; min-height: 0; padding: 0;
  border: 0; border-radius: 0; background: none;
  accent-color: var(--brand-blue); cursor: pointer;
}
/* A checkbox and its text are ONE line reading left to right, not a stacked label/control pair:
   `.field > span` is block with a bottom margin, which is right for a field whose control sits
   under its name and wrong for one whose name sits beside its box. align-items: start keeps the
   box on the first line when the text wraps to two. */
.field-check {
  display: flex; align-items: flex-start; gap: .625rem;
  padding: .625rem .75rem;
  border: 1px solid var(--line-strong); border-radius: var(--r-sm);
  background: var(--surface);
}
.field-check input[type="checkbox"] { width: 1rem; height: 1rem; margin: .1875rem 0 0; flex: none; }
.field-check > span {
  display: block; margin-bottom: 0;
  font-size: .9375rem; font-weight: 400; line-height: 1.45; color: var(--ink-1); cursor: pointer;
}

/* Sections of a long write form (§134). A twelve-field intake is not one list: the groups say
   which question is being answered — what the record is CALLED, what the SPEAKER is like, what
   was CONSENTED — and a legend is the accessible way to say so (a screen reader announces it with
   every control inside). Borderless: the panel card already draws the box, and a second frame
   inside it would make each group look like a nested card. */
.form-group { border: 0; padding: 0; margin: 0; min-width: 0; display: grid; gap: var(--s4); }
.form-group > legend {
  padding: 0; margin-bottom: var(--s2);
  font-size: .8125rem; font-weight: 600; color: var(--ink-2);
  letter-spacing: .06em; text-transform: uppercase;
}
/* The reason a field exists, under the field it explains. Not `.muted`, which carries the 36rem
   measure meant for prose and would set a one-line hint at a different width than its own input.
   The negative top margin cancels the grid gap, so the hint sits ON its field instead of floating
   equidistant between two — which is the whole difference between "this explains the control above"
   and "this is a loose sentence". .group-help is the same voice for a note that belongs to the
   WHOLE fieldset and therefore follows the legend, where there is no preceding gap to cancel. */
.field-help, .group-help {
  max-width: none;
  font-size: .8125rem; line-height: 1.45; color: var(--ink-3);
}
.field-help { margin: calc(var(--s4) * -1 + var(--s1)) 0 0; }
.group-help { margin: 0; }

/* Parameter picker of the comparison form (§132). A checkbox grid rather than a multi-select:
   the set is small and fixed, every option has to be readable at a glance, and a multi-select
   hides what is NOT chosen — which is precisely what the examiner is deciding here. The grid
   auto-fills so it is one column on a phone and four on a desktop without a media query.
   .param-note holds the per-parameter caveat (channel-dependent, collinear with F0…), one line
   under its own label, because a choice like this must carry its reason where it is made. */
.param-picker { border: 0; padding: 0; margin: 0; min-width: 0; }
.param-picker > legend {
  padding: 0;
  font-weight: 500; font-size: .8125rem; color: var(--ink-2);
  letter-spacing: .01em; margin-bottom: .375rem;
}
/* The descriptive index, demoted out of the opening position (§136). It is deliberately NOT a
   .result-card: a card with a .card-value is what this page used to lead with, and re-using that
   treatment lower down would keep the visual claim while only moving it. This is a quiet inset —
   the number in mono at body size, its band beside it, its floor note underneath — so the page's
   emphasis sits where the informative output is, on the per-parameter table above. */
.index-secondary {
  margin: var(--s5) 0 0;
  padding: var(--s4);
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
}
.index-secondary-h {
  margin: 0 0 .25rem;
  font-weight: 600; font-size: .8125rem; color: var(--ink-2);
  letter-spacing: .01em;
}
.index-secondary-v { margin: 0 0 .5rem; font-size: .9375rem; color: var(--ink-1); }
.index-secondary-v .mono { font-weight: 600; }
.index-secondary-v small { color: var(--ink-3); }
.index-secondary p.muted:last-child { margin-bottom: 0; }

/* The picker is split by what each parameter is EVIDENCE OF — speaker or recording chain (§136).
   The heading and its note are what make the split mean something: two unlabelled columns of
   checkboxes would only move the options around. The group note is a full-width paragraph rather
   than a per-option .param-note because it states a property of the whole group, and repeating it
   on each option would read as four separate warnings about four unrelated things. */
.param-group + .param-group { margin-top: var(--s4); }
.param-group-h {
  margin: 0 0 .125rem;
  font-weight: 600; font-size: .8125rem; color: var(--ink-1);
  letter-spacing: .01em;
}
.param-group-note {
  margin: 0 0 .5rem;
  font-size: .8125rem; line-height: 1.45; color: var(--ink-3);
  max-width: 68ch;
}
.param-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(13.5rem, 1fr));
  gap: var(--s2) var(--s4);
}
.param-option {
  display: grid; grid-template-columns: auto 1fr; gap: .5rem .5rem;
  align-items: start;
  padding: .375rem .5rem;
  border: 1px solid transparent; border-radius: var(--r-xs);
}
.param-option:hover { background: var(--surface-sunken); }
.param-option input[type="checkbox"] {
  width: 1rem; height: 1rem; margin: .1875rem 0 0; accent-color: var(--brand-blue); cursor: pointer;
}
.param-option > span { font-size: .9375rem; color: var(--ink-1); cursor: pointer; }
.param-option .param-note {
  grid-column: 2; margin: 0;
  font-size: .8125rem; line-height: 1.45; color: var(--ink-3);
}
.param-option-off > span, .param-option-off .param-note { color: var(--ink-3); }

/* Password field with a show/hide toggle (partials/password-field.php). The button is
   overlaid on the input's trailing edge; the input reserves room for it so a long value
   never slides underneath. Behaviour lives in assets/js/scripts.js (CSP: no inline JS). */
.input-reveal { position: relative; display: block; }
.input-reveal > input { padding-right: 2.9rem; }
.reveal-toggle {
  position: absolute; top: 0; right: 0; height: 100%; width: 2.75rem;
  display: flex; align-items: center; justify-content: center;
  padding: 0; border: 0; background: none; cursor: pointer;
  color: var(--ink-3); font-size: .9375rem; line-height: 1;
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
  transition: color var(--t-fast);
}
.reveal-toggle:hover { color: var(--brand-blue); }
.reveal-toggle:focus-visible {
  outline: 2px solid var(--brand-blue); outline-offset: -2px; color: var(--brand-blue);
}
/* Suppress the browser's own reveal control so only ours is shown (Edge/IE, WebKit). */
.input-reveal > input::-ms-reveal,
.input-reveal > input::-ms-clear { display: none; }
.input-reveal > input::-webkit-credentials-auto-fill-button { margin-right: 2.2rem; }

/* ---- Alerts / disclaimer ------------------------------------------------ */
.alert {
  padding: .8125rem var(--s4);
  border-radius: var(--r-md);
  border: 1px solid transparent;
  margin: 0 0 var(--s4);
  font-size: .9063rem;
}
.alert strong { font-weight: 600; }
.alert ul { margin: var(--s2) 0 0; }
.alert-error   { background: var(--err-bg);  color: var(--err-ink);  border-color: var(--err-edge); }
.alert-success { background: var(--ok-bg);   color: var(--ok-ink);   border-color: var(--ok-edge); }
.alert-info    { background: var(--info-bg); color: var(--info-ink); border-color: var(--info-edge); }
.alert-warning { background: var(--warn-bg); color: var(--warn-ink); border-color: var(--warn-edge); }
.alert-error a, .alert-warning a { color: inherit; font-weight: 600; }

/* Scientific-honesty disclaimer (non-negotiable on comparison/result screens).
   Kept deliberately conspicuous: the redesign must not soften the one element whose
   job is to stop a reader over-reading the number above it. */
.disclaimer {
  border: 1px solid var(--warn-edge);
  border-left: 3px solid #e0a020;
  background: var(--warn-bg);
  color: #6b4d12;
  padding: var(--s3) var(--s4);
  border-radius: var(--r-md);
  font-size: .8750rem; line-height: 1.6;
  margin: var(--s4) 0;
}
.disclaimer strong { color: #573f0e; font-weight: 600; }
.disclaimer a { color: #7a5510; font-weight: 600; }
.disclaimer a:hover { background: rgba(224, 160, 32, .18); }

/* Minimal system error pages (500 / 503 / 419) — class-based, never inline (CSP-clean). */
.error-page { max-width: 32rem; margin: var(--s8) auto; text-align: center; padding: var(--s5); }
.error-page h1 { font-size: 3.5rem; font-weight: 300; letter-spacing: -.04em; margin-bottom: var(--s2); color: var(--ink-2); }

/* ---- Sections / dashboard panel ----------------------------------------- */
.section { margin-top: var(--s6); }
.section > h2 {
  margin-bottom: var(--s4);
  font-size: .8125rem; font-weight: 600;
  letter-spacing: .07em; text-transform: uppercase;
  color: var(--ink-3);
}
.chart-wrap {
  position: relative; height: 300px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--e1);
  padding: var(--s4);
}
.audio-figure { margin: var(--s4) 0; max-width: 640px; }
.waveform {
  display: block; width: 100%; height: 120px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--e1);
}
.audio-player { display: block; width: 100%; margin-top: var(--s2); }

/* ---- Spectrogram (visualization only — never a measurement) -------------- */
.spectrogram-figure { margin: var(--s4) 0; max-width: 960px; }
.spectrogram-title {
  font-weight: 600; font-size: .8125rem; color: var(--ink-2);
  margin-bottom: var(--s2); letter-spacing: .01em;
}
.spectrogram {
  display: block; width: 100%; height: auto;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  box-shadow: var(--e1);
}
/* Side-by-side on wide screens, stacked on narrow ones. minmax(0,1fr) (not 1fr) so a wide
   image cannot force the column past the grid and scroll the page horizontally. */
.spectrogram-pair {
  display: grid; gap: var(--s4);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
}
.spectrogram-pair .spectrogram-figure { margin: 0; max-width: none; min-width: 0; }

/* Render controls (§113). A spectrogram is not one picture but a family of them: the window,
   the band, the ceiling and the dynamic range each change what can be read, so the settings are
   a control surface and not a constant. Laid out on .form-inline, so it wraps to a phone the
   same way the list filter does. The step arrows are pushed to the far end because they are
   navigation through the recording, not another setting. */
.spectrogram-controls {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--e1);
  padding: var(--s4);
}
.spectrogram-controls .field { min-width: 6.5rem; }
.spectrogram-controls .field > span { white-space: nowrap; }
.spectrogram-step { margin-left: auto; }

/* The settings the image was actually drawn with. It sits between the picture and the caveat
   because "spectrogram" alone does not identify an image whose band and ceiling are choices —
   two figures of the same voice can look nothing alike and both be right. */
.spectrogram-settings {
  margin-top: var(--s2);
  font-size: .8125rem; color: var(--ink-2); letter-spacing: .01em;
}

/* Legend for the overlaid tracks. It lives in HTML, not painted into the PNG, so it is
   translated, selectable and reachable by a screen reader while the image itself stays
   language-neutral and one cached render serves every reader. Each swatch is the EXACT colour
   the Praat script paints, so the legend cannot drift from the figure:
     #c70d0d = Colour {0.78,0.05,0.05}   #1a40bf = {0.10,0.25,0.75}   #007359 = {0,0.45,0.35}
   All three clear WCAG 1.4.11 non-text contrast against --surface by a wide margin — measured
   6.01:1, 8.30:1 and 5.84:1 — and each swatch is a redundant cue beside its own written label,
   never the only way to tell the tracks apart. */
.spectrogram-legend {
  display: flex; flex-wrap: wrap; gap: var(--s2) var(--s4);
  align-items: center; margin: var(--s3) 0 0;
  font-size: .8125rem; color: var(--ink-2);
}
.spectrogram-legend .legend-item { display: inline-flex; align-items: center; }
.swatch {
  display: inline-block; width: .75rem; height: .75rem;
  margin-right: .4375rem; border-radius: 2px;
  border: 1px solid rgba(0, 0, 0, .18);
}
.swatch-formants  { background: #c70d0d; }
.swatch-f0        { background: #1a40bf; }
.swatch-intensity { background: #007359; }

.empty {
  color: var(--ink-2);
  background: var(--surface);
  border: 1px dashed var(--line-strong);
  padding: var(--s5);
  border-radius: var(--r-lg);
  box-shadow: none;
  text-align: center; font-size: .9063rem;
}

/* ---- Toolbar / panels / badges ------------------------------------------ */
.toolbar {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--s3); flex-wrap: wrap; margin-bottom: var(--s4);
}
.toolbar > h2, .toolbar > h3 { margin-bottom: 0; }
.panel-card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--e1);
  padding: var(--s5);
  max-width: 44rem;
}

/* Soft tinted pills. The two that carry meaning — badge-ok (seal verified) and
   badge-danger (seal FAILED) — keep full-strength ink and a visible edge so the
   distinction survives the lighter treatment. */
.badge {
  display: inline-flex; align-items: center; gap: .375em;
  padding: .1875rem .5625rem; border-radius: var(--r-full);
  font-size: .75rem; font-weight: 600; letter-spacing: .01em;
  border: 1px solid transparent;
  background: var(--info-bg); color: var(--info-ink); border-color: var(--info-edge);
  white-space: nowrap;
}
.badge i { font-size: .8125em; }
.badge-danger { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-edge); }
.badge-ok     { background: var(--ok-bg);  color: var(--ok-ink);  border-color: var(--ok-edge); }
.badge-muted  { background: var(--surface-2); color: var(--ink-2); border-color: var(--line-strong); }
.result-card .badge { border-color: transparent; }

.form-inline {
  display: flex; gap: var(--s3); align-items: flex-end; flex-wrap: wrap;
  margin-bottom: var(--s4);
}
/* Shared list filter (partials/list-filter.php): the search box absorbs the free space while the
   two day pickers keep their natural width, so the row stays readable and wraps cleanly on a
   phone instead of squeezing all three fields to a third each. */
.list-filter {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: var(--s4);
  box-shadow: var(--e1);
}
.list-filter .list-filter-q { flex: 1 1 15rem; }
.list-filter input[type="date"] { min-width: 10rem; }
.list-filter .field > span:not(.sr-only) { margin-bottom: .375rem; }

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0); clip-path: inset(50%);
  white-space: nowrap; border: 0;
}

/* ---- Utilities ---------------------------------------------------------- */
.muted { color: var(--ink-3); font-size: .8750rem; }
.text-center { text-align: center; }

.sidebar-toggle {
  display: none;
  width: 2.375rem; height: 2.375rem;
  align-items: center; justify-content: center;
  background: none; border: 1px solid transparent; border-radius: var(--r-sm);
  color: var(--ink-2); font-size: 1.0625rem; cursor: pointer;
  transition: background-color var(--t-fast), color var(--t-fast);
}
.sidebar-toggle:hover { background: var(--surface-2); color: var(--ink-1); }

/* ---- Scrollbars ---------------------------------------------------------
 * Two separate mechanisms, one per engine — they do NOT read the same declarations.
 * Firefox honours ONLY the standard properties below and ignores ::-webkit-scrollbar
 * entirely, so --scrollbar-width never reaches it: `scrollbar-width` is keyword-only
 * (auto | thin | none), never a length. Chrome/Edge ignore the standard properties on
 * any scroller whose ::-webkit-scrollbar is author-styled, so the two blocks never fight.
 * The navy-thumb-on-teal-track of the prototype was loud enough to read as chrome in its
 * own right; a neutral thumb lets the content own the attention. */
* { scrollbar-color: var(--n-300) transparent; scrollbar-width: thin; }
::-webkit-scrollbar { width: var(--scrollbar-width); height: var(--scrollbar-width); }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: var(--n-300); border-radius: var(--r-full);
  border: 3px solid transparent; background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--n-400); background-clip: content-box; }
/* The rail is dark, so it needs its own thumb or the neutral one disappears into it. */
.sidebar { scrollbar-color: rgba(255, 255, 255, .22) transparent; }
.sidebar::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .22); background-clip: content-box; }
.sidebar::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, .34); background-clip: content-box; }

/* ---- Responsive ---------------------------------------------------------
   Mobile-first corrections on top of the fluid grids above. The card grid and the
   spectrogram pair use auto-fit, so they need no breakpoint of their own. */
@media (max-width: 1024px) {
  :root { --sidebar-width: 232px; }
  .content { padding: var(--s4); }
}
@media (max-width: 860px) {
  .form-grid { grid-template-columns: 1fr; gap: var(--s4); }
  .sidebar {
    position: fixed; left: 0; top: 0; z-index: 20;
    width: 278px; flex-basis: 278px; max-width: 84vw;
    transform: translateX(-100%);
    transition: transform var(--t);
    box-shadow: var(--e3);
  }
  .sidebar.is-open { transform: none; }
  .sidebar-toggle { display: inline-flex; }
  .topbar { padding: var(--s3) var(--s4); gap: var(--s3); }
  .topbar-title { font-size: 1rem; }
  .panel-card { padding: var(--s4); }
}
@media (max-width: 600px) {
  body { font-size: 14.5px; }
  .content { padding: var(--s4) var(--s3); }
  .card { padding: var(--s4); }
  .card .card-value { font-size: 2rem; }
  .result-card { padding: var(--s4); }
  .result-card .card-value { font-size: 2.5rem; }
  .auth-card { padding: var(--s5) var(--s4); }
  /* The user chip's label would push the language switcher off-screen; the icon
     still identifies the control and the name stays in the accessibility tree. */
  .user-chip { max-width: 8.5rem; }
  .toolbar { gap: var(--s2); }
  .toolbar .btn { flex: 1 1 auto; }
  .data td, .data thead th { padding-left: var(--s3); padding-right: var(--s3); }
}

/* ---- Busy state (a long action in flight) --------------------------------
   Acoustic analysis blocks its own request for 3 s (a 10 s recording) to 59,7 s (the
   240 s ceiling), and until now the page said NOTHING for that whole time: no spinner,
   no disabled button, nothing. A dead-looking page invites a second click, which on a
   1 vCPU box is the worst possible response — the engine lock refuses the second run,
   so the operator gets an error for having been given no feedback.

   It is deliberately INDETERMINATE — a moving band, `role="progressbar"` with NO
   aria-valuenow. The honest reason: nothing in the request knows how far along Praat
   is. `shell_exec` returns everything at the end, so a percentage here could only be a
   clock reading dressed up as work completed, and this project does not draw what it
   has not measured (the same rule that pins the comparison chart's axis to the metric's
   own bound, §110, and that refuses an unreachable similarity band, §95). What IS known
   is the elapsed time and a calibrated ESTIMATE, and both are shown as what they are.

   The global prefers-reduced-motion rule below flattens the animation; the override
   there turns the band into a full static track, so the panel still reads as "working"
   without motion. */
.busy[hidden] { display: none; }
.busy {
  margin-top: var(--s4);
  padding: var(--s4);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface-2);
}
.busy-head {
  display: flex; align-items: center; gap: .5em;
  margin: 0 0 var(--s3);
  font-weight: 500;
  max-inline-size: none;            /* it is a status line, not prose: no --measure */
}
.busy-bar {
  position: relative; overflow: hidden;
  height: 6px; border-radius: 999px;
  background: var(--line);
}
.busy-bar::after {
  content: ""; position: absolute; top: 0; bottom: 0;
  width: 35%; border-radius: inherit;
  background: var(--brand-blue);
  animation: busySlide 1.4s ease-in-out infinite;
}
@keyframes busySlide { from { left: -35%; } to { left: 100%; } }
.busy-meta {
  display: flex; flex-wrap: wrap; gap: .35em 1em;
  margin: var(--s3) 0 0;
  color: var(--ink-2); font-size: .875rem;
  max-inline-size: none;
}
.busy-note { margin: var(--s2) 0 0; color: var(--ink-3); font-size: .8125rem; }

/* ---- Motion / contrast preferences -------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important; animation-iteration-count: 1 !important;
    transition-duration: .01ms !important; scroll-behavior: auto !important;
  }
}
/* The blanket rule above freezes the busy band mid-travel, which would read as a stalled
   percentage — the one thing this indicator must never imply. Make it a full static track
   instead: still visibly "working", still asserting no value. */
@media (prefers-reduced-motion: reduce) {
  .busy-bar::after { left: 0; right: 0; width: auto; }
}
@media (prefers-contrast: more) {
  :root { --line: #b9c4d2; --line-strong: #93a2b6; --ink-3: #5d6e88; }
}

/* ---- Print ---------------------------------------------------------------
   A comparison or report screen is routinely printed to paper for a case file, so
   the chrome must not come with it. */
@media print {
  .sidebar, .topbar, .sidebar-backdrop, .lang-switch, .btn, .sidebar-toggle { display: none !important; }
  body { background: #fff; font-size: 11pt; }
  .app-body { display: block; }
  .content { max-width: none; padding: 0; }
  .card, .panel-card, .data, .table-wrap, .chart-wrap { box-shadow: none; break-inside: avoid; }
  .result-card { background: #fff !important; color: #000; border: 1px solid #000; }
  .result-card .card-value, .result-card strong { color: #000; }
  .result-card small, .result-card .muted { color: #333; }
  .disclaimer { border: 1px solid #000; background: #fff; color: #000; }
}

/* Per-parameter verdicts (§137). The verdict is the one cell a reader scans for, so it carries a
   pill rather than plain text — and the colour is SEMANTIC state, never the brand accent, which
   is why these do not reuse --brand-teal. Each pill also carries its own word, so the state is
   never conveyed by colour alone. The palette is the app's existing ok/warn/err triplets, with
   "not evaluated" deliberately neutral: an absence must not read as a finding. */
.verdict {
  display: inline-block; white-space: nowrap;
  padding: .0625rem .4375rem; border-radius: var(--r-xs);
  font-size: .8125rem; font-weight: 600; line-height: 1.45;
  border: 1px solid transparent;
}
.verdict-compatible   { color: var(--ok-ink);   background: var(--ok-bg);   border-color: var(--ok-edge); }
.verdict-inconclusive { color: var(--warn-ink); background: var(--warn-bg); border-color: var(--warn-edge); }
.verdict-incompatible { color: var(--err-ink);  background: var(--err-bg);  border-color: var(--err-edge); }
.verdict-not_evaluated{ color: var(--ink-3);    background: var(--surface-sunken); border-color: var(--line); }

/* The count, stated before the table it summarizes. Not a .card-value: the whole point of the
   per-parameter reading is that no single figure stands for the comparison. */
.verdict-summary {
  margin: 0 0 var(--s3);
  font-size: .9375rem; color: var(--ink-1); font-weight: 500;
}

/* The verbal expression and its fixed note are ONE block. The note is what distinguishes this
   wording from the ENFSI likelihood-ratio scale, so it is styled as part of the statement rather
   than as a footnote that could be read past — and the box is deliberately unlike any other
   result box on the page, so a future calibrated scale could never be confused with it. */
.verdict-expression {
  margin: var(--s4) 0 0;
  padding: var(--s4);
  border: 1px solid var(--line-strong);
  border-left: 4px solid var(--ink-2);
  border-radius: var(--r-xs);
  background: var(--surface);
}
.verdict-expression-h {
  margin: 0 0 .25rem;
  font-size: .8125rem; font-weight: 600; color: var(--ink-2); letter-spacing: .01em;
}
.verdict-expression-v { margin: 0 0 .5rem; font-size: 1rem; line-height: 1.5; color: var(--ink-1); }
.verdict-expression-note {
  margin: 0; font-size: .8125rem; line-height: 1.5; color: var(--ink-3);
  max-width: 68ch;
}

/* GRBAS perceptual rating row (reports/create, reports/edit).
   Five 0-3 selects that belong on ONE line: they are a single instrument, not five independent
   questions, and stacking them the way .form-stack stacks fields would hide that. Wraps instead of
   shrinking below a legible width, so a narrow viewport gets two rows rather than five unreadable
   columns. Matches the .param-option treatment the comparison picker already uses for a group of
   controls that are read together. */
.grbas-row {
  display: flex;
  flex-wrap: wrap;
  gap: .5rem .75rem;
  margin-bottom: .5rem;
}
.grbas-row .field-narrow {
  flex: 0 1 9.5rem;
  min-width: 8rem;
}

/* Conformance-state pills (standards/index).
   A PARALLEL family to .verdict, not a reuse of it: a per-parameter verdict and a conformance state
   are different vocabularies, and one class carrying both would let a future change to either
   silently restyle the other. Same semantic triplet, so the two read as the same system.
   `declined` is deliberately NEUTRAL rather than red — a decision taken on purpose and defended in
   writing is not a failure to implement something, and colouring it as one would misreport the
   single most important position this platform holds. */
.std-state {
  display: inline-block; white-space: nowrap;
  padding: .0625rem .4375rem; border-radius: var(--r-xs);
  font-size: .8125rem; font-weight: 600; line-height: 1.45;
  border: 1px solid transparent;
}
.std-state-met      { color: var(--ok-ink);   background: var(--ok-bg);   border-color: var(--ok-edge); }
.std-state-partial  { color: var(--warn-ink); background: var(--warn-bg); border-color: var(--warn-edge); }
.std-state-not_met  { color: var(--err-ink);  background: var(--err-bg);  border-color: var(--err-edge); }
.std-state-declined { color: var(--ink-3);    background: var(--surface-sunken); border-color: var(--line); }

/* ---- Maintenance: the closed platform, and the banner behind the bypass -------------------
   The page REUSES the auth shell — same card, same logo, same language switcher — and that is the
   decision, not a shortcut: a closed platform must still look like THIS platform. A generic 503 is
   precisely the moment a user starts wondering whether they typed the wrong address, or whether
   their case file still exists. Only three things here are new.

   The badge is a WARN triplet, never danger: maintenance is planned and temporary, and red would
   say "something broke" about a window somebody opened on purpose. Its glyph is carried by the
   `fa-solid` face, the only one precached in sw.js, so it still draws for an installed client that
   is offline. */
.maint-badge {
  width: 3.5rem; height: 3.5rem;
  margin: 0 auto var(--s4);
  display: flex; align-items: center; justify-content: center;
  border-radius: var(--r-full);
  background: var(--warn-bg); color: var(--warn-ink);
  border: 1px solid var(--warn-edge);
  font-size: 1.375rem;
}

/* The three facts are a LIST because they are read one by one and each answers a different
   question ("when", "did my upload go through", "is my data being touched"); left-aligned inside a
   centred card for the same reason every other run of text in this app is left-aligned — centred
   prose is read by scanning, and these have to be read. */
.maint-facts {
  list-style: none; margin: var(--s5) 0; padding: 0;
  display: grid; gap: var(--s3);
  text-align: left;
}
.maint-facts li {
  display: flex; gap: var(--s3); align-items: flex-start;
  font-size: .875rem; line-height: 1.55; color: var(--ink-2);
}
.maint-facts i { flex: none; width: 1rem; margin-top: .2rem; text-align: center; color: var(--ink-3); }

/* The operator's banner. It exists because the failure to guard against is not switching
   maintenance ON, it is forgetting it is on — and the operator is the one person the screen cannot
   tell, since the bypass shows them the ordinary platform. Same warn triplet as the badge, so the
   two read as one state; its partner is the badged favicon, because this scrolls away and a tab
   does not. */
.maint-banner {
  display: flex; gap: var(--s3); align-items: flex-start;
  margin: 0 0 var(--s5); padding: var(--s3) var(--s4);
  border: 1px solid var(--warn-edge); border-left: 3px solid var(--warn-solid);
  border-radius: var(--r-md);
  background: var(--warn-bg); color: var(--warn-ink);
}
.maint-banner-i { margin-top: .15rem; font-size: 1rem; }
.maint-banner-t { margin: 0 0 .1rem; font-size: .9375rem; font-weight: 600; }
/* These paragraphs are box copy, not prose: the measure would cap them well short of the banner
   they live in — the same subtraction .alert and .empty already make. */
.maint-banner p { max-width: none; margin: 0; font-size: .875rem; line-height: 1.5; }
.maint-banner-meta { margin-top: .25rem; font-size: .8125rem; opacity: .85; }

/* ---- The per-vowel workspace (§149) ---------------------------------------------------------
   The laboratory's workflow is six steps in a fixed order (technical control, speaker and noise,
   quality label, vowels, measurement, closing), so the page carries a step tracker whose current
   step is marked in WORDS (aria-current) as well as colour, and each section heading repeats its
   number so a section reached by its anchor still says where it sits. */
.vowel-steps ol {
  list-style: none; margin: 0 0 var(--s5); padding: 0;
  display: flex; flex-wrap: wrap; gap: var(--s2);
}
.vowel-step a {
  display: inline-flex; align-items: center; gap: var(--s2);
  padding: var(--s2) var(--s3); border-radius: var(--r-full);
  border: 1px solid var(--line-strong); background: var(--surface); color: var(--ink-2);
  font-size: .875rem; text-decoration: none;
}
.vowel-step.is-done a    { border-color: var(--ok-edge); background: var(--ok-bg); color: var(--ok-ink); }
.vowel-step.is-current a { border-color: var(--brand-blue); background: var(--info-bg); color: var(--brand-blue-700); font-weight: 600; }
.vowel-step-n {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 1.5rem; height: 1.5rem; border-radius: var(--r-full);
  background: var(--surface-sunken); color: var(--ink-1); font-size: .8125rem; font-weight: 600;
}
.vowel-step.is-current .vowel-step-n { background: var(--brand-blue); color: #fff; }

/* Waveform with marked stretches: a click writes a time into the focused field, so the cursor
   says it is a target. */
.waveform-seg { cursor: crosshair; }
.wave-readout { min-height: 1.25rem; margin: var(--s1) 0 0; font-family: var(--font-code); font-size: .8125rem; }
.swatch-seg-h { background: rgba(0, 113, 188, .35); }
.swatch-seg-n { background: rgba(100, 115, 137, .5); }
.swatch-seg-v { background: #e0a020; }
.swatch-q     { background: #0071bc; }
.swatch-k     { background: #0e8f78; }
.chart-legend-line { display: flex; flex-wrap: wrap; gap: var(--s4); }

.seg-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr)); gap: var(--s4); margin: var(--s4) 0; }
.seg-columns .panel-card { margin: 0; }
.seg-columns h3 { display: flex; justify-content: space-between; align-items: baseline; gap: var(--s3); }

/* The scale is cited wherever a label is shown: the citation is part of the label (§148, R1). */
.scale-cite { font-size: .875rem; color: var(--ink-2); }
.scale-cite i { color: var(--ink-3); margin-right: .25rem; }

/* The perceptual form: one row of options per criterion, each option its level and its words. */
.crit-row { display: grid; gap: var(--s2); }
.crit-option { display: flex; gap: var(--s2); align-items: flex-start; font-size: .875rem; line-height: 1.45; }
.crit-option input { margin-top: .2rem; }

/* The five quality levels as blocks, best to worst; the proposal is marked and the choice is the
   examiner's. Colours are the semantic triplets, never the brand accent: a level is a finding
   about the material, not a place in the app. BC and C read as the stop they are. */
.level-blocks { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: var(--s3); }
.level-block {
  position: relative; display: grid; gap: var(--s1); padding: var(--s3);
  border: 1px solid var(--line-strong); border-radius: var(--r-md); background: var(--surface); cursor: pointer;
}
.level-block input { position: absolute; top: var(--s3); right: var(--s3); }
.level-block-h { font-size: 1.125rem; font-weight: 600; }
.level-block-t { font-size: .875rem; color: var(--ink-1); }
.level-block-c { font-size: .8125rem; color: var(--ink-2); }
.level-block.is-proposed { border-width: 2px; border-color: var(--brand-blue); }
.level-block:has(input:checked) { box-shadow: var(--ring); }
.level-A, .level-AB { background: var(--ok-bg); }
.level-B { background: var(--warn-bg); }
.level-BC, .level-C { background: var(--err-bg); }
.level-chip {
  display: inline-block; min-width: 2rem; padding: .1rem .5rem; border-radius: var(--r-full);
  text-align: center; font-weight: 600; font-size: .875rem; border: 1px solid var(--line-strong);
}
.level-chip.level-A, .level-chip.level-AB { color: var(--ok-ink); border-color: var(--ok-edge); }
.level-chip.level-B { color: var(--warn-ink); border-color: var(--warn-edge); }
.level-chip.level-BC, .level-chip.level-C, .level-chip.level-BCC { color: var(--err-ink); border-color: var(--err-edge); background: var(--err-bg); }

/* Vowel counters: amber below the minimum number of tokens (§6.3). */
.vowel-counters { display: flex; flex-wrap: wrap; gap: var(--s2); }
.vchip {
  padding: .2rem .6rem; border-radius: var(--r-full); font-size: .875rem;
  background: var(--ok-bg); color: var(--ok-ink); border: 1px solid var(--ok-edge);
}
.vchip-low { background: var(--warn-bg); color: var(--warn-ink); border-color: var(--warn-edge); }
.vchip-pending { background: var(--surface-2); color: var(--ink-2); border-color: var(--line-strong); }

/* The candidate table: compact rows, the focused row visible for keyboard labelling. */
.cand-table td { padding-top: .3rem; padding-bottom: .3rem; }
.cand-table .time-input { width: 7.5rem; min-height: 2rem; font-family: var(--font-code); font-size: .8125rem; }
.cand-table select { min-height: 2rem; }
.cand-table tr.is-focused { outline: 2px solid var(--brand-blue); outline-offset: -2px; }
.cand-table tr.seg-descartado td { color: var(--ink-3); }
.cand-table tr.seg-candidato td { background: var(--surface-2); }

/* Tabs of the parameter table: links, one of them current. */
.tabs { display: flex; flex-wrap: wrap; gap: var(--s2); margin: var(--s3) 0; }

/* The parameter table: IQR small; a manual value in the warn tone with its automatic value struck
   through beside it (§8.2: "tachado y el manual en dorado"); limited-quality cells dimmed. */
.iqr { color: var(--ink-3); font-size: .75rem; }
.origin-m {
  display: inline-block; margin-left: .25rem; padding: 0 .3rem; border-radius: var(--r-xs);
  background: var(--warn-bg); color: var(--warn-ink); border: 1px solid var(--warn-edge);
  font-size: .6875rem; font-weight: 600;
}
.val-manual { color: var(--warn-ink); font-weight: 600; border-bottom: 1px dotted var(--warn-ink); }
.val-auto { color: var(--ink-3); }
.cell-limited { opacity: .55; }
.row-low td { background: var(--warn-bg); }
.cell-edit { margin-left: .25rem; color: var(--ink-3); }
.cell-edit:hover { color: var(--brand-blue); }
.vowel-summary td, .vowel-tokens td { white-space: nowrap; }

/* The F1/F2 vowel space, as SVG (§9.2): axes inverted as phonetics draws them, /ɨ/ in its own
   colour, the questioned and known samples in the two validated series colours. */
.vowel-space { width: 100%; max-width: 40rem; height: auto; display: block; background: var(--surface);
               border: 1px solid var(--line); border-radius: var(--r-lg); }
.vowel-space .vs-grid { stroke: var(--line); stroke-width: 1; }
.vowel-space .vs-tick { fill: var(--ink-3); font-size: 11px; font-family: var(--font-code); }
.vowel-space .vs-axis { fill: var(--ink-2); font-size: 12px; }
.vowel-space .vs-tok { fill: #0071bc; fill-opacity: .35; }
.vowel-space .vs-med { fill: #0071bc; stroke: #fff; stroke-width: 1.5; }
.vowel-space .vs-k.vs-tok, .vowel-space .vs-k.vs-med { fill: #0e8f78; }
.vowel-space .vs-y.vs-med, .vowel-space .vs-y.vs-tok { fill: #c08a14; }
.vowel-space .vs-label { font-size: 13px; font-weight: 600; fill: var(--ink-1); }
.vowel-space .vs-label.vs-k { fill: #0b6d5b; }
.vowel-space .vs-n { font-size: 10px; font-weight: 400; fill: var(--ink-3); }

.profile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: var(--s3); }
.profile-figure { margin: 0; }
.chart-wrap-small { height: 200px; }

/* The previous engine's retired parameters: folded, readable, never deleted (§148, B5). */
.retired-params { margin: var(--s4) 0; }
.retired-params summary { cursor: pointer; color: var(--ink-2); }

/* The verbal scale, whole, with the emitted conclusion marked (IAFPA CoP §4.2). */
.scale-list { margin: var(--s1) 0 var(--s3); padding-left: 1.25rem; font-size: .875rem; color: var(--ink-2); }
.scale-list .is-emitted { color: var(--ink-1); font-weight: 600; }

/* §158 — the ceiling-stability mark (C-04) and the per-vowel decision (C-01). The unstable token is
   drawn larger and ringed in the WARN ink, never red: it is a value to verify on the spectrogram,
   not a finding. The warn badge is the same triplet as .alert-warning. */
.badge-warn   { background: var(--warn-bg); color: var(--warn-ink); border-color: var(--warn-edge); }
.vowel-space .vs-tok.vs-unstable { fill-opacity: .15; stroke: #8a5a00; stroke-width: 1.4; }
/* §160 — a vowel below the minimum is compared as CONTEXT: its median is hollow and dashed, in the
   colour of its sample, so it can never be read as a median that decided anything. */
.vowel-space .vs-med.vs-ctx { fill: var(--surface); stroke: #0071bc; stroke-width: 1.6; stroke-dasharray: 3 2; }
.vowel-space .vs-k.vs-med.vs-ctx { fill: var(--surface); stroke: #0e8f78; }
.vowel-space .vs-y.vs-med.vs-ctx { fill: var(--surface); stroke: #c08a14; }
.vowel-decision td { white-space: nowrap; vertical-align: top; }
.vowel-decision td small { margin-left: .25rem; color: var(--ink-3); }
.verdict-expression-verify { font-size: .9375rem; color: var(--warn-ink); }
tr.is-emitted td { font-weight: 600; }
.plaus-hint { color: var(--ink-3); font-size: .75rem; margin-left: .25rem; }
.integrity-status { margin: var(--s3) 0; }

/* §159 (C-02) — two small charts of one quantity, questioned beside known, stacked on a phone; the
   same minmax(0,…) guard as .spectrogram-pair so a canvas can never force a horizontal scroll. */
.chart-pair {
  display: grid; gap: var(--s3);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
}
.chart-pair .chart-figure { margin: 0; min-width: 0; }

/* §157 (C-17) — the error page, inside the sign-in card of the platform. */
.error-card h1 { margin: 0 0 var(--s2); font-size: 2rem; color: var(--ink-1); }
.error-card p { margin: 0 0 var(--s3); }
