/* ============================================
   Components (spec 12), on role tokens
   ============================================
   The shared component layer of the light theme. Role tokens only (tokens.css): no
   colour literal, no --legacy-* name, no --_* primitive. The reference implementation is
   the prototype's app.css, section 6.

   - Every production class and state class keeps its name: the JS reads and toggles them
     (.active, .open, .focused, .hidden, .d-none...; test_js_hooks.py guards them).
   - Where the prototype names a component differently from production, one rule serves
     both through a selector list (.btn-primary, .btn--primary): one look per component.
   - Classes that templates or JS emit without any rule are defined here by the role they
     play at their call sites (.btn-ghost, .btn-success, .badge, .form-input, .text-muted...).
   - No transform, filter, contain or container-type on a card or a tab panel: the fixed
     modals live inside the page content, and those properties would trap them.

   Order: shared icon masks, surfaces (cards, tab panels), the page layout of the rewritten
   pages (page header, sheet, panels, wells, stacks: prototype app.css section 3), then
   spec 12: 1 buttons, 2 fields, 3 choices, 4 underline tabs, 5 segmented control and
   pagination, 7 type badges, 8 status chips, 9 tags, 10 selectable chips (with the
   column-list toolbar and the paste modal), 11 badges, 12 readouts, 13 data table, 14
   callouts, 15 toasts and modals, 16 tooltips, 17 menu, 18 drawers, 19 disclosure, 20
   empty state, 21 progress, skeleton and spinners, 22 drop zone; then the empreinte
   (spec 8.5), the « Préparation des données » (SotisDataRecipe, lot L3), the chart cards
   (spec 11, prototype app.css section 7) and the utilities (app.css section 8). The
   stepper (6) and the navigation chrome (23) are in base.css, the notes drawer in
   notes.css. */

/* ============================================
   Shared: icon masks and the inline sprite
   ============================================
   CSS-only icons (search fields, select chevrons, chip checks, callout and verdict
   icons, sort arrows) are masks painted with a token. The data URIs carry no colour,
   only currentColor, which a mask reads as opaque. Shapes of the prototype's sprite. */
:root {
    --mask-chevron-down: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
    --mask-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
    --mask-search: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E");
    --mask-check-circle: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='m8.5 12.5 2.4 2.4 4.6-5'/%3E%3C/svg%3E");
    --mask-alert-triangle: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.3 4.2 2.6 17.6A2 2 0 0 0 4.3 20.6h15.4a2 2 0 0 0 1.7-3L13.7 4.2a2 2 0 0 0-3.4 0Z'/%3E%3Cpath d='M12 9.5v4M12 17h.01'/%3E%3C/svg%3E");
    --mask-x-circle: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='m15 9-6 6M9 9l6 6'/%3E%3C/svg%3E");
    --mask-info: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M12 16v-4.5M12 8h.01'/%3E%3C/svg%3E");
    --mask-sort: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 9 5-5 5 5M7 15l5 5 5-5'/%3E%3C/svg%3E");
    --mask-sort-up: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 14 5-5 5 5'/%3E%3C/svg%3E");
    --mask-sort-down: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3C/svg%3E");

    /* Component parameters, set inline or by a page on an ancestor: their defaults live
       here so that a declaration on the component never cuts the inheritance. */
    --table-max-height: none;   /* .table-box */
    --th-w: auto;               /* .th-name, two-line data headers (SotisShell.headerWidth) */
    --unit-width: 22px;         /* .input-unit, the room of its suffix */
    --value: 0;                 /* .progress, the fill in percent */
    --chart-height: 320px;      /* .chart-box (SotisChart.render sets it to a figure's own height) */
    --chart-min-width: 0px;     /* .chart-scroll > .chart-box: under it, the wrapper scrolls sideways */
    --swatch: var(--series-1);  /* .legend-swatch, .legend-line: the series they name */
    --label-shift: 0px;         /* .gauge-marker-label, set by SotisShell.gauges() */
    --label-lift: 0px;          /* .gauge-marker-label raised a row or over the needle's cap (gauges()) */
    --gauge-label-room: 0px;    /* .gauge, the room its raised labels take above it (gauges()) */
}

/* The connexion page carries its own sprite (base.html includes _icons.html, hidden). */
.icon-sprite { display: none; }

/* ============================================
   Surfaces: cards and tab panels
   ============================================
   A card is a flat panel (spec 7): white, 1 px line, radius 16, no shadow; the paper
   shadow belongs to the page's one sheet. */
.card {
    padding: var(--space-6);
    margin-bottom: var(--space-6);
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius-l);
}
.card-title {
    margin-bottom: var(--space-4);
    color: var(--ink);
    font-size: var(--text-l);
    font-weight: var(--weight-heading);
    line-height: var(--leading-snug);
    letter-spacing: -0.01em;
}
.card-title-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    margin-bottom: var(--space-4);
}

/* Exploration views: one panel per rail .tab (init.js toggles .active). No entry
   animation (spec 18: no animation on load). */
.tab-panel:not(.active) { display: none; }

/* Kept for upload.css and ml.css. Opacity only: a transform, even while it runs, would
   make the animated panel the containing block of the fixed modals inside it. */
@keyframes fadeInUp {
    from { opacity: 0; }
    to { opacity: 1; }
}

/* ============================================
   Page layout (spec 7 and 8.1; prototype app.css section 3)
   ============================================
   The desk holds a page header, the page's one sheet and flat panels (maximum nesting: desk,
   then sheet or panel, then well). Opt-in classes for the rewritten pages: none of these
   names existed in production, but .page-label, which the Exploration pager uses
   (dashboard.css): the section label above the h1 is .page-header .page-label here.
   .page-stack is the prototype's desk layout for a page's content: base.css keeps .desk in
   block layout, so the pages not rewritten yet keep their own margins. */
.page-stack { display: flex; flex-direction: column; gap: var(--space-4); min-width: 0; }
/* Short windows (Chrome leaves 790 px of page on a 1440 x 900 screen, 690 on 1280 x 800):
   12 px between blocks, so the key sheet keeps its fold budget. */
@media (min-width: 1100px) and (max-height: 820px) {
    .page-stack { gap: var(--space-3); }
}

/* Page header: in the desk, not sticky. Section label, h1, one context line, actions on the
   right, ending on the h1 row; the context line takes the full width under them. Under
   780 px everything stacks, the actions last. */
.page-header {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas:
        "label actions"
        "title actions"
        "context context";
    align-items: end;
    column-gap: var(--space-6);
    row-gap: 2px;
    padding-bottom: var(--space-1);
}
.page-heading { display: grid; gap: 2px; min-width: 0; }
.page-header > .page-heading { display: contents; }
.page-header .page-label {
    grid-area: label;
    color: var(--ink-3);
    font-size: var(--text-s);
    font-weight: var(--weight-semibold);
    line-height: var(--leading-snug);
}
.page-header .page-title { grid-area: title; }
.page-header .page-context { grid-area: context; }
.page-header > .page-actions { grid-area: actions; justify-content: flex-end; }
@media (max-width: 779px) {
    .page-header {
        grid-template-columns: minmax(0, 1fr);
        grid-template-areas: "label" "title" "context" "actions";
    }
    .page-header > .page-actions { justify-content: flex-start; margin-top: var(--space-3); }
}
.page-title {
    color: var(--ink);
    font-size: var(--text-xl);
    font-weight: var(--weight-heading);
    line-height: 1.25;
    letter-spacing: -0.01em;
    text-wrap: balance;
}
.page-context { margin-top: 2px; color: var(--ink-2); font-size: var(--text-s); line-height: 1.45; }
.page-context--quiet { color: var(--ink-3); }
.page-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
/* Facts separated by hairlines, never by a middle dot. Each fact draws its hairline on its
   left, outside its own box (21 px of gap: 10 + 1 + 10), and the line clips what falls past
   its left edge: the first fact, and any fact that opens a wrapped line, has none. */
.page-context, .facts, .facts-line {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    column-gap: 21px;
    row-gap: 2px;
    overflow: hidden;
}
.page-context > *, .facts > *, .facts-line > * { position: relative; }
.page-context > *::before, .facts > *::before, .facts-line > *::before {
    content: "";
    position: absolute;
    top: 50%;
    left: -11px;
    width: 1px;
    height: 0.95em;
    margin-top: -0.48em;
    background: var(--line-strong);
}

/* THE sheet: white paper with the only paper shadow of the page; it holds the key
   information. Positioned flat panels for everything else (a panel that follows the sheet
   paints above its paper shadow); wells, sunken and borderless, inside them. */
.sheet {
    position: relative;
    padding: var(--space-6);
    border: 1px solid var(--line);
    border-radius: var(--radius-l);
    background: var(--surface);
    box-shadow: var(--shadow-paper);
}
.sheet--flush { padding: 0; overflow: hidden; }
.panel {
    position: relative;
    padding: var(--space-5) var(--space-6);
    border: 1px solid var(--line);
    border-radius: var(--radius-l);
    background: var(--surface);
}
.panel--tight { padding: var(--space-4); }
.well { padding: var(--space-3) var(--space-4); border-radius: var(--radius-m); background: var(--surface-sunken); }
/* « Suite »: the panel that ends a step and says where to go next (Exploration, Anomalies). */
.next-panel {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3) var(--space-6);
    padding: 18px var(--space-6);
}
.next-panel-text { display: grid; flex: 1 1 420px; gap: 4px; min-width: 0; }
.next-panel-sentence { max-width: 88ch; color: var(--ink-2); font-size: var(--text-s); line-height: 1.45; }

/* A block's head inside a sheet or a panel: its title, a muted line, actions on the right. */
.block-head {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    margin-bottom: var(--space-3);
}
.block-head h2, .block-title {
    color: var(--ink);
    font-size: var(--text-l);
    font-weight: var(--weight-heading);
    line-height: var(--leading-snug);
    letter-spacing: -0.01em;
}
.block-head h3 { color: var(--ink); font-size: var(--text-m); font-weight: var(--weight-semibold); line-height: var(--leading-snug); }
.block-sub { color: var(--ink-3); font-size: var(--text-s); }
.lead { max-width: 72ch; color: var(--ink-2); font-size: var(--text-m); }
.muted { color: var(--ink-3); }
.small { font-size: var(--text-s); }
.xsmall { font-size: var(--text-xs); }

.grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); }
.grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-4); }
.stack { display: grid; align-content: start; gap: var(--space-3); }
.stack-2 { display: grid; align-content: start; gap: var(--space-2); }
.row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.row-3 { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); }
.spacer { flex: 1; }
@media (max-width: 900px) {
    .grid-2, .grid-3 { grid-template-columns: minmax(0, 1fr); }
}

/* ============================================
   1. Buttons (spec 12.1)
   ============================================
   Height 36 (small 30, large 44), radius 10, 15/600, icon 18 + 8 gap. Primary: one per
   page. Filled danger: confirm modals only. Disabled: sunken fill and ink-disabled text,
   never an opacity. Production and prototype names share each rule. */
.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    height: var(--control-height);
    padding: 0 14px;
    border: 1px solid transparent;
    border-radius: var(--radius-m);
    background: transparent;
    color: var(--ink);
    font-size: var(--text-m);
    font-weight: var(--weight-semibold);
    line-height: 1;
    white-space: nowrap;
    text-decoration: none;
    cursor: pointer;
    transition: background-color var(--duration-fast) var(--ease), border-color var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease);
}
.btn:hover { text-decoration: none; }
.btn .icon { width: 18px; height: 18px; }
/* A .btn without a variant (Stockage « Export », « Import », « Synchroniser ») takes the
   ghost's hover. :where() keeps it at (0,2,0): the variants' hover rules follow it, and a
   page rule of the same weight wins by load order. */
.btn:where(:not([class*="btn-"])):hover { background: var(--surface-hover); }

/* Primary: accent fill, white. .btn-success (« Retrain with selected features », the
   commit action of the feature selection panel) is a primary: green marks a state,
   never an action. */
.btn-primary, .btn--primary, .btn-success {
    background: var(--accent);
    color: var(--ink-on-accent);
}
.btn-primary:hover, .btn--primary:hover, .btn-success:hover { background: var(--accent-hover); }

/* Secondary: white, 1 px line-strong, ink. .btn-outline-muted (« Télécharger le
   rapport », Prédire) is a secondary. */
.btn-secondary, .btn--secondary, .btn-outline-muted {
    background: var(--surface);
    border-color: var(--line-strong);
    color: var(--ink);
}
.btn-secondary:hover, .btn--secondary:hover, .btn-outline-muted:hover {
    background: var(--surface-hover);
    border-color: var(--ink-3);
}

/* Ghost: transparent, ink-2 (compare.js « Change »). */
.btn-ghost, .btn--ghost { color: var(--ink-2); }
.btn-ghost:hover, .btn--ghost:hover { background: var(--surface-hover); color: var(--ink); }

/* Danger, filled: danger-ink, white 6.57:1, hover danger-hover 8.14:1. */
.btn-danger, .btn--danger {
    background: var(--danger-ink);
    color: var(--ink-on-accent);
}
.btn-danger:hover, .btn--danger:hover { background: var(--danger-hover); }

/* Danger, quiet: ink-3 at rest, danger only on hover. Also on icon buttons (the notes
   drawer's « Tout supprimer »), hence a hover strong enough to beat .icon-btn:hover.
   .btn--outline-danger: « Supprimer IQR / Percentile / Manual » (_table_tab.html). */
.btn--danger-quiet, .btn--outline-danger { color: var(--ink-3); }
.btn--danger-quiet:hover:not(:disabled), .btn--outline-danger:hover:not(:disabled) {
    background: var(--danger-soft);
    color: var(--danger-ink);
}

/* Sizes. */
.btn-sm, .btn--sm {
    height: var(--control-height-s);
    gap: 6px;
    padding: 0 10px;
    font-size: var(--text-s);
}
.btn-sm .icon, .btn--sm .icon { width: 16px; height: 16px; }
.btn--lg { height: var(--control-height-l); padding: 0 18px; }
.btn--block { width: 100%; }

/* Disabled: no opacity; its reason goes in a tooltip on a wrapper (spec 12). */
.btn:disabled, .btn[aria-disabled="true"] {
    background: var(--surface-sunken);
    border-color: var(--line);
    color: var(--ink-disabled);
    cursor: not-allowed;
}

/* Loading: btnLoading() (utils.js) disables the button and puts a .btn-spinner before
   the label; the prototype marks it aria-busy. Either way the variant keeps its fill and
   the label stays: the spinner says it works. */
.btn[aria-busy="true"], .btn:has(> .btn-spinner) { cursor: progress; }
.btn:is(.btn-primary, .btn--primary, .btn-success):is([aria-busy="true"], :has(> .btn-spinner)) {
    background: var(--accent);
    border-color: transparent;
    color: var(--ink-on-accent);
}
.btn:is(.btn-secondary, .btn--secondary, .btn-outline-muted):is([aria-busy="true"], :has(> .btn-spinner)) {
    background: var(--surface);
    border-color: var(--line-strong);
    color: var(--ink);
}
.btn:is(.btn-danger, .btn--danger):is([aria-busy="true"], :has(> .btn-spinner)) {
    background: var(--danger-ink);
    border-color: transparent;
    color: var(--ink-on-accent);
}
/* The button spinner. .spinner-dot: storage.js puts it in « Export... ». The prototype
   names it .spinner, a name production gives the page loading overlay's 48 px ring
   (base.css): production's .btn-spinner keeps the role. */
.btn-spinner, .spinner-dot {
    display: inline-block;
    flex: none;
    width: 16px;
    height: 16px;
    border: 2px solid currentColor;
    border-right-color: transparent;
    border-radius: 50%;
    vertical-align: -3px;
    animation: spin 0.8s linear infinite;
}

/* Link button: accent-ink, underlined. Production's .btn-link (« All », « None »,
   « Tout garder ») keeps its compact 13.5 px size; no capitals. */
.btn-link, .link-btn {
    padding: 0;
    border: 0;
    background: none;
    color: var(--accent-ink);
    font-weight: var(--weight-semibold);
    text-decoration: underline;
    text-decoration-thickness: 1px;
    text-underline-offset: 3px;
    cursor: pointer;
}
.btn-link { padding: 2px 4px; font-size: var(--text-s); }
.btn-link:hover, .link-btn:hover { text-decoration-thickness: 2px; }

/* Icon buttons: 32 px (small 28), transparent, ink-3; hover graphite; aria-label and a
   tooltip carry the name. Production's .btn-icon (expand, close, session row actions)
   is the same component, and so are the chrome's icon buttons (rail, notes drawer). */
.icon-btn, .btn-icon {
    display: inline-grid;
    place-items: center;
    flex: none;
    width: 32px;
    height: 32px;
    padding: 0;
    border: 0;
    border-radius: var(--radius-m);
    background: transparent;
    color: var(--ink-3);
    cursor: pointer;
}
.icon-btn:hover, .icon-btn[aria-expanded="true"], .btn-icon:hover, .btn-icon[aria-expanded="true"] {
    background: var(--surface-hover);
    color: var(--ink);
}
.btn-icon svg { width: 18px; height: 18px; }
.icon-btn--sm { width: 28px; height: 28px; }
.icon-btn--sm .icon { width: 16px; height: 16px; }
.icon-btn:disabled, .btn-icon:disabled {
    background: transparent;
    color: var(--ink-disabled);
    cursor: not-allowed;
}
/* The one filled icon button: send (the notes composer). */
.icon-btn--accent { background: var(--accent); color: var(--ink-on-accent); }
.icon-btn--accent:hover { background: var(--accent-hover); color: var(--ink-on-accent); }

/* ============================================
   2. Fields (spec 12.2)
   ============================================
   Label 13.5/600 ink-2 above, the control, then a hint (ink-3) or an error (icon + text
   in danger-ink, tied by aria-describedby). Controls: height 36, padding 0 12, 1 px
   line-control (3.87:1 on white), radius 10, ink 15, placeholder ink-3. Focus: accent
   border and the 2 px ring at 1 px; no halo on mouse focus. Production .form-control,
   .form-select, the Simuler .form-input and showTextPrompt's .text-prompt-input share
   the prototype's .input, .select and .textarea. */
.form-group { margin-bottom: var(--space-4); }
.form-label, .field-label {
    color: var(--ink-2);
    font-size: var(--text-s);
    font-weight: var(--weight-semibold);
    line-height: 1.35;
}
.form-label { display: block; margin-bottom: 6px; }

/* align-content: start keeps the rows at their content height beside a taller field. */
.field { display: grid; gap: 6px; align-content: start; min-width: 0; }
.field-hint { color: var(--ink-3); font-size: var(--text-s); line-height: 1.4; }
.field-error {
    display: flex;
    align-items: flex-start;
    gap: 6px;
    color: var(--danger-ink);
    font-size: var(--text-s);
    line-height: 1.4;
}
.field-error .icon { width: 16px; height: 16px; margin-top: 1px; }

.form-control, .form-select, .form-input, .text-prompt-input, .input, .select, .textarea {
    height: var(--control-height);
    padding: 0 12px;
    border: 1px solid var(--line-control);
    border-radius: var(--radius-m);
    background: var(--input-bg);
    color: var(--ink);
    font-size: var(--text-m);
    line-height: 1.3;
    transition: border-color var(--duration-fast) var(--ease);
}
.form-control, .form-select, .text-prompt-input, .input, .select, .textarea { width: 100%; }
.input, .select, .textarea { display: block; }
:is(.form-control, .form-input, .text-prompt-input, .input, .textarea)::placeholder { color: var(--ink-3); opacity: 1; }

.form-control:focus-visible, .form-select:focus-visible, .form-input:focus-visible,
.text-prompt-input:focus-visible, .input:focus-visible, .select:focus-visible,
.textarea:focus-visible, .search-select-input:focus-visible, .feature-search:focus-visible,
.session-search:focus-visible, .chip-paste-text:focus-visible {
    border-color: var(--accent);
    outline: 2px solid var(--focus-ring);
    outline-offset: 1px;
}
:is(.form-control, .form-select, .form-input, .input, .select, .textarea):disabled {
    border-color: var(--line);
    background: var(--surface-sunken);
    color: var(--ink-disabled);
    cursor: not-allowed;
}
:is(.form-control, .form-select, .form-input, .input, .select, .textarea)[aria-invalid="true"] {
    border-color: var(--danger);
}

/* Compact field (30 px): the Simuler value fields (.form-input, sized by ml.css). */
.input--sm, .form-input { height: var(--control-height-s); font-size: var(--text-s); }
/* Numbers end on the right, in tabular figures. Decimal fields are type=text
   inputmode=decimal [data-decimal] (spec 12.2, decision D-F). */
.input.num { text-align: right; font-variant-numeric: tabular-nums; }
.textarea {
    height: auto;
    min-height: 88px;
    padding: 8px 12px;
    line-height: 1.5;
    resize: vertical;
}

/* Production width modifiers. */
.form-control-narrow { width: 70px; }
.form-control-sm { width: 100px; }
.form-select-auto { width: auto; min-width: 70px; }
.form-select-md { width: 150px; }

/* Form layout helpers. */
.form-group-flex { flex: 1; min-width: 250px; max-width: 350px; margin-bottom: 0; }
.form-group-flex-sm { min-width: 150px; margin-bottom: 0; }
.form-group-flex-md { flex: 1; min-width: 220px; max-width: 350px; margin-bottom: 0; }
.controls-row {
    display: flex;
    flex-wrap: wrap;
    align-items: end;
    gap: var(--space-4);
    margin-bottom: var(--space-4);
}
.controls-row .form-group { margin-bottom: 0; }

/* Select. Production .form-select keeps the native arrow (no wrapper to draw on); the
   prototype's .select sits in span.select-wrap, which draws the chevron icon. */
.select {
    padding-right: 34px;
    appearance: none;
    -webkit-appearance: none;
    cursor: pointer;
}
.select-wrap { position: relative; display: block; min-width: 0; }
.select-wrap::after {
    content: "";
    position: absolute;
    top: 50%;
    right: 11px;
    width: 16px;
    height: 16px;
    margin-top: -8px;
    background: var(--ink-3);
    -webkit-mask: var(--mask-chevron-down) center / contain no-repeat;
    mask: var(--mask-chevron-down) center / contain no-repeat;
    pointer-events: none;
}
.select-wrap--sm .select { height: var(--control-height-s); font-size: var(--text-s); }
/* A name field with suggestions (input[list] + datalist): the chevron replaces Chrome's
   triangle, whose indicator stays clickable under it. Without suggestions
   (.has-options off), no chevron. */
.combo-wrap .input { padding-right: 34px; }
.combo-wrap .input::-webkit-calendar-picker-indicator { opacity: 0; cursor: pointer; }
.combo-wrap:not(.has-options)::after { display: none; }
.combo-wrap:not(.has-options) .input { padding-right: 12px; }

/* Number with a unit inside the field (--unit-width: 22 px fits « % »). */
.input-unit { position: relative; display: block; }
.input-unit .input { padding-right: calc(var(--unit-width) + 12px); }
.input-unit-suffix {
    position: absolute;
    top: 50%;
    right: 12px;
    color: var(--ink-3);
    font-size: var(--text-m);
    line-height: 1;
    transform: translateY(-50%);
    pointer-events: none;
}

/* Search field: magnifier, input, clear button (shown once there is text). */
.search-field { position: relative; display: block; min-width: 0; }
.search-field > .icon {
    position: absolute;
    top: 50%;
    left: 11px;
    width: 16px;
    height: 16px;
    margin-top: -8px;
    color: var(--ink-3);
    pointer-events: none;
}
.search-field .input { padding-right: 34px; padding-left: 34px; }
.search-field .input:placeholder-shown { padding-right: 12px; }
.search-field .search-clear {
    position: absolute;
    top: 50%;
    right: 4px;
    width: 28px;
    height: 28px;
    margin-top: -14px;
}
.search-field .input:placeholder-shown + .search-clear { display: none; }
.search-field--sm .input { height: var(--control-height-s); font-size: var(--text-s); }
.search-field--sm .search-clear { width: 24px; height: 24px; margin-top: -12px; }
/* Chrome's own clear cross (blue, outside the palette): the field's button is the only one. */
.search-field input[type="search"]::-webkit-search-cancel-button,
.search-field input[type="search"]::-webkit-search-decoration {
    display: none;
    -webkit-appearance: none;
    appearance: none;
}

/* Searchable dropdown (initSearchSelect: .search-select-input, -dropdown.open, options
   with .focused (keyboard), .active (chosen), .hidden (filtered out)). The listbox is a
   raised menu under the field. */
.search-select { position: relative; min-width: 0; }
.search-select-input {
    width: 100%;
    height: var(--control-height);
    padding: 0 12px 0 34px;
    border: 1px solid var(--line-control);
    border-radius: var(--radius-m);
    background: var(--input-bg);
    color: var(--ink);
    font-size: var(--text-m);
    cursor: pointer;
    transition: border-color var(--duration-fast) var(--ease);
}
.search-select-input:focus { cursor: text; }
.search-select::before {
    content: "";
    position: absolute;
    z-index: 1;
    top: 50%;
    left: 11px;
    width: 16px;
    height: 16px;
    margin-top: -8px;
    background: var(--ink-3);
    -webkit-mask: var(--mask-search) center / contain no-repeat;
    mask: var(--mask-search) center / contain no-repeat;
    pointer-events: none;
}
.search-select-dropdown {
    display: none;
    position: absolute;
    z-index: var(--z-dropdown);
    top: calc(100% + 4px);
    right: 0;
    left: 0;
    max-height: 300px;
    padding: 4px;
    overflow-y: auto;
    background: var(--surface-raised);
    border: 1px solid var(--line);
    border-radius: var(--radius-m);
    box-shadow: var(--shadow-2);
}
.search-select-dropdown.open {
    display: flex;
    flex-direction: column;
}
.search-select-option {
    flex: none;
    min-height: 32px;
    padding: 6px 10px;
    border-radius: var(--radius-s);
    color: var(--ink);
    font-size: var(--text-table);
    line-height: 1.4;
    overflow-wrap: anywhere;
    cursor: pointer;
}
.search-select-option:hover,
.search-select-option.focused { background: var(--surface-hover); }
/* The keyboard position: an ink bar, as the prototype's listbox. */
.search-select-option.focused { box-shadow: inset 2px 0 0 var(--ink); }
/* The chosen value: graphite and 600, never violet. */
.search-select-option.active { background: var(--surface-selected); font-weight: var(--weight-semibold); }
.search-select-option.hidden { display: none; }
.search-select-label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.search-select-label strong { font-weight: var(--weight-bold); }
.search-select-hint { color: var(--ink-3); font-size: var(--text-xs); font-variant-numeric: tabular-nums; white-space: nowrap; }
.search-select-empty,
.search-select-more {
    padding: 8px 10px;
    color: var(--ink-3);
    font-size: var(--text-s);
}
.search-select-more { margin-top: 4px; border-top: 1px solid var(--line); }

/* Search over a chip or checkbox list (compact, 30 px), with its magnifier. */
.feature-search-wrapper { position: relative; margin-bottom: var(--space-2); }
.feature-search-wrapper.mb-0 { margin-bottom: 0; }
.feature-search-wrapper.flex-fixed { flex: 0 0 200px; }
.feature-search-wrapper.flex-grow { flex: 1; }
.feature-search-wrapper::before {
    content: "";
    position: absolute;
    top: 50%;
    left: 10px;
    width: 14px;
    height: 14px;
    margin-top: -7px;
    background: var(--ink-3);
    -webkit-mask: var(--mask-search) center / contain no-repeat;
    mask: var(--mask-search) center / contain no-repeat;
    pointer-events: none;
}
.feature-search {
    width: 100%;
    height: var(--control-height-s);
    padding: 0 10px 0 30px;
    border: 1px solid var(--line-control);
    border-radius: var(--radius-m);
    background: var(--input-bg);
    color: var(--ink);
    font-size: var(--text-s);
    transition: border-color var(--duration-fast) var(--ease);
}

/* Checkbox rows (the ML model cards, styled further by ml.css). */
.checkbox-item {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    padding: 4px 8px;
    border-radius: var(--radius-s);
    color: var(--ink);
    font-size: var(--text-s);
    cursor: pointer;
}
.checkbox-item:hover { background: var(--surface-hover); }
.checkbox-item input[type="checkbox"] {
    flex: none;
    width: 16px;
    height: 16px;
    margin: 0;
    accent-color: var(--accent);
    cursor: pointer;
}

/* ============================================
   3. Choices (spec 12.3)
   ============================================
   Native checkbox and radio, 18 px, accent-color (base.css sets it on :root); a switch
   36 x 20 (off track line-control, on track accent, white knob, label on the right); a
   native range with the accent fill. noUiSlider stays restyled by its page (ml.css,
   under .ml-target-range-slider), after the library's own stylesheet. */
.choice {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    color: var(--ink);
    font-size: var(--text-m);
    line-height: 1.35;
    cursor: pointer;
}
.choice input[type="checkbox"], .choice input[type="radio"] {
    flex: none;
    width: 18px;
    height: 18px;
    margin: 0;
    accent-color: var(--accent);
    cursor: pointer;
}
.choice:has(input:disabled) { color: var(--ink-disabled); cursor: not-allowed; }
.choice-group { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-5); }

.switch {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: 10px;
    font-size: var(--text-m);
    cursor: pointer;
}
.switch input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.switch-track {
    position: relative;
    flex: none;
    width: 36px;
    height: 20px;
    border-radius: var(--radius-pill);
    background: var(--line-control);
    transition: background-color var(--duration-fast) var(--ease);
}
.switch-track::after {
    content: "";
    position: absolute;
    top: 2px;
    left: 2px;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background: var(--surface);
    box-shadow: var(--shadow-1);
    transition: transform var(--duration-fast) var(--ease);
}
.switch input:checked + .switch-track { background: var(--accent); }
.switch input:checked + .switch-track::after { transform: translateX(16px); }
.switch input:focus-visible + .switch-track { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
.switch input:disabled + .switch-track { background: var(--line-strong); }

.range { width: 100%; accent-color: var(--accent); cursor: pointer; }

/* ============================================
   4. Underline tabs (spec 12.4)
   ============================================
   .u-tabs > button.u-tab[role=tab][aria-selected]; never the class .tab, which init.js
   reads as the Exploration views. 40 px, ink-3; selected ink 600 on a 2 px ink line. */
.u-tabs {
    display: flex;
    gap: 4px;
    overflow-x: auto;
    border-bottom: 1px solid var(--line);
}
.u-tab {
    flex: none;
    height: 40px;
    margin-bottom: -1px;
    padding: 0 12px;
    border: 0;
    border-bottom: 2px solid transparent;
    background: transparent;
    color: var(--ink-3);
    font-size: var(--text-m);
    font-weight: var(--weight-medium);
    cursor: pointer;
}
.u-tab:hover { color: var(--ink); }
.u-tab[aria-selected="true"] {
    border-bottom-color: var(--ink);
    color: var(--ink);
    font-weight: var(--weight-semibold);
}

/* ============================================
   5. Segmented control (spec 12.5) and pagination
   ============================================
   .segmented > .segmented-option[aria-pressed | aria-checked] (or .active): a sunken
   track, the chosen segment white with a 1 px line-control and 600, no shadow. */
.segmented {
    display: inline-flex;
    justify-self: start;
    align-self: start;
    gap: 2px;
    width: fit-content;
    max-width: 100%;
    padding: 3px;
    border-radius: var(--radius-m);
    background: var(--surface-sunken);
    box-shadow: inset 0 0 0 1px var(--line);
}
.segmented-option {
    height: 30px;
    padding: 0 12px;
    border: 1px solid transparent;
    border-radius: var(--radius-s);
    background: transparent;
    color: var(--ink-2);
    font-size: var(--text-s);
    font-weight: var(--weight-medium);
    white-space: nowrap;
    cursor: pointer;
}
.segmented-option:hover { color: var(--ink); }
/* Disabled: no opacity, as the buttons (the outlier drawer's other methods while a step is
   modified). */
.segmented-option:disabled { color: var(--ink-disabled); cursor: not-allowed; }
.segmented-option[aria-pressed="true"], .segmented-option[aria-checked="true"], .segmented-option.active {
    border-color: var(--line-control);
    background: var(--surface);
    color: var(--ink);
    font-weight: var(--weight-semibold);
}
.segmented--block { display: flex; width: 100%; }
.segmented--block .segmented-option { flex: 1; }

/* Pagination (« Lignes 1 à 10 sur 501 », « Page [1] sur 51 », rows per page). The
   prototype's .pager; production's Exploration pager (.pagination, dashboard.css)
   shares .page-selector, .page-input and .page-select, at the small control height. */
.pager {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-3);
    color: var(--ink-2);
    font-size: var(--text-s);
    font-variant-numeric: tabular-nums;
}
.pager .select-wrap, .pager .select { width: auto; }
.pager-buttons { display: flex; gap: 2px; }
.pager-info { color: var(--ink-2); font-size: var(--text-s); }
.page-selector {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    color: var(--ink-2);
}
.page-input {
    width: 52px;
    height: var(--control-height-s);
    padding: 0 8px;
    font-variant-numeric: tabular-nums;
}
.page-select { height: var(--control-height-s); font-size: var(--text-s); }
.pager-sep { width: 1px; height: 20px; background: var(--line); }

/* ============================================
   7. Type badges (spec 12.7)
   ============================================
   A glyph (or the type word, today) in the type's ink on its soft tint, before the
   column name; the word in aria-label. Float is neutral graphite (the 186 floats stay
   quiet, the rare types stand out). Neutral on Comparer and inside chart-control rows
   and the outlier drawer (.types-neutral, spec 3.4). */
.col-type-badge {
    display: inline-grid;
    place-items: center;
    flex: none;
    min-width: 26px;
    height: 18px;
    padding: 0 4px;
    border-radius: var(--radius-s);
    background: var(--type-soft, var(--type-float-soft));
    color: var(--type-ink, var(--type-float-ink));
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
    font-variant-numeric: tabular-nums;
    /* The line box is the badge's height: centred in grid and in inline-block contexts
       (the Exploration table headers, dashboard.css). */
    line-height: 18px;
    white-space: nowrap;
    /* A glyph, not data: a selection copied from a table header gives the names only. */
    -webkit-user-select: none;
    user-select: none;
}
.col-type-badge .icon { width: 13px; height: 13px; stroke-width: 2; }
.col-type-badge--float { --type-ink: var(--type-float-ink); --type-soft: var(--type-float-soft); }
.col-type-badge--integer { --type-ink: var(--type-integer-ink); --type-soft: var(--type-integer-soft); }
.col-type-badge--categorical { --type-ink: var(--type-categorical-ink); --type-soft: var(--type-categorical-soft); }
.col-type-badge--datetime { --type-ink: var(--type-datetime-ink); --type-soft: var(--type-datetime-soft); }
.col-type-badge--boolean { --type-ink: var(--type-boolean-ink); --type-soft: var(--type-boolean-soft); }
:is(.compare-page, .types-neutral) .col-type-badge { --type-ink: var(--ink-2); --type-soft: var(--surface-sunken); }

/* The type mark of the type filter chips (_type_filter_chips.html): the type's base,
   float neutral; grey once the type is switched off. */
.type-dot {
    flex: none;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--type-float);
}
.type-dot--integer { background: var(--type-integer); }
.type-dot--float { background: var(--type-float); }
.type-dot--categorical { background: var(--type-categorical); }
.type-dot--datetime { background: var(--type-datetime); }
.type-dot--boolean { background: var(--type-boolean); }

/* ============================================
   8. Status chips (spec 12.8)
   ============================================
   Pill 24, SVG icon 14 in the status base + word 13.5/500 in -ink on -soft. A status is
   always an icon and a word. */
.status-chip {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    height: 24px;
    padding: 0 10px 0 7px;
    border-radius: var(--radius-pill);
    background: var(--status-soft, var(--info-soft));
    color: var(--status-ink, var(--info-ink));
    font-size: var(--text-s);
    font-weight: var(--weight-medium);
    line-height: 1;
    white-space: nowrap;
}
.status-chip .icon { width: 14px; height: 14px; stroke-width: 2; color: var(--status-base, var(--info)); }
.status-chip--success { --status-soft: var(--success-soft); --status-ink: var(--success-ink); --status-base: var(--success); }
.status-chip--warning { --status-soft: var(--warning-soft); --status-ink: var(--warning-ink); --status-base: var(--warning); }
.status-chip--danger { --status-soft: var(--danger-soft); --status-ink: var(--danger-ink); --status-base: var(--danger); }
.status-chip--info { --status-soft: var(--info-soft); --status-ink: var(--info-ink); --status-base: var(--info); }

/* ============================================
   9. Neutral and state tags (spec 12.9)
   ============================================
   Pill 22, sunken, 1 px inset line, ink-2 12.5: format, group (folder icon), task,
   « Meilleur sur 2 modèles » (star), state tags (funnel, two arrows, notes). Production's
   .badge is this tag: .badge-purple (a session's group, compare.js, config.js,
   predict.js) and .badge-blue (« Current session », predict.js) are facts, so they stay
   neutral (violet is for actions). A removable tag adds a 16 px icon button. */
.tag, .badge, .badge-purple, .badge-blue {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    height: 22px;
    padding: 0 9px;
    border-radius: var(--radius-pill);
    background: var(--surface-sunken);
    box-shadow: inset 0 0 0 1px var(--line);
    color: var(--ink-2);
    font-size: var(--text-xs);
    font-weight: var(--weight-medium);
    line-height: 1;
    white-space: nowrap;
    vertical-align: middle;
}
.tag .icon, .badge .icon { width: 13px; height: 13px; stroke-width: 2; color: var(--ink-3); }
.tag--removable { padding-right: 3px; }
.tag-remove {
    display: inline-grid;
    place-items: center;
    width: 16px;
    height: 16px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: transparent;
    color: var(--ink-3);
    cursor: pointer;
}
.tag-remove:hover { background: var(--surface-selected); color: var(--ink); }
.tag-remove .icon { width: 11px; height: 11px; stroke-width: 2.25; }

/* ============================================
   10. Selectable chips (spec 12.10)
   ============================================
   White, 1 px line-strong, ink-2; selected (production .active, prototype aria-pressed)
   = surface-selected, ink 600 and a check: graphite, never violet. A chip grid is a
   sunken well that scrolls (column pickers); its chips are the compact 26 px ones. */
.chip-grid {
    display: flex;
    flex-wrap: wrap;
    align-content: flex-start;
    gap: 6px;
    max-height: 180px;
    padding: var(--space-2);
    overflow-y: auto;
    border-radius: var(--radius-m);
    background: var(--surface-sunken);
}
.chip, .type-chip {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    height: 30px;
    padding: 0 12px;
    border: 1px solid var(--line-strong);
    border-radius: var(--radius-pill);
    background: var(--surface);
    color: var(--ink-2);
    font-size: var(--text-s);
    font-weight: var(--weight-medium);
    white-space: nowrap;
    -webkit-user-select: none;
    user-select: none;
    cursor: pointer;
    transition: background-color var(--duration-fast) var(--ease), border-color var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease);
}
.chip:hover, .type-chip:hover { border-color: var(--ink-3); color: var(--ink); }
.chip.active, .chip[aria-pressed="true"], .type-chip.active {
    border-color: var(--line-control);
    background: var(--surface-selected);
    color: var(--ink);
    font-weight: var(--weight-semibold);
}
/* The check of a selected chip, drawn without markup (the prototype's .chip-check). */
.chip.active::before, .chip[aria-pressed="true"]::before {
    content: "";
    flex: none;
    width: 14px;
    height: 14px;
    margin-left: -2px;
    background: currentColor;
    -webkit-mask: var(--mask-check) center / contain no-repeat;
    mask: var(--mask-check) center / contain no-repeat;
}
.chip .chip-check { display: none; width: 14px; height: 14px; stroke-width: 2.25; }
.chip:has(.chip-check)::before { content: none; }
.chip.active .chip-check, .chip[aria-pressed="true"] .chip-check { display: block; }
.chip.hidden { display: none; }
/* A pasted name that matches no column (table.js, config.js). */
.chip.error {
    border-color: var(--danger);
    background: var(--danger-soft);
    color: var(--danger-ink);
}
.chip.error:hover { border-color: var(--danger-ink); }
.chip .col-type-badge { height: 16px; min-width: 24px; line-height: 16px; }

/* Compact chips: column and value pickers (every production chip grid, the
   stratification values of .strat-chip-grid included). */
.chip-grid .chip, .chip-grid--compact .chip, .strat-chip-grid .chip {
    height: 26px;
    gap: 5px;
    padding: 0 9px;
    font-size: var(--text-xs);
}
:is(.chip-grid, .chip-grid--compact, .strat-chip-grid) .chip::before,
:is(.chip-grid, .chip-grid--compact, .strat-chip-grid) .chip .chip-check { width: 13px; height: 13px; }

/* Type filter chips (_type_filter_chips.html: button.type-chip.type-chip--<type>
   [data-type], .active = the type is shown): a type mark, the word; a type switched
   off keeps a dashed border and ink-3. */
.type-filter-group {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px;
}
.type-chip { padding: 0 12px 0 10px; }
.type-chip:not(.active), .type-chip[aria-pressed="false"] {
    border-style: dashed;
    border-color: var(--line-control);
    color: var(--ink-3);
}
.type-chip:not(.active):hover { border-color: var(--ink-3); color: var(--ink); }
.type-chip:not(.active) .type-dot { background: var(--line-control); }
/* The chip's type (.type-chip--<type>): its ink and tint, for a glyph badge inside it (the
   prototype's type chip holds a .col-type-badge; production's, a .type-dot--<type>). A
   type switched off shows a neutral badge. */
.type-chip--integer { --type-ink: var(--type-integer-ink); --type-soft: var(--type-integer-soft); }
.type-chip--float { --type-ink: var(--type-float-ink); --type-soft: var(--type-float-soft); }
.type-chip--categorical { --type-ink: var(--type-categorical-ink); --type-soft: var(--type-categorical-soft); }
.type-chip--datetime { --type-ink: var(--type-datetime-ink); --type-soft: var(--type-datetime-soft); }
.type-chip--boolean { --type-ink: var(--type-boolean-ink); --type-soft: var(--type-boolean-soft); }
.type-chip:not(.active) .col-type-badge, .type-chip[aria-pressed="false"] .col-type-badge {
    --type-ink: var(--ink-3);
    --type-soft: var(--surface-sunken);
}
.type-chip .chip-count { color: var(--ink-3); font-weight: var(--weight-medium); font-variant-numeric: tabular-nums; }

/* The toolbar over a chip list: search, « All | None », count. */
.feature-toolbar {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    margin-bottom: var(--space-2);
}
/* The « | » between « All » and « None » becomes a hairline (spec 14: separators are
   CSS hairlines); its text is kept for the markup, not drawn. */
.feature-toolbar-sep {
    display: inline-block;
    flex: none;
    width: 1px;
    height: 16px;
    overflow: hidden;
    background: var(--line-strong);
    font-size: 0;
}
.feature-count {
    float: right;
    color: var(--ink-3);
    font-size: var(--text-xs);
    font-weight: var(--weight-regular);
    font-variant-numeric: tabular-nums;
}

/* Type bar of the column pickers (initChipExtras: .col-sel-type-btn[data-type], .active
   when every chip of the type is selected). Its tint and ink are the type badge's
   (section 7, from data-type: float neutral, an unknown type too), so a type keeps one
   colour on the panel; the state shows by the border (solid when active, dashed when
   not) and the weight, never by an opacity. */
.col-sel-type-bar {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    margin-bottom: var(--space-2);
}
.col-sel-type-btn {
    --type-ink: var(--ink-2);
    --type-soft: var(--surface-sunken);
    display: inline-flex;
    align-items: center;
    gap: 5px;
    height: 26px;
    padding: 0 10px;
    border: 1px dashed currentColor;
    border-radius: var(--radius-pill);
    background: var(--type-soft);
    color: var(--type-ink);
    font-size: var(--text-xs);
    font-weight: var(--weight-medium);
    cursor: pointer;
}
.col-sel-type-btn[data-type="integer"] { --type-ink: var(--type-integer-ink); --type-soft: var(--type-integer-soft); }
.col-sel-type-btn[data-type="float"] { --type-ink: var(--type-float-ink); --type-soft: var(--type-float-soft); }
.col-sel-type-btn[data-type="categorical"] { --type-ink: var(--type-categorical-ink); --type-soft: var(--type-categorical-soft); }
.col-sel-type-btn[data-type="datetime"] { --type-ink: var(--type-datetime-ink); --type-soft: var(--type-datetime-soft); }
.col-sel-type-btn[data-type="boolean"] { --type-ink: var(--type-boolean-ink); --type-soft: var(--type-boolean-soft); }
.col-sel-type-btn.active { border-style: solid; font-weight: var(--weight-semibold); }
.col-sel-type-btn:hover { box-shadow: inset 0 0 0 1px currentColor; }
.col-sel-type-btn .col-sel-type-count { font-weight: var(--weight-regular); font-variant-numeric: tabular-nums; }

/* Paste a list of columns (.convert-modal on Accueil and Conception): a textarea over a
   highlight layer, the match preview, the not-found names, the three modes. */
.col-sel-paste-preview {
    margin-top: var(--space-3);
    color: var(--ink-2);
    font-size: var(--text-s);
}
.col-sel-paste-preview .matched { color: var(--success-ink); font-weight: var(--weight-semibold); }
.col-sel-paste-preview .not-found { color: var(--danger-ink); font-weight: var(--weight-semibold); }
.col-sel-paste-notfound {
    max-height: 80px;
    margin-top: 6px;
    overflow-y: auto;
    color: var(--ink-3);
    font-size: var(--text-xs);
    line-height: 1.5;
}
.col-sel-paste-options {
    display: flex;
    flex-direction: column;
    gap: 6px;
    margin-top: var(--space-3);
}
.col-sel-paste-options label {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    color: var(--ink);
    font-size: var(--text-s);
    cursor: pointer;
}
.col-sel-paste-options input[type="radio"] { width: 18px; height: 18px; margin: 0; accent-color: var(--accent); }
/* The textarea and its highlight layer share one grid cell; the layer shows through
   the field once highlights exist (.has-highlights). */
.chip-paste-container {
    display: grid;
    width: 100%;
    max-height: 40vh;
    overflow-y: auto;
}
.chip-paste-container > * { grid-area: 1 / 1; }
.chip-paste-backdrop, .chip-paste-text {
    padding: 8px 12px;
    font-family: var(--font-mono);
    font-size: var(--text-s);
    line-height: 1.5;
    border-radius: var(--radius-m);
}
.chip-paste-backdrop {
    z-index: 1;
    overflow: auto;
    border: 1px solid transparent;
    background: var(--input-bg);
    color: var(--ink);
    white-space: pre-wrap;
    overflow-wrap: break-word;
    pointer-events: none;
}
.chip-paste-text {
    position: relative;
    z-index: 2;
    width: 100%;
    border: 1px solid var(--line-control);
    background: var(--input-bg);
    color: var(--ink);
    resize: none;
}
.chip-paste-text.has-highlights {
    background: transparent;
    color: transparent;
    caret-color: var(--ink);
}
.chip-paste-hl-green { color: var(--success-ink); }
.chip-paste-hl-red { color: var(--danger-ink); }
.chip-paste-hl-notfound { color: var(--ink-3); text-decoration: line-through; }

/* ============================================
   11. Badges (spec 12.11)
   ============================================
   Count (pill 18, surface-selected, ink 12.5/600: base.css, rail foot), « Nouveau » (the
   count badge's colours, decision D-M), letter A / B (20 px circle, compare fill, white
   12.5/700, Comparer only). */
.badge-new {
    display: inline-flex;
    align-items: center;
    height: 20px;
    padding: 0 8px;
    border-radius: var(--radius-pill);
    background: var(--surface-selected);
    color: var(--ink);
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
}
.letter-badge {
    display: inline-grid;
    place-items: center;
    flex: none;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: var(--compare-a);
    color: var(--ink-on-accent);
    font-size: var(--text-xs);
    font-weight: var(--weight-bold);
    line-height: 1;
}
.letter-badge--b { background: var(--compare-b); }

/* ============================================
   12. Readouts (spec 12.12)
   ============================================
   Quality strip cells, the hero figure, the graduated readout (gauge). Numbers in ink,
   proportional figures; a verdict is a status chip beside them. */
.quality-strip {
    display: flex;
    align-items: stretch;
    min-height: 64px;
    padding: 0;
    overflow-x: auto;
}
.quality-cell {
    display: flex;
    flex: none;
    flex-direction: column;
    justify-content: center;
    gap: 2px;
    min-width: 0;
    padding: 7px 18px;
}
.quality-cell + .quality-cell { border-left: 1px solid var(--line); }
.quality-cell--grow { flex: 1 0 auto; }
.quality-cell--end { justify-content: center; margin-left: auto; border-left: 1px solid var(--line); }
/* A phone: the cells wrap two by two instead of scrolling, so a verdict never sits off-screen;
   the row separators become top rules. */
@media (max-width: 599px) {
    .quality-strip { flex-wrap: wrap; overflow-x: visible; }
    .quality-cell { flex: 1 1 50%; }
    .quality-cell + .quality-cell, .quality-cell--end { border-left: 0; border-top: 1px solid var(--line); }
    .quality-cell--end { margin-left: 0; }
}
.quality-value {
    display: flex;
    align-items: baseline;
    gap: 6px;
    color: var(--ink);
    font-size: var(--text-lead);
    font-weight: var(--weight-semibold);
    line-height: 1.2;
    white-space: nowrap;
}
.quality-label { color: var(--ink-2); font-size: var(--text-s); line-height: 1.3; white-space: nowrap; }
/* The 20 px info button stays out of the label's line box, so every value sits on one line. */
.quality-label .info-btn { margin: -2px 0 -2px 2px; }
/* The fact behind a share, as a link to the tool that treats it. */
.quality-link {
    align-self: flex-start;
    padding: 0;
    border: 0;
    background: none;
    color: var(--accent-ink);
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
    line-height: 1.35;
    text-align: left;
    text-decoration: underline;
    text-decoration-thickness: 1px;
    text-underline-offset: 3px;
    white-space: nowrap;
    cursor: pointer;
}
.quality-link:hover { text-decoration-thickness: 2px; }
.quality-row { display: flex; align-items: center; gap: 10px; }

.hero-figure { display: grid; gap: 2px; }
.hero-label { color: var(--ink-3); font-size: var(--text-s); font-weight: var(--weight-semibold); }
.hero-value {
    color: var(--ink);
    font-size: var(--text-2xl);
    font-weight: var(--weight-semibold);
    line-height: 1.1;
    letter-spacing: -0.02em;
}
.hero-unit {
    margin-left: 4px;
    color: var(--ink-3);
    font-size: var(--text-lead);
    font-weight: var(--weight-medium);
    letter-spacing: 0;
}

/* Readout strip: hairline-divided readouts (40 / 30 / 30 %) sharing one instrument row
   through a subgrid (label, value, scale, caption), so the scales line up whatever the
   size of the value above. */
.readout-strip {
    display: grid;
    grid-template-columns: 4fr 3fr 3fr;
    grid-auto-rows: auto;
    align-items: stretch;
}
.readout-strip > * { min-width: 0; padding: 4px 24px; }
.readout-strip > .readout { grid-row: span 4; grid-template-rows: subgrid; }
.readout-strip > .readout > .readout-value { align-self: end; }
.readout-strip > * + * { border-left: 1px solid var(--line); }
.readout-strip > :first-child { padding-left: 0; }
.readout-strip > :last-child { padding-right: 0; }
@media (max-width: 1180px) {
    .readout-strip { grid-template-columns: minmax(0, 1fr); row-gap: 16px; }
    .readout-strip > * { padding: 0; }
    .readout-strip > * + * { padding-top: 16px; border-top: 1px solid var(--line); border-left: 0; }
}
/* A strip that opens on a hero, from 700 to 1180 px: the hero on its own row, the two
   graduated readouts side by side under it. */
@media (max-width: 1180px) and (min-width: 700px) {
    .readout-strip:has(> .readout--hero:first-child) { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 0; }
    .readout-strip > .readout--hero:first-child { grid-column: 1 / -1; padding: 0 0 18px; border: 0; }
    .readout-strip > .readout--hero:first-child .gauge { max-width: 620px; }
    .readout-strip > .readout--hero:first-child + * { padding: 16px 24px 0 0; border-top: 1px solid var(--line); border-left: 0; }
    .readout-strip > .readout--hero:first-child + * + * { padding: 16px 0 0 24px; border-top: 1px solid var(--line); border-left: 1px solid var(--line); }
}

.readout { display: grid; gap: 6px; align-content: start; }
.readout-label { color: var(--ink-2); font-size: var(--text-s); font-weight: var(--weight-semibold); line-height: 1.3; }
.readout-value { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
.readout-number {
    color: var(--ink);
    font-size: var(--text-xl);
    font-weight: var(--weight-semibold);
    line-height: 1.15;
    letter-spacing: -0.01em;
}
/* The hero's line box hugs its figures, so the scales of a strip stay on one line. */
.readout--hero .readout-number { font-size: var(--text-2xl); line-height: 0.85; letter-spacing: -0.02em; }
.readout-unit { margin-left: -6px; color: var(--ink-3); font-size: var(--text-m); }
.readout-value .status-chip { align-self: center; }
.readout-caption { color: var(--ink-3); font-size: var(--text-xs); line-height: 1.45; }
.readout-caption strong { color: var(--ink-2); font-weight: var(--weight-semibold); }
.readout-caption .tnum { font-variant-numeric: tabular-nums; }
.caption-line { display: block; }

/* Graduated readout (the gauge): inactive zones --line with 1 px white gaps at the
   limits, the active zone in its status base; ticks 12 px tabular at the limits; a 2 px
   ink needle (6 to 21 px, 1 px above the tick row); at most two hollow 8 px markers; a
   value off the scale is an outward chevron at the edge with its label inside. Built
   from --min, --max, --from, --to, --at: left = (at - min) / (max - min). After
   inserting gauges from JS, call SotisShell.gauges(root). A gauge whose labels rise (a
   second row, or over the needle's cap) takes that room above it (--gauge-label-room). */
.gauge {
    --min: 0;
    --max: 1;
    position: relative;
    height: 38px;
    margin: calc(2px + var(--gauge-label-room)) 0 0;
}
/* In a readout strip the scales share one subgrid row: bottom-aligned, they stay on one line
   when one gauge takes room above it for its labels. */
.readout > .gauge { align-self: end; }
.gauge-band {
    position: absolute;
    top: 13px;
    right: 0;
    left: 0;
    height: 6px;
}
.gauge-zone {
    --from: 0;
    --to: 1;
    position: absolute;
    top: 0;
    bottom: 0;
    left: calc((var(--from) - var(--min)) / (var(--max) - var(--min)) * 100%);
    width: calc((var(--to) - var(--from)) / (var(--max) - var(--min)) * 100%);
    background: var(--line);
    box-shadow: inset 1px 0 0 var(--surface), inset -1px 0 0 var(--surface);
}
.gauge-zone:first-child { border-radius: 3px 0 0 3px; box-shadow: inset -1px 0 0 var(--surface); }
.gauge-zone:last-child { border-radius: 0 3px 3px 0; box-shadow: inset 1px 0 0 var(--surface); }
.gauge-zone:only-child { border-radius: 3px; box-shadow: none; }
.gauge-zone.is-active.zone--success { background: var(--success); }
.gauge-zone.is-active.zone--warning { background: var(--warning); }
.gauge-zone.is-active.zone--danger { background: var(--danger); }
.gauge-zone.is-active.zone--neutral { background: var(--ink-3); }

.gauge-tick {
    --at: 0;
    position: absolute;
    top: 22px;
    left: calc((var(--at) - var(--min)) / (var(--max) - var(--min)) * 100%);
    color: var(--ink-3);
    font-size: var(--text-tick);
    font-variant-numeric: tabular-nums;
    line-height: 1.2;
    white-space: nowrap;
    transform: translateX(-50%);
}
.gauge-tick::before {
    content: "";
    position: absolute;
    top: -4px;
    left: 50%;
    width: 1px;
    height: 3px;
    background: var(--line-control);
}
.gauge-tick.is-start { transform: none; }
.gauge-tick.is-start::before { left: 0; }
.gauge-tick.is-end { transform: translateX(-100%); }
.gauge-tick.is-end::before { right: 0; left: auto; }
/* A label that would touch its neighbour (shell.js hideCrowdedTicks): its mark stays. */
.gauge-tick.is-crowded { color: transparent; }

.gauge-needle {
    --at: 0;
    position: absolute;
    top: 6px;
    left: clamp(0%, calc((var(--at) - var(--min)) / (var(--max) - var(--min)) * 100%), 100%);
    width: 2px;
    height: 15px;
    margin-left: -1px;
    border-radius: 1px;
    background: var(--ink);
}
.gauge-needle::before {
    content: "";
    position: absolute;
    top: -3px;
    left: 50%;
    width: 6px;
    height: 6px;
    margin-left: -3px;
    border-radius: 50%;
    background: var(--ink);
}

.gauge-marker {
    --at: 0;
    position: absolute;
    top: 12px;
    left: clamp(0%, calc((var(--at) - var(--min)) / (var(--max) - var(--min)) * 100%), 100%);
    width: 8px;
    height: 8px;
    margin-left: -4px;
    border: 2px solid var(--ink-3);
    border-radius: 50%;
    background: var(--surface);
}
/* Centred on its marker, then moved by --label-shift: SotisShell.gauges() measures each label
   and places it inside the scale, over its marker, off the other label and off the needle's
   cap (the prototype's labels passed the scale's edge by 3.6 to 11.1 px); --label-lift raises
   it a row, or over the needle's cap, when one row leaves no room. .label-left and
   .label-right, set in the markup, hang a label on one side instead. */
.gauge-marker-label {
    position: absolute;
    bottom: calc(100% + 4px + var(--label-lift));
    left: 50%;
    color: var(--ink-3);
    font-size: var(--text-tick);
    font-variant-numeric: tabular-nums;
    line-height: 1.1;
    white-space: nowrap;
    transform: translateX(calc(-50% + var(--label-shift)));
}
.gauge-marker.label-left .gauge-marker-label { right: -2px; left: auto; transform: none; }
.gauge-marker.label-right .gauge-marker-label { left: -2px; transform: none; }
/* Off scale: an outward chevron at the edge, never a silent clamp. */
.gauge-marker.is-below, .gauge-marker.is-above {
    width: 0;
    height: 0;
    margin-left: 0;
    border-top: 5px solid transparent;
    border-bottom: 5px solid transparent;
    border-radius: 0;
    background: none;
}
.gauge-marker.is-below { top: 11px; margin-left: -7px; border-right: 7px solid var(--ink-3); border-left: 0; }
.gauge-marker.is-above { top: 11px; border-right: 0; border-left: 7px solid var(--ink-3); }
.gauge-marker.is-below .gauge-marker-label { right: auto; bottom: calc(6px + var(--label-lift)); left: 7px; transform: none; }
.gauge-marker.is-above .gauge-marker-label { right: 7px; bottom: calc(6px + var(--label-lift)); left: auto; transform: none; }

/* Compact (120 x 16) for strips: band and needle, no tick labels. With labelled limits
   (.gauge--ticks), 24 px, the height of the status chip beside it. */
.gauge--compact { width: 120px; height: 16px; margin: 0; }
.gauge--compact .gauge-band { top: 5px; }
.gauge--compact .gauge-needle { top: 1px; height: 14px; }
.gauge--compact .gauge-needle::before { display: none; }
.gauge--compact .gauge-tick { display: none; }
.gauge--compact.gauge--ticks { height: 24px; }
.gauge--compact.gauge--ticks .gauge-band { top: 2px; }
.gauge--compact.gauge--ticks .gauge-needle { top: 0; height: 10px; }
.gauge--compact.gauge--ticks .gauge-tick { display: block; top: 11px; line-height: 1.05; }
.gauge--compact.gauge--ticks .gauge-tick::before { top: -3px; height: 2px; }

/* ============================================
   13. Data table (spec 12.13)
   ============================================
   .table-box[role=region][aria-label][tabindex=0] > table.data-table: a sticky sunken
   header (ink-2 12.5/600), 36 px rows of 14 px, hairlines, no zebra; hover graphite;
   selected row graphite + a 3 px violet bar; numbers right-aligned and tabular, copied as
   shown. .data-table--data: the typed data tables (columns at least 120 px, two-line
   headers after the type badge). Production tables keep their page styles until their
   step adopts these classes. */
.table-wrap { position: relative; min-width: 0; }
/* Its focus ring is drawn by the wrapper, above the sticky cells. */
.table-wrap > .table-box:focus-visible { outline: none; }
.table-wrap:has(> .table-box:focus-visible)::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 5;
    border: 2px solid var(--focus-ring);
    pointer-events: none;
}
.table-box {
    position: relative;
    max-width: 100%;
    max-height: var(--table-max-height);
    overflow: auto;
    border: 1px solid var(--line);
    border-radius: var(--radius-m);
    background: var(--surface);
}
.table-box:focus-visible, .table-box :focus-visible { outline-offset: -2px; }
.table-box--flush { border: 0; border-radius: 0; }
.data-table {
    width: 100%;
    min-width: max-content;
    border-collapse: collapse;
    color: var(--ink);
    font-size: var(--text-table);
    line-height: 1.35;
}
.data-table--fit { min-width: 0; }
.data-table caption { -webkit-user-select: none; user-select: none; }
.data-table th, .data-table td {
    --row-bg: var(--surface);
    height: var(--row-height);
    padding: 7px 12px;
    border-bottom: 1px solid var(--line);
    background: var(--row-bg);
    text-align: left;
    vertical-align: middle;
    white-space: nowrap;
}
.data-table thead th {
    --row-bg: var(--surface-sunken);
    position: sticky;
    top: 0;
    z-index: 2;
    height: 40px;
    padding: 6px 12px;
    color: var(--ink-2);
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
    line-height: 1.3;
    vertical-align: bottom;
    white-space: normal;
}
.data-table--data thead th { height: 50px; min-width: var(--table-col-min); }
.data-table--data td { min-width: var(--table-col-min); }
/* A typed header: the type glyph, then the name, which breaks after its underscores
   (<wbr>) and clamps to two lines, the second under the name (a hanging indent). --th-w
   (SotisShell.headerWidth, at most 160 px) is set inline on the .th-name. */
.th-name { display: flex; align-items: flex-start; column-gap: 6px; }
.th-name > .col-type-badge { width: 26px; min-width: 26px; height: 16px; padding: 0; line-height: 16px; }
.th-text {
    display: -webkit-box;
    min-width: 0;
    overflow: hidden;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    overflow-wrap: anywhere;
    word-break: normal;
    text-align: left;
}
.data-table--data .th-name { width: var(--th-w); max-width: 160px; }
.data-table--data thead th.num .th-name { margin-left: auto; }
/* A column name is not a quantity: proportional digits. */
.data-table .th-name { font-variant-numeric: proportional-nums; }
.data-table thead th.num { text-align: right; }
.data-table td.num, .data-table th.num { text-align: right; font-variant-numeric: tabular-nums; }
.data-table td.muted { color: var(--ink-3); }
.data-table tbody tr:hover > * { --row-bg: var(--surface-hover); }
.data-table tbody tr.selected > *, .data-table tbody tr[aria-selected="true"] > * { --row-bg: var(--surface-selected); }
.data-table tbody tr.selected > :first-child, .data-table tbody tr[aria-selected="true"] > :first-child {
    box-shadow: inset 3px 0 0 var(--accent);
}
.data-table tbody tr:last-child > * { border-bottom: 0; }
/* Reference row (Référence, Dummy): ink-3, never a verdict. */
.data-table tbody tr.baseline-row > * { color: var(--ink-3); }

/* Sticky first column: opaque, follows hover and selection, right hairline, corner on top. */
.data-table .is-sticky-col, .data-table--sticky-first tr > :first-child {
    position: sticky;
    left: 0;
    z-index: 1;
    box-shadow: inset -1px 0 0 var(--line);
}
.data-table--sticky-first thead tr > :first-child, .data-table thead .is-sticky-col { z-index: 3; }
.data-table--sticky-first tbody tr.selected > :first-child,
.data-table--sticky-first tbody tr[aria-selected="true"] > :first-child {
    box-shadow: inset 3px 0 0 var(--accent), inset -1px 0 0 var(--line);
}

/* Sortable header: button.th-sort + a sort mask, aria-sort on the th. The icon takes room
   on the sorted column only; elsewhere it shows on hover or focus, in the cell padding. */
.th-sort {
    display: inline-flex;
    align-items: flex-end;
    gap: 4px;
    padding: 0;
    border: 0;
    background: none;
    color: inherit;
    font-weight: inherit;
    text-align: inherit;
    cursor: pointer;
}
.th-sort::after {
    content: "";
    flex: none;
    width: 14px;
    height: 14px;
    margin-bottom: 1px;
    background: var(--ink-disabled);
    -webkit-mask: var(--mask-sort) center / contain no-repeat;
    mask: var(--mask-sort) center / contain no-repeat;
}
.th-sort:hover::after { background: var(--ink-3); }
th[aria-sort="ascending"] .th-sort::after { background: var(--ink); -webkit-mask-image: var(--mask-sort-up); mask-image: var(--mask-sort-up); }
th[aria-sort="descending"] .th-sort::after { background: var(--ink); -webkit-mask-image: var(--mask-sort-down); mask-image: var(--mask-sort-down); }
th:not([aria-sort]) .th-sort { position: relative; }
th:not([aria-sort]) .th-sort::after {
    position: absolute;
    bottom: 1px;
    opacity: 0;
    transition: opacity var(--duration-fast) var(--ease);
}
th.num:not([aria-sort]) .th-sort::after { right: calc(100% + 1px); }
th:not(.num):not([aria-sort]) .th-sort::after { left: calc(100% + 4px); }
th .th-sort:hover::after, th .th-sort:focus-visible::after { opacity: 1; }

/* A missing cell: « NA » 12.5/600 in warning-ink on warning-soft. */
.na {
    display: inline-block;
    padding: 1px 6px;
    border-radius: var(--radius-s);
    background: var(--warning-soft);
    color: var(--warning-ink);
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
    line-height: 1.4;
}

/* Verdict cells: the value, then a 14 px status icon (the word in its tooltip and
   aria-label); .best-metric 600 alongside, never instead. */
.verdict { display: inline-flex; align-items: center; gap: 6px; }
.verdict::after {
    content: "";
    flex: none;
    width: 14px;
    height: 14px;
    background: var(--verdict-base, var(--info));
    -webkit-mask: var(--verdict-icon, var(--mask-check-circle)) center / contain no-repeat;
    mask: var(--verdict-icon, var(--mask-check-circle)) center / contain no-repeat;
}
.verdict--success { --verdict-base: var(--success); --verdict-icon: var(--mask-check-circle); }
.verdict--warning { --verdict-base: var(--warning); --verdict-icon: var(--mask-alert-triangle); }
.verdict--danger { --verdict-base: var(--danger); --verdict-icon: var(--mask-x-circle); }
.verdict-word { color: var(--ink-2); font-size: var(--text-xs); }
.data-table td.num .verdict { justify-content: flex-end; }
/* A verdict column's cell without a verdict reserves the icon and its gap. */
.data-table td.verdict-pad { padding-right: calc(var(--space-3) + 20px); }
.data-table .best-metric { font-weight: var(--weight-semibold); }

/* A row that switches a detail panel holds a button in its first cell (keyboard). */
.row-select {
    margin: -3px -6px;
    padding: 3px 6px;
    border: 0;
    border-radius: var(--radius-s);
    background: none;
    color: inherit;
    font-weight: var(--weight-semibold);
    text-align: left;
    cursor: pointer;
}
.row-select:hover { text-decoration: underline; text-underline-offset: 3px; }
.table-box .row-select:focus-visible { outline-offset: 0; }
.data-table tbody tr.baseline-row .row-select { color: var(--ink-3); font-weight: var(--weight-medium); }

.table-foot {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    padding: 10px 4px 0;
}
.table-note { color: var(--ink-3); font-size: var(--text-xs); line-height: 1.5; }
.table-toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
    min-height: 52px;
    padding-bottom: var(--space-3);
}
.table-toolbar-label { margin-right: 4px; color: var(--ink); font-size: var(--text-s); font-weight: var(--weight-semibold); }

/* ============================================
   14. Callouts (spec 12.14)
   ============================================
   1 px -line border, -soft fill, radius 10, padding 12 / 16, a 20 px status icon, a
   title 600 and the text in ink-2; no thick left bar. Production's .alert is a callout
   whose icon is drawn by CSS (no markup for it): .alert-error, .alert-warning. */
.callout, .alert {
    border: 1px solid var(--callout-line, var(--info-line));
    border-radius: var(--radius-m);
    background: var(--callout-soft, var(--info-soft));
    color: var(--ink-2);
    font-size: var(--text-m);
    line-height: 1.45;
}
.callout {
    display: grid;
    grid-template-columns: 20px minmax(0, 1fr);
    gap: 10px;
    padding: 12px 16px;
}
.callout > .icon { width: 20px; height: 20px; color: var(--callout-base, var(--info)); }
.callout-title { display: block; color: var(--ink); font-weight: var(--weight-semibold); }
.callout--info { --callout-line: var(--info-line); --callout-soft: var(--info-soft); --callout-base: var(--info); }
.callout--success { --callout-line: var(--success-line); --callout-soft: var(--success-soft); --callout-base: var(--success); }
.callout--warning, .alert-warning { --callout-line: var(--warning-line); --callout-soft: var(--warning-soft); --callout-base: var(--warning); --callout-icon: var(--mask-alert-triangle); }
.callout--danger, .alert-error { --callout-line: var(--danger-line); --callout-soft: var(--danger-soft); --callout-base: var(--danger); --callout-icon: var(--mask-x-circle); }
.alert {
    position: relative;
    margin-bottom: var(--space-4);
    padding: 12px 16px 12px 46px;
}
.alert::before {
    content: "";
    position: absolute;
    top: 13px;
    left: 16px;
    width: 20px;
    height: 20px;
    background: var(--callout-base, var(--info));
    -webkit-mask: var(--callout-icon, var(--mask-info)) center / contain no-repeat;
    mask: var(--callout-icon, var(--mask-info)) center / contain no-repeat;
}
/* A check's result in a drawer: a compact callout under a hairline. */
.check-result {
    display: grid;
    gap: 10px;
    padding-top: 12px;
    border-top: 1px solid var(--line);
}
.check-result .callout { padding: 10px 14px; font-size: var(--text-s); }
.check-result .callout-title { display: block; font-size: var(--text-m); }

/* ============================================
   15. Toasts and modals (spec 8.4 and 12.15)
   ============================================
   Toasts: one body-level #toastContainer (aria-live), bottom-right, 16 px from the
   edges, at most 3 (showToast); each an opaque raised sheet, 1 px line, shadow-2,
   radius 10, a status icon, the message in ink and a close button; 4 s (8 s for
   errors), the fade starts 300 ms before the end and the toast then leaves the DOM.
   While a right-hand drawer is open the stack moves left of it. */
.toast-container {
    position: fixed;
    right: var(--space-4);
    bottom: var(--space-4);
    z-index: var(--z-toast);
    display: grid;
    gap: var(--space-2);
    width: min(380px, calc(100vw - 2 * var(--space-4)));
    pointer-events: none;
    transition: right var(--duration-normal) var(--ease);
}
body.has-drawer .toast-container { right: calc(var(--drawer-width) + var(--space-4)); }
body.has-drawer-wide .toast-container { right: calc(var(--drawer-width-wide) + var(--space-4)); }
/* The notes drawer opened from the right edge (top bar, notes.js). */
@media (min-width: 780px) {
    body.has-notes-right .toast-container { right: calc(var(--notes-width) + var(--space-4)); }
}
/* The wide notes drawer (body.has-notes-wide, notes.js; --notes-width-wide is 560 px).
   Beside the rail (1100 px and up), the toasts narrow to the room left on its right, down
   to 240 px. From the right edge, they move beside it while 240 px of toast fit (832 px =
   560 + 240 + 2 x 16); below, they stay at the window's edge, above the drawer. */
@media (min-width: 1100px) {
    body.has-notes-wide .toast-container {
        width: max(240px, min(380px, calc(100vw - var(--rail-width) - var(--notes-width-wide) - 2 * var(--space-4))));
    }
}
@media (min-width: 780px) and (max-width: 831px) {
    body.has-notes-right.has-notes-wide .toast-container { right: var(--space-4); }
}
@media (min-width: 832px) {
    body.has-notes-right.has-notes-wide .toast-container {
        right: calc(var(--notes-width-wide) + var(--space-4));
        width: min(380px, calc(100vw - var(--notes-width-wide) - 2 * var(--space-4)));
    }
}
.toast {
    display: grid;
    grid-template-columns: 20px minmax(0, 1fr) auto;
    align-items: start;
    gap: 10px;
    padding: 12px 8px 12px 14px;
    border: 1px solid var(--line);
    border-radius: var(--radius-m);
    background: var(--surface-raised);
    box-shadow: var(--shadow-2);
    color: var(--ink);
    font-size: var(--text-m);
    line-height: 1.4;
    overflow-wrap: anywhere;
    pointer-events: auto;
    animation: toast-in var(--duration-normal) var(--ease);
}
.toast > .icon { width: 20px; height: 20px; color: var(--toast-base, var(--info)); }
.toast-body { display: grid; gap: 2px; padding-top: 1px; }
.toast-title { font-weight: var(--weight-semibold); }
.toast-text { color: var(--ink-2); font-size: var(--text-s); }
.toast-actions { display: flex; align-items: center; gap: 2px; margin-top: -4px; }
.toast--success { --toast-base: var(--success); }
.toast--warning { --toast-base: var(--warning); }
.toast--error, .toast--danger { --toast-base: var(--danger); }
.toast--info { --toast-base: var(--info); }
/* Never an invisible toast that still catches clicks. */
.toast.is-leaving {
    opacity: 0;
    pointer-events: none;
    transform: translateY(4px);
    transition: opacity 300ms var(--ease), transform 300ms var(--ease);
}
@keyframes toast-in {
    from { opacity: 0; transform: translateY(6px); }
    to { opacity: 1; transform: none; }
}

/* Modals: one look for the four systems, each keeping its mechanism: #confirmModalGlobal
   (.confirm-modal-overlay.active, showConfirm / showTextPrompt), .convert-modal-overlay
   (.active: conversion, paste a list, import predictors), .fullscreen-modal-overlay
   (.active: correlation charts, large) and .outlier-action-modal-overlay (.d-none,
   Exploration; its content is styled in dashboard.css). Scrim, a white dialog, radius
   16, shadow-3, 440 px; title 17/650, text 15 ink-2, actions on the right (secondary
   « Annuler » then primary or danger); above the page, below the toasts. */
.confirm-modal-overlay, .convert-modal-overlay, .fullscreen-modal-overlay,
.outlier-action-modal-overlay, .modal-overlay {
    position: fixed;
    inset: 0;
    z-index: var(--z-modal);
    justify-content: center;
    align-items: center;
    padding: var(--space-4);
    background: var(--scrim);
    animation: modal-fade-in var(--duration-normal) var(--ease);
}
.confirm-modal-overlay, .convert-modal-overlay, .fullscreen-modal-overlay { display: none; }
.confirm-modal-overlay.active, .convert-modal-overlay.active, .fullscreen-modal-overlay.active,
.outlier-action-modal-overlay { display: flex; }
.modal-overlay { display: grid; place-items: center; }
@keyframes modal-fade-in {
    from { opacity: 0; }
    to { opacity: 1; }
}

.confirm-modal, .convert-modal, .outlier-action-modal, .modal {
    width: 100%;
    max-width: 440px;
    max-height: 100%;
    padding: var(--space-6);
    overflow-y: auto;
    border-radius: var(--radius-l);
    background: var(--surface-raised);
    box-shadow: var(--shadow-3);
    color: var(--ink-2);
    font-size: var(--text-m);
    line-height: 1.5;
}
/* Titles 17/650 ink. */
.confirm-modal h3, .convert-modal h3, .modal-title {
    margin: 0;
    color: var(--ink);
    font-size: var(--text-l);
    font-weight: var(--weight-heading);
    line-height: var(--leading-snug);
    letter-spacing: -0.01em;
}
/* Actions on the right: « Annuler » (secondary) then the primary or danger. */
.confirm-modal-actions, .convert-modal-actions, .modal-actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: var(--space-2);
}
/* The icon: a 40 px soft circle in the variant's status (danger, warning, info). */
.confirm-modal-icon, .modal-icon {
    display: grid;
    place-items: center;
    flex: none;
    width: 40px;
    height: 40px;
    border-radius: 50%;
    background: var(--modal-soft, var(--info-soft));
    color: var(--modal-base, var(--info));
}
.confirm-modal-icon svg, .modal-icon .icon { width: 20px; height: 20px; }
.confirm-modal-icon:empty { display: none; }
.confirm-modal-icon--danger, .modal-icon--danger { --modal-soft: var(--danger-soft); --modal-base: var(--danger); }
.confirm-modal-icon--warning, .modal-icon--warning { --modal-soft: var(--warning-soft); --modal-base: var(--warning); }
.confirm-modal-icon--info, .modal-icon--info { --modal-soft: var(--info-soft); --modal-base: var(--info); }

/* The shared confirm modal: icon beside the title, then the text (and showTextPrompt's
   field), then the actions, 16 px apart. */
.confirm-modal {
    display: grid;
    grid-template-columns: 40px minmax(0, 1fr);
    align-items: start;
    gap: var(--space-4) 14px;
}
.confirm-modal > * { grid-column: 1 / -1; }
.confirm-modal > .confirm-modal-icon { grid-row: 1; grid-column: 1; }
.confirm-modal > h3 { grid-row: 1; grid-column: 2; padding-top: 8px; }
.confirm-modal > .confirm-modal-icon:empty + h3 { grid-column: 1 / -1; padding-top: 0; }
.confirm-modal p { margin: 0; color: var(--ink-2); }

/* The convert modal family (block content of each page's own). */
.convert-modal { resize: both; }
.convert-modal h3 { margin-bottom: var(--space-3); }
.convert-modal p { margin-bottom: var(--space-2); color: var(--ink-2); }
.convert-modal .problem-list {
    margin: var(--space-3) 0;
    padding: var(--space-2) var(--space-3);
    border-radius: var(--radius-m);
    background: var(--surface-sunken);
    color: var(--ink-2);
    font-size: var(--text-s);
}
.convert-modal-actions { margin-top: var(--space-4); }

/* The prototype's modal markup (page steps): .modal-head = icon + title. */
.modal { display: grid; gap: var(--space-4); }
.modal-head { display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: start; gap: 14px; }
.modal-title { padding-top: 8px; }
.modal-text { color: var(--ink-2); font-size: var(--text-m); line-height: 1.5; }

/* Fullscreen chart modal: the same sheet, as large as the window allows. */
.fullscreen-modal {
    display: flex;
    flex-direction: column;
    width: 100%;
    height: 100%;
    max-width: 95vw;
    max-height: 95vh;
    border-radius: var(--radius-l);
    background: var(--surface-raised);
    box-shadow: var(--shadow-3);
}
.fullscreen-modal-header {
    display: flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    padding: 14px 16px 14px 24px;
    border-bottom: 1px solid var(--line);
}
.fullscreen-modal-header h3 {
    margin: 0;
    color: var(--ink);
    font-size: var(--text-l);
    font-weight: var(--weight-heading);
    line-height: var(--leading-snug);
    letter-spacing: -0.01em;
}
.fullscreen-modal-body {
    display: flex;
    flex: 1;
    min-height: 0;
    padding: var(--space-4);
    overflow: hidden;
}
.fullscreen-chart { width: 100%; height: 100%; }

/* ============================================
   16. Tooltips (spec 12.16)
   ============================================
   Production's CSS tooltips (.tooltip > .tooltip-content, shown on hover or keyboard
   focus within): the short one is an ink bubble, white 12.5, radius 6; the rich help
   (.tooltip-content--rich, ml.css) is a raised popover. A hidden tooltip is out of the
   layout (display: none), so it never widens the page; initMetricTooltips() and the
   model identity card measure it on mouseenter, when :hover already applies. The
   prototype's JS-placed .tooltip-bubble, .info-btn and .popover follow for the page
   steps, which replace the « ⓘ » glyphs. */
.tooltip { position: relative; cursor: help; }
.tooltip-content {
    position: absolute;
    z-index: var(--z-tooltip);
    bottom: calc(100% + 8px);
    left: 50%;
    width: max-content;
    max-width: 260px;
    padding: 5px 8px;
    border-radius: var(--radius-s);
    background: var(--ink);
    color: var(--ink-on-accent);
    font-size: var(--text-xs);
    font-style: normal;
    font-weight: var(--weight-medium);
    line-height: 1.4;
    letter-spacing: normal;
    text-align: left;
    text-transform: none;
    white-space: normal;
    transform: translateX(-50%);
    pointer-events: none;
}
.tooltip-content::after {
    content: "";
    position: absolute;
    top: 100%;
    left: 50%;
    margin-left: -5px;
    border: 5px solid transparent;
    border-top-color: var(--ink);
}
/* White on ink, whatever a page context sets (ml.css .ml-model-tags), down to the
   bubble's children: a page ink meant for a white panel would vanish on the ink. Help
   with a heading or rows is the rich popover, which keeps its inks. */
.tooltip .tooltip-content:not(.tooltip-content--rich) { color: var(--ink-on-accent); }
.tooltip .tooltip-content:not(.tooltip-content--rich) * { color: inherit; }
.tooltip:not(:hover):not(:focus-within) .tooltip-content { display: none; }

.tooltip-bubble {
    position: fixed;
    z-index: var(--z-tooltip);
    max-width: 260px;
    padding: 5px 8px;
    border-radius: var(--radius-s);
    background: var(--ink);
    color: var(--ink-on-accent);
    font-size: var(--text-xs);
    font-weight: var(--weight-medium);
    line-height: 1.4;
    pointer-events: none;
}
.info-btn {
    display: inline-grid;
    place-items: center;
    width: 20px;
    height: 20px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: transparent;
    color: var(--ink-3);
    vertical-align: -4px;
    cursor: pointer;
}
.info-btn:hover, .info-btn[aria-expanded="true"] { background: var(--surface-hover); color: var(--ink); }
.info-btn .icon { width: 16px; height: 16px; }
.popover {
    position: fixed;
    z-index: var(--z-dropdown);
    display: grid;
    gap: 4px;
    max-width: min(320px, calc(100vw - 16px));
    padding: 12px 14px;
    border: 1px solid var(--line);
    border-radius: var(--radius-m);
    background: var(--surface-raised);
    box-shadow: var(--shadow-2);
    color: var(--ink-2);
    font-size: var(--text-s);
    line-height: 1.45;
}
.popover-title { color: var(--ink); font-size: var(--text-s); font-weight: var(--weight-semibold); }
/* The presentation of a standard session: the popover of a list row's ⓘ (session_list.js)
   and of the rail's (_rail.html), the Accueil sheet's « Présentation » (upload.html), one
   markup (_partials/_standard_presentation.html). A long description scrolls inside the
   window. */
.standard-popover { max-width: min(380px, calc(100vw - 16px)); max-height: calc(100vh - 16px); overflow-y: auto; }
/* A help popover taller than a short window (Conception's objectives help, #mlObjectivesHelp):
   it scrolls inside the window instead of running past its bottom. */
.popover--long { max-height: calc(100vh - 16px); overflow-y: auto; }
.standard-summary { color: var(--ink); font-weight: var(--weight-medium); }
.standard-text + .standard-text { margin-top: 2px; }
.standard-verification { display: flex; align-items: flex-start; gap: 6px; margin-top: 4px; color: var(--ink-2); font-size: var(--text-s); }
.standard-verification .icon { flex: none; width: 15px; height: 15px; margin-top: 2px; color: var(--success); }
.standard-verification--failed { color: var(--danger-ink); }
.standard-verification--failed .icon { color: var(--danger); }
.standard-failed { margin: 0; padding-left: 21px; color: var(--danger-ink); font-size: var(--text-s); }

/* ============================================
   17. Menu (spec 12.17)
   ============================================
   The menu, its items, hints and separators are base.css's (the rail's dataset menu).
   Added here: a group label, danger items in danger-ink, inert aria-disabled items. */
.menu-label {
    padding: 6px 10px 4px;
    color: var(--ink-3);
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
}
.menu-item--danger, .menu-item--danger .icon { color: var(--danger-ink); }
.menu-item--danger:hover { background: var(--danger-soft); }
.menu-item[aria-disabled="true"], .menu-item[aria-disabled="true"] .icon { color: var(--ink-disabled); }
.menu-item[aria-disabled="true"] { cursor: not-allowed; }
.menu-item[aria-disabled="true"]:hover { background: transparent; }

/* ============================================
   18. Drawers (spec 12.18)
   ============================================
   400 px (560 px in check mode, SotisShell.setDrawerWide), body-level, non-modal, raised,
   shadow-3: a header (title, subtitle, close), a scrolling body, a footer with the
   actions. Focus moves in on open; Esc or close returns it to the trigger; the toasts
   move left of it (body.has-drawer, .has-drawer-wide). */
.drawer {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    z-index: var(--z-overlay);
    display: flex;
    flex-direction: column;
    width: min(var(--drawer-width), 100vw);
    border-left: 1px solid var(--line);
    background: var(--surface-raised);
    box-shadow: var(--shadow-3);
    transition: width var(--duration-normal) var(--ease);
}
.drawer--wide { width: min(var(--drawer-width-wide), 100vw); }
.drawer-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--space-3);
    padding: 18px 16px 14px 20px;
    border-bottom: 1px solid var(--line);
}
.drawer-title { color: var(--ink); font-size: var(--text-l); }
/* A title focused on open (tabindex -1, data-autofocus) is not a control: no ring. */
.drawer-title[tabindex="-1"]:focus { outline: none; }
.drawer-sub { margin-top: 2px; color: var(--ink-3); font-size: var(--text-s); }
.drawer-body {
    display: grid;
    flex: 1;
    align-content: start;
    gap: var(--space-4);
    min-height: 0;
    padding: 18px 20px;
    overflow-y: auto;
}
.drawer-body :focus-visible { outline-offset: -2px; }
.drawer-foot {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: var(--space-2);
    padding: 12px 20px;
    border-top: 1px solid var(--line);
    background: var(--surface-raised);
}

/* ============================================
   19. Disclosure (spec 12.19)
   ============================================
   One system: <details class="disclosure"> with an SVG chevron that rotates. Inside it,
   the ▼ / ▶ text that production JS writes in .toggle-icon is hidden; production
   collapsibles outside a .disclosure keep theirs until their page adopts this.
   .disclosure--quiet: « Voir les données », « Voir la configuration ». */
.disclosure { border-top: 1px solid var(--line); }
.disclosure:last-child { border-bottom: 1px solid var(--line); }
.disclosure + .disclosure { border-top: 0; }
.disclosure > summary {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-height: 44px;
    padding: 8px 4px;
    color: var(--ink);
    font-size: var(--text-m);
    font-weight: var(--weight-semibold);
    list-style: none;
    cursor: pointer;
}
.disclosure > summary::-webkit-details-marker { display: none; }
.disclosure > summary .icon { color: var(--ink-3); transition: transform var(--duration-fast) var(--ease); }
.disclosure > summary:hover .icon { color: var(--ink); }
.disclosure[open] > summary .icon.disclosure-chevron { transform: rotate(90deg); }
.disclosure-meta { margin-left: auto; color: var(--ink-3); font-size: var(--text-s); font-weight: var(--weight-regular); }
.disclosure-body { padding: 4px 4px 16px 30px; color: var(--ink-2); }
.disclosure--quiet { border: 0; }
.disclosure--quiet:last-child { border-bottom: 0; }
.disclosure--quiet > summary { min-height: 32px; padding: 4px 0; color: var(--accent-ink); font-size: var(--text-s); }
.disclosure--quiet > summary .icon { color: var(--accent-ink); }
.disclosure--quiet .disclosure-body { padding: 4px 0 8px 26px; }
.disclosure .toggle-icon { font-size: 0; }

/* ============================================
   20. Empty state (spec 12.20)
   ============================================
   A 40 px outline icon in ink-3, a title 15/600, one sentence in ink-3, one secondary
   action. The Corrélations empty state (dashboard.css) is this component with its own
   animated layout, which its page keeps. .text-muted: the sentence a chart area shows
   when it could not load (table.js). */
.empty-state {
    display: grid;
    justify-items: center;
    align-content: center;
    gap: var(--space-2);
    padding: var(--space-7) var(--space-4);
    text-align: center;
}
.empty-state > .icon { width: 40px; height: 40px; stroke-width: 1.5; color: var(--ink-3); }
.empty-title { color: var(--ink); font-size: var(--text-m); font-weight: var(--weight-semibold); }
.empty-text { max-width: 44ch; color: var(--ink-3); font-size: var(--text-s); }
.empty-state .btn { margin-top: 6px; }
.text-muted { color: var(--ink-3); }

/* ============================================
   21. Progress, skeleton and spinners (spec 12.21)
   ============================================
   A 6 px --line track with the accent fill (--value in percent), its label and meta;
   the indeterminate stripe stops under reduced motion. The skeleton block itself
   (.skeleton, sunken with a light sweep) is base.css's; its title and block sizes are
   here. .loading-spinner: a chart area loading (transforms.js). */
.progress { display: grid; gap: var(--space-2); }
.progress-head {
    display: flex;
    justify-content: space-between;
    gap: var(--space-3);
    color: var(--ink);
    font-size: var(--text-s);
    font-weight: var(--weight-semibold);
}
.progress-meta { color: var(--ink-3); font-weight: var(--weight-regular); font-variant-numeric: tabular-nums; }
.progress-track {
    position: relative;
    height: 6px;
    overflow: hidden;
    border-radius: var(--radius-pill);
    background: var(--line);
}
.progress-fill {
    position: absolute;
    inset: 0 auto 0 0;
    width: calc(var(--value) * 1%);
    border-radius: inherit;
    background: var(--accent);
}
.progress--indeterminate .progress-fill { width: 35%; animation: progress-indeterminate 1.4s var(--ease) infinite; }
@keyframes progress-indeterminate {
    from { left: -35%; }
    to { left: 100%; }
}
@media (prefers-reduced-motion: reduce) {
    .progress--indeterminate .progress-fill { width: 100%; opacity: 0.5; animation: none; }
}
.skeleton--title { height: 18px; width: 40%; }
.skeleton--block { height: 120px; border-radius: var(--radius-m); }
.loading-spinner {
    display: block;
    width: 24px;
    height: 24px;
    border: 2px solid var(--line);
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
}

/* ============================================
   22. Drop zone (spec 12.22)
   ============================================
   2 px dashed line-control on sunken, radius 10; a file dragged over it: solid accent
   border on accent-soft (.dragover, the state class production's drop zones use). The
   prototype's alias .drag-over is left out: in production it marks the session group a
   dragged group will drop on (utils.js initGroupDragDrop), not a file drop. */
.dropzone {
    display: grid;
    justify-items: center;
    gap: var(--space-2);
    padding: var(--space-6) var(--space-5);
    border: 2px dashed var(--line-control);
    border-radius: var(--radius-m);
    background: var(--surface-sunken);
    text-align: center;
}
.dropzone > .icon { width: 28px; height: 28px; color: var(--ink-3); }
.dropzone-title { color: var(--ink); font-weight: var(--weight-semibold); }
.dropzone-hint { color: var(--ink-3); font-size: var(--text-s); }
.dropzone.dragover {
    border-style: solid;
    border-color: var(--accent);
    background: var(--accent-soft);
}
.dropzone.dragover > .icon { color: var(--accent-ink); }

/* ============================================
   Empreinte (spec 8.5; static/js/empreinte.js; prototype app.css .empreinte, .emp-*)
   ============================================
   The dataset as a graphite barcode: one 2 px stroke per bin of adjacent columns, 1 px
   gaps, integer x, crisp at 1x, never scaled (the compact rail is redrawn 18 px high, not
   shrunk). Strokes of numeric columns only in --ink-3 (6.29:1), strokes holding a
   non-numeric column in --chart-context (3.08:1: on a white surface only, the card or a
   sheet); the hovered stroke in ink. --accent marks the columns a page displays (2 px
   ticks under the rail form, Exploration). The host (.empreinte) takes the SVG's size;
   its margins belong to its context (the dataset card's are in base.css). The hover
   tooltip is one body-level popover (.emp-popover), out of the pointer's way. */
.empreinte { position: relative; }
.empreinte svg { display: block; overflow: visible; }
.emp-bar { fill: var(--ink-3); shape-rendering: crispEdges; }
.emp-bar.is-mixed { fill: var(--chart-context); }
.emp-bar.is-hot { fill: var(--ink); }
.emp-bar.is-mixed.is-hot { fill: var(--ink-2); }
.emp-tick { fill: var(--accent); shape-rendering: crispEdges; }
/* The invisible layer that resolves the nearest stroke: a pointer only where a page acts
   on a click (SotisEmpreinte.onSelect). */
.emp-hit { fill: transparent; cursor: default; }
.empreinte.is-clickable .emp-hit { cursor: pointer; }
.emp-popover { pointer-events: none; max-width: 300px; }
.emp-list {
    display: grid;
    gap: 1px;
    margin: 2px 0 4px;
    padding: 0;
    list-style: none;
    color: var(--ink);
    font-size: var(--text-xs);
}
.emp-legend { color: var(--ink-3); font-size: var(--text-xs); line-height: 1.4; }

/* ============================================
   Préparation des données (static/js/data_recipe.js, SotisDataRecipe; lot L3)
   ============================================
   One component for every screen that says what was done to the data: the tags of
   #pipelineBar (one per step, its effect even when zero; a button that opens the drawer),
   the body of #dataRecipeDrawer (the head entries, the steps numbered by block, the frozen
   bounds in a popover, the actions), and the compact summary of Conception §1, the Accueil
   and Comparer. Facts in ink, never a verdict: a disabled step is dashed and ink-3, a step
   that ran with an error carries the warning triangle; the step the user came from is
   marked in graphite (.is-current). */
/* A tag never widens its line: on a phone its text ends in an ellipsis (the tooltip and the
   drawer hold the whole sentence). */
.recipe-tag {
    max-width: 100%;
    border: 0;
    font-family: inherit;
    cursor: pointer;
}
.recipe-tag:hover { background: var(--surface-hover); color: var(--ink); }
.recipe-tag:hover .icon { color: var(--ink-2); }
.recipe-tag-number, .recipe-step-number {
    display: inline-grid;
    place-items: center;
    flex: none;
    border-radius: var(--radius-pill);
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--line-strong);
    color: var(--ink-2);
    font-weight: var(--weight-semibold);
    font-variant-numeric: tabular-nums;
    line-height: 1;
}
.recipe-tag-number { min-width: 16px; height: 16px; padding: 0 4px; margin-left: -4px; font-size: 11px; }
/* The text's own cap keeps the tag inside a phone: a flex line counts the text's full width as
   its minimum whatever the tag's max-width, so the text is capped against the window. */
.recipe-tag-text { min-width: 0; max-width: min(42ch, calc(100vw - 128px)); overflow: hidden; text-overflow: ellipsis; }
.recipe-tag.is-disabled, .recipe-step.is-disabled .recipe-step-number,
.recipe-summary-step.is-disabled .recipe-step-number {
    background: transparent;
    box-shadow: none;
    outline: 1px dashed var(--line-strong);
    outline-offset: -1px;
    color: var(--ink-3);
}
.recipe-tag.is-failed .icon, .recipe-step.is-failed .recipe-step-title .icon { color: var(--warning); }

/* The drawer's body. */
.recipe { display: grid; gap: var(--space-4); color: var(--ink-2); font-size: var(--text-s); line-height: 1.45; }
.recipe-intro { color: var(--ink-2); font-size: var(--text-s); }
.recipe-head, .recipe-steps, .recipe-step-details, .recipe-summary-steps { margin: 0; padding: 0; list-style: none; }
.recipe-head { display: grid; gap: var(--space-2); padding: var(--space-3); border-radius: var(--radius-m); background: var(--surface-sunken); }
.recipe-head-entry { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: var(--space-2); align-items: start; }
.recipe-head-entry > .icon { width: 16px; height: 16px; margin-top: 1px; color: var(--ink-3); }
.recipe-head-label, .recipe-step-title { color: var(--ink); font-weight: var(--weight-semibold); }
.recipe-head-text { overflow-wrap: anywhere; }
.recipe-block { display: grid; gap: var(--space-2); }
.recipe-block-label { color: var(--ink-3); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
.recipe-steps { display: grid; gap: var(--space-2); }
.recipe-step {
    display: grid;
    grid-template-columns: 22px minmax(0, 1fr);
    gap: var(--space-3);
    padding: var(--space-3);
    border: 1px solid var(--line);
    border-radius: var(--radius-m);
    background: var(--surface);
}
.recipe-step.is-current { background: var(--surface-selected); border-color: var(--line-strong); }
.recipe-step.is-disabled .recipe-step-sentence { color: var(--ink-3); }
.recipe-step.is-disabled .recipe-step-title { color: var(--ink-2); }
.recipe-step-number { width: 22px; height: 22px; font-size: var(--text-xs); }
.recipe-step-body { display: grid; gap: 6px; min-width: 0; }
.recipe-step-sentence { overflow-wrap: anywhere; }
.recipe-step-title .icon { display: inline-block; width: 14px; height: 14px; margin-right: 6px; vertical-align: -2px; color: var(--ink-3); }
.recipe-step-errors { margin: 0; padding: 0; list-style: none; display: grid; gap: 2px; color: var(--warning-ink); font-size: var(--text-xs); overflow-wrap: anywhere; }
.recipe-step-details { display: grid; gap: 2px; color: var(--ink-3); font-size: var(--text-xs); overflow-wrap: anywhere; }
/* A SQL step's query (custom_sql), as written: monospace, wrapped, never cut. */
.recipe-step-sql {
    margin: 0;
    padding: 8px 10px;
    border: 1px solid var(--line);
    border-radius: var(--radius-s);
    background: var(--surface-sunken);
    color: var(--ink);
    font-family: var(--font-mono);
    font-size: var(--text-xs);
    line-height: 1.5;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

/* SQL code (sql_highlight.js): the pieces SotisSqlHighlight colours, in the SQL drawer's
   editor and in a SQL step's query; a quoted name and the rest keep the block's ink. */
.sql-token--keyword { color: var(--code-keyword); font-weight: 500; }
.sql-token--function { color: var(--code-function); }
.sql-token--string { color: var(--code-string); }
.sql-token--number { color: var(--code-number); }
.sql-token--comment { color: var(--code-comment); font-style: italic; }

.recipe-step-frozen { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; color: var(--ink-2); }
/* The bounds' popover may be wider than the drawer it opens from: it moves left over the page. */
.recipe-frozen-popover { --table-max-height: 260px; max-width: min(400px, calc(100vw - 16px)); }
.recipe-frozen-popover .table-box { margin-top: 4px; }
/* Lot L4: under the bounds, what a new row meets at prediction (the backend's sentence). */
.recipe-frozen-note { margin-top: var(--space-2); color: var(--ink-2); font-size: var(--text-s); }
.recipe-frozen-popover .data-table th, .recipe-frozen-popover .data-table td { height: 30px; padding: 4px 10px; }
.recipe-frozen-popover .data-table thead th { height: 32px; white-space: nowrap; }
.recipe-frozen-group th {
    --row-bg: var(--surface-sunken);
    color: var(--ink-2);
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
}
.recipe-step-locked { display: flex; align-items: flex-start; gap: 6px; color: var(--ink-3); font-size: var(--text-xs); }
.recipe-step-locked .icon { flex: none; width: 14px; height: 14px; margin-top: 1px; }
.recipe-step-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: 2px; }
.recipe-empty { color: var(--ink-3); }
.recipe-output { color: var(--ink); font-weight: var(--weight-semibold); }
.recipe-lens { font-size: var(--text-s); }

/* The compact summary (Conception §1, the Accueil, Comparer) and the one tag. */
.recipe-summary { display: grid; gap: var(--space-2); color: var(--ink-2); font-size: var(--text-s); line-height: 1.45; }
.recipe-summary-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2) var(--space-3); }
.recipe-summary-edit { margin-left: auto; }
.recipe-summary-line { overflow-wrap: anywhere; }
.recipe-summary-steps { display: grid; gap: 4px; }
.recipe-summary-step { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: var(--space-2); align-items: start; overflow-wrap: anywhere; }
.recipe-summary-step .recipe-step-number { width: 20px; height: 20px; font-size: 11px; }
.recipe-summary-step.is-disabled { color: var(--ink-3); }
.recipe-state-tag.has-disabled { box-shadow: inset 0 0 0 1px var(--line-strong); }

/* A model's own preparation (lot L4): the preparation a prediction replayed and, when the
   session's has changed since the training, that fact (never a verdict); the « Hors domaine »
   cell of a row, its reasons in its tooltip; the count of the drawer's footer. */
.recipe-replay { display: grid; gap: 4px; color: var(--ink-2); font-size: var(--text-s); line-height: 1.45; }
.recipe-replay-name { color: var(--ink); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
.recipe-replay-text, .recipe-replay-changed {
    display: grid;
    grid-template-columns: 16px minmax(0, 1fr);
    gap: var(--space-2);
    align-items: start;
    overflow-wrap: anywhere;
}
.recipe-replay-text .icon, .recipe-replay-changed .icon { width: 16px; height: 16px; margin-top: 1px; color: var(--ink-3); }
.recipe-replay-text { color: var(--ink); }
.recipe-replay.is-legacy .recipe-replay-text { color: var(--ink-2); }
.recipe-row-status { cursor: help; }
.recipe-row-status.is-out-of-domain .icon, .recipe-row-status.is-target-rule .icon { color: var(--warning); }
.recipe-row-status.is-note { background: transparent; }
/* « Lignes hors domaine »: as wide as its three columns, and up to 1000 rows scroll under the
   sticky header rather than lengthen the page. */
.recipe-flagged-box { --table-max-height: 480px; width: fit-content; }
.recipe-flagged-table td { vertical-align: middle; }
.recipe-models-note { flex: 1 1 260px; align-self: center; margin: 0; color: var(--ink-2); font-size: var(--text-s); line-height: 1.45; }

/* ============================================
   Charts (spec 11; prototype app.css section 7)
   ============================================
   A chart card (templates/_partials/_chart_card.html): figure.chart-card holding
   figcaption.chart-head (h3.chart-title, p.chart-sub), the chart box (.chart-box with
   data-legend, the div SotisChart.render draws into), optionally inside a .chart-scroll
   wrapper, then the « Voir les données » twin (details.chart-data, filled by
   SotisChart.tableTwin). render() lifts the figure's title into the h3 and its <br> line
   into .chart-sub, and sets --chart-height on the box when the figure has a height of its
   own. None of these names existed in production before step 6 (production's chart boxes
   are .ml-chart-box and .chart-container, left as they are). */
.chart-card { display: grid; gap: 8px; align-content: start; min-width: 0; margin: 0; }
/* The card is a grid: its items must be allowed to shrink, or an open « Voir les données »
   twin (data-table min-width: max-content) widens the card and the page on a phone
   (measured in the prototype: 512 px of content at 390). */
.chart-card > * { min-width: 0; }
.chart-head { display: grid; gap: 2px; }
/* A figure without a title, in a card whose template wrote none: no empty row above it. */
.chart-head:not(:has(> :not(:empty))) { display: none; }
/* A head that carries a control on its right (a switch « Tous les prédicteurs », a
   segmented view): the titles take the room left, the control wraps under them. */
.chart-head-row {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-start;
    justify-content: space-between;
    gap: 8px 16px;
}
.chart-head-row > .chart-head { flex: 1 1 260px; min-width: 0; }
.chart-title {
    color: var(--ink);
    font-size: var(--text-m);
    font-weight: var(--weight-semibold);
    line-height: var(--leading-snug);
    letter-spacing: 0;
}
.chart-title:empty { display: none; }
.chart-sub { color: var(--ink-3); font-size: var(--text-s); line-height: 1.45; }
.chart-sub:empty { display: none; }
.chart-box { position: relative; width: 100%; min-width: 0; height: var(--chart-height); }
.chart-box--s { --chart-height: 220px; }
/* Two charts side by side, one above the other under 900 px. Their legends go inside, in
   an empty corner (data-legend="overlay*"), so both plot areas keep the same margins. */
.chart-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-6); }
@media (max-width: 900px) {
    .chart-pair { grid-template-columns: minmax(0, 1fr); }
}
/* A chart wider than its card (a correlation heatmap whose columns must hold their rotated
   names) scrolls sideways in its wrapper, never the page: the page sets --chart-min-width on
   the box, and render() fades the wrapper's right edge while more of the chart lies there
   (.has-more). */
.chart-scroll { min-width: 0; overflow-x: auto; overflow-y: hidden; }
.chart-scroll > .chart-box { min-width: var(--chart-min-width); }
.chart-scroll.has-more {
    -webkit-mask-image: linear-gradient(to right, var(--mask-opaque) calc(100% - 48px), transparent);
    mask-image: linear-gradient(to right, var(--mask-opaque) calc(100% - 48px), transparent);
}
/* « Voir les données »: the table takes the width of its columns (at least 360 px), left
   under its chart, in a bounded scroll box; three columns stretched over the card's width
   lose the eye between them. */
.chart-data { --table-max-height: 300px; margin-top: 4px; }
.chart-data .table-box { width: fit-content; max-width: 100%; margin-top: 4px; }
.chart-data .data-table { width: auto; min-width: 360px; }
.chart-data .table-note { margin-top: 6px; }
/* A series named in text (a caption, a twin's header): its swatch or its line. */
.legend-swatch {
    display: inline-block;
    width: 12px;
    height: 12px;
    border-radius: 3px;
    background: var(--swatch);
    vertical-align: -1px;
}
.legend-line { display: inline-block; width: 18px; height: 0; border-top: 2px solid var(--swatch); vertical-align: 3px; }
.legend-line--dashed { border-top-style: dashed; }
/* The modebar clear of the card's rounded edge. Scoped to the chart boxes of the cards, so
   the charts not moved to a card yet keep Plotly's own place. */
.chart-box .modebar { right: 4px !important; }

/* ============================================
   Utilities (prototype app.css section 8)
   ============================================
   Only the ones production lacked: production's own (.mb-0, .mb-md, .mt-md, .mt-lg,
   .d-none in base.css; .text-muted above) keep their meaning. Icon sizes complete base.css's
   .icon (18 px). */
.mt-2 { margin-top: var(--space-2); }
.mt-3 { margin-top: var(--space-3); }
.mt-4 { margin-top: var(--space-4); }
.mt-6 { margin-top: var(--space-6); }
.ink-2 { color: var(--ink-2); }
.ink-3 { color: var(--ink-3); }
.nowrap { white-space: nowrap; }
.ellipsis { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.w-full { width: 100%; }
.hairline { height: 1px; margin: var(--space-4) 0; border: 0; background: var(--line); }
.icon--xs { width: 14px; height: 14px; }
.icon--s { width: 16px; height: 16px; }
.icon--l { width: 20px; height: 20px; }
.icon--xl { width: 40px; height: 40px; stroke-width: 1.5; }
