/* Mindchord Console — "Precise" design system
   ──────────────────────────────────────────────────────────────────────
   Swiss-neutral structure (one hairline grid, a named type scale, a small
   tokenized palette) + a warm accent + playfulness that lives in motion and
   feedback, not in decoration.

   Everything is driven by the tokens in :root. To re-theme the accent, swap
   the four --accent-* values — nothing else needs to change. The type scale
   and component utilities are configured against these tokens via the
   Tailwind theme in base.njk (text-display / text-title / bg-surface / ...).
   ──────────────────────────────────────────────────────────────────────── */

/* ── Logo font ──────────────────────────────────────────────────────────
   The console body is full Inter (Swiss-neutral). The serif survives only
   for the small "Mindchord" logo lockup, so it stays recognisably brand. */
@font-face {
    font-family: 'TeX Gyre Pagella';
    src: url('/media/tex-gyre-pagella/texgyrepagella-regular.otf') format('opentype');
    font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
    font-family: 'TeX Gyre Pagella';
    src: url('/media/tex-gyre-pagella/texgyrepagella-bold.otf') format('opentype');
    font-weight: 700; font-style: normal; font-display: swap;
}

/* ── Tokens ─────────────────────────────────────────────────────────────── */
:root {
    /* Canvas & surfaces — warm neutral (Swiss structure, not cold-clinical) */
    --bg: #F5F4F1;            /* page canvas */
    --surface: #FFFFFF;       /* raised panels, cards */
    --surface-2: #EFEDE8;     /* subtle fills, hovers */
    --border: #E7E3DD;        /* hairline */
    --border-strong: #CFC9C0; /* control borders */

    /* Cool neutrals — used for editor controls (band inputs, direction
       toggle, reference chips) where the warm beige read muddy. */
    --cool-fill: #F4F5F6;     /* very light grey band/control fill */
    --cool-fill-2: #ECEEF0;   /* slightly stronger cool fill (control rest state) */
    --cool-fill-3: #E3E7EA;   /* input hover — one step past --cool-fill-2 */

    /* Ink */
    --ink: #211E1A;           /* primary text */
    --text-muted: #756F66;    /* secondary text, labels */

    /* Accent — soft terracotta coral, clearly warm rather than alarm-red and
       well separated from --danger. Swap this block to re-theme the console.
       (Alternate audition value: iris  --accent #6E56CF / hover #5C45BE /
        weak #ECE9FB / ink #4B3AA0.) */
    --accent: #C4704B;
    --accent-hover: #AE6040;
    --accent-press: #96522F;  /* one notch past hover — the pressed fill */
    --accent-weak: #F5E8E0;
    --accent-ink: #8F4E2E;    /* accent text on the weak tint */

    /* Status */
    --ok: #2FA06A;     --ok-weak: #E6F4EC;
    --warn: #C0791A;   --warn-weak: #FBF0DC;
    --info: #4A63D8;   --info-weak: #E9ECFB;
    --danger: #D14343; --danger-weak: #FBE9E9;
    --danger-hover: #BE3B3B; --danger-press: #A83434;

    /* Press step for light-fill controls (secondary/ghost) — one step past
       --surface-2, so hover and press are visibly distinct values. */
    --surface-press: #E3DED6;
    --border-press: #BDB5A9;

    /* Sage — brand secondary. Carries the second series in a plot, where a
       neutral grey read as chrome rather than as data. */
    --sage: #869C8A;

    /* Radius */
    --r-sm: 6px;
    --r: 8px;
    --r-lg: 12px;

    /* Shadow — Swiss-minimal; structure comes from borders, not drop shadows */
    --shadow-sm: 0 1px 2px rgba(20, 23, 30, 0.05);
    --shadow: 0 1px 2px rgba(20, 23, 30, 0.04), 0 10px 28px rgba(20, 23, 30, 0.07);

    /* Motion — "subtle & quick": fast micro-feedback, no showy overshoot */
    --ease: cubic-bezier(0.22, 0.61, 0.36, 1);  /* snappy ease-out */
    --spring: cubic-bezier(0.2, 0.7, 0.3, 1);   /* quick settle, no bounce */
    --t-press: 80ms;
    --t-fast: 130ms;
    --t-base: 190ms;
}

/* ── Document defaults ──────────────────────────────────────────────────── */
html {
    background: var(--bg);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-rendering: optimizeLegibility;
}
body { font-family: 'Inter', system-ui, sans-serif; color: var(--ink); background: var(--bg); }
.page-bg { background: var(--bg); }
.wordmark { font-family: 'TeX Gyre Pagella', Georgia, serif; letter-spacing: -0.015em; }
::selection { background: color-mix(in srgb, var(--accent) 22%, transparent); }
.tnum { font-variant-numeric: tabular-nums; }

/* ── Buttons ────────────────────────────────────────────────────────────
   The button never moves. Each variant instead walks a three-step colour
   ramp — rest → hover → press — so the state is read as a discrete value
   change rather than a wobble. The press step is deliberately a full step
   past hover, not a tint of it, so mid-drag the two never read the same.

   Timing carries the "cut" feel: the pressed fill snaps on with no
   transition (the :active duration override) and eases back out over
   --t-fast on release. Instant down, soft up. */
.btn {
    display: inline-flex; align-items: center; justify-content: center; gap: 8px;
    height: 40px; padding: 0 16px;
    font-size: 14px; font-weight: 500; line-height: 1;
    border: 1px solid transparent; border-radius: var(--r);
    cursor: pointer; user-select: none; white-space: nowrap;
    transition: background-color var(--t-fast) var(--ease),
                border-color var(--t-fast) var(--ease),
                box-shadow var(--t-fast) var(--ease),
                color var(--t-fast) var(--ease);
}
.btn:active { transition-duration: 0s; }
.btn:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent); }
.btn:disabled, .btn[disabled], .btn.is-disabled { opacity: 0.5; pointer-events: none; }

.btn-sm { height: 32px; padding: 0 12px; font-size: 13px; border-radius: var(--r-sm); }
.btn-icon { width: 40px; padding: 0; }
.btn-icon.btn-sm { width: 32px; }

.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:active { background: var(--accent-press); }

.btn-secondary { background: var(--surface); color: var(--ink); border-color: var(--border-strong); }
.btn-secondary:hover { background: var(--surface-2); }
.btn-secondary:active { background: var(--surface-press); border-color: var(--border-press); }

.btn-ghost { background: transparent; color: var(--ink); }
.btn-ghost:hover { background: var(--surface-2); }
.btn-ghost:active { background: var(--surface-press); }

.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { background: var(--danger-hover); }
.btn-danger:active { background: var(--danger-press); }

.btn-link {
    display: inline-flex; align-items: center; gap: 4px;
    height: auto; padding: 0; border-radius: 4px;
    background: transparent; color: var(--accent); font-weight: 500;
}
.btn-link:hover { color: var(--accent-hover); }
.btn-link:active { color: var(--accent-press); }
.btn-link .arrow { transition: transform var(--t-base) var(--spring); }
.btn-link:hover .arrow { transform: translateX(3px); }

/* ── Inputs & controls ──────────────────────────────────────────────────── */
.field > .label { display: block; font-size: 13px; font-weight: 500; color: var(--ink); margin-bottom: 8px; }
.field > .hint { margin-top: 6px; font-size: 13px; color: var(--text-muted); }

/* The field is a recess, not an outline — a tint against the surface it sits
   on. Forms live on white cards, where a white field could only be read by its
   hairline; a fill gives it a body.

   The border stays transparent at every state and exists purely to hold the
   box size steady. Focus is an opaque 1.5px ring flush against that edge, so
   it reads as a drawn border rather than a halo — the same discrete-state
   logic as the button colour ramp, rather than a soft glow. It matches the
   1.5px stroke the plot lines use, which is the console's "one hairline up"
   weight.

   Always background-COLOR, never the `background` shorthand: `select.input`
   carries its chevron as a background-image, and these rules outrank it
   (0,2,0 against 0,1,1), so a shorthand here erases the arrow. */
.input {
    width: 100%; height: 44px; padding: 0 14px;
    font-size: 15px; color: var(--ink);
    background-color: var(--cool-fill-2);
    border: 1px solid transparent; border-radius: var(--r);
    transition: box-shadow var(--t-fast) var(--ease),
                background-color var(--t-fast) var(--ease);
}
.input::placeholder { color: var(--text-muted); }
.input:hover { background-color: var(--cool-fill-3); }
/* Declared after :hover deliberately — equal specificity, so source order
   settles it and a focused field does not flicker when the cursor crosses it. */
.input:focus, .input:focus-visible {
    outline: none;
    background-color: var(--cool-fill-2);
    box-shadow: 0 0 0 1.5px var(--accent);
}
textarea.input { height: auto; min-height: 96px; padding: 12px 14px; line-height: 1.55; resize: vertical; }
textarea.input[data-autogrow] { min-height: 0; resize: none; overflow: hidden; }

/* Compact text inputs — tighter height/padding for short free-text fields
   (e.g. protocol name/description). `.input-grotesk` swaps to a neutral grotesque
   so user-entered prose reads distinct from the UI's Inter chrome. */
.input-sm { height: 38px; padding: 0 12px; font-size: 14px; }
textarea.input-sm { height: auto; padding: 9px 12px; }
.input-grotesk { font-family: Helvetica, Arial, system-ui, sans-serif; letter-spacing: -0.01em; }
select.input {
    appearance: none; padding-right: 40px; cursor: pointer;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2369707C' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
    background-repeat: no-repeat; background-position: right 12px center;
}

/* Search input with a leading icon — wrap an .input in .input-search */
.input-search { position: relative; }
.input-search .input { padding-left: 40px; }
.input-search svg { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-muted); pointer-events: none; }

/* Toggle switch — thumb springs across, track fades to accent */
.switch { position: relative; display: inline-flex; align-items: center; gap: 10px; cursor: pointer; font-size: 14px; }
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.switch .track {
    position: relative; flex-shrink: 0;
    width: 44px; height: 26px; border-radius: 999px;
    background: var(--border-strong);
    transition: background var(--t-base) var(--ease);
}
.switch .thumb {
    position: absolute; top: 3px; left: 3px;
    width: 20px; height: 20px; border-radius: 999px; background: #fff;
    box-shadow: var(--shadow-sm);
    transition: transform var(--t-base) var(--spring);
}
.switch input:checked + .track { background: var(--accent); }
.switch input:checked + .track .thumb { transform: translateX(18px); }
.switch input:focus-visible + .track { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent); }

/* Checkbox — box fills + tick pops on check */
.check { position: relative; display: inline-flex; align-items: center; gap: 10px; cursor: pointer; font-size: 14px; }
.check input { position: absolute; opacity: 0; width: 0; height: 0; }
.check .box {
    width: 20px; height: 20px; border-radius: 6px;
    border: 1.5px solid var(--border-strong); background: var(--surface);
    display: inline-grid; place-items: center;
    transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease), transform var(--t-press) var(--ease);
}
.check .box svg { width: 12px; height: 12px; color: #fff; opacity: 0; transform: scale(0.5); transition: opacity var(--t-fast) var(--ease), transform var(--t-fast) var(--spring); }
.check input:checked + .box { background: var(--accent); border-color: var(--accent); }
.check input:checked + .box svg { opacity: 1; transform: scale(1); }
.check input:active + .box { transform: scale(0.9); }
.check input:focus-visible + .box { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent); }

/* ── Chips & tags ───────────────────────────────────────────────────────── */
.chip {
    display: inline-flex; align-items: center; gap: 6px;
    height: 24px; padding: 0 10px;
    font-size: 12.5px; font-weight: 500; border-radius: 999px;
    background: var(--surface-2); color: var(--text-muted);
}
/* .chip sets display, which outranks nothing and ties with Tailwind's .hidden —
   so toggling a chip with that class is order-dependent. Use the `hidden`
   attribute instead; this rule is what makes it stick. */
.chip[hidden] { display: none; }
.chip .dot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; }
.chip-ok { background: var(--ok-weak); color: var(--ok); }
.chip-warn { background: var(--warn-weak); color: var(--warn); }
.chip-info { background: var(--info-weak); color: var(--info); }
.chip-accent { background: var(--accent-weak); color: var(--accent-ink); }
.chip-neutral { background: var(--surface-2); color: var(--text-muted); }
.chip-danger { background: var(--danger-weak); color: var(--danger); }

.tag {
    display: inline-flex; align-items: center; height: 22px; padding: 0 8px;
    font-size: 12px; color: var(--text-muted);
    border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface);
}

/* ── Surfaces ───────────────────────────────────────────────────────────── */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); box-shadow: var(--shadow-sm); }
.card-hover { transition: transform var(--t-base) var(--ease), box-shadow var(--t-base) var(--ease), border-color var(--t-base) var(--ease); }
.card-hover:hover { transform: translateY(-2px); box-shadow: var(--shadow); border-color: var(--border-strong); }

/* The one label style — for metric labels and any section header that should
   sit *under* the type scale rather than in it. Sentence case: the wide
   letter-spacing that used to go with it existed only to make all-caps
   readable, so both were dropped together, and the size moves up to 13px since
   it no longer needs to compensate for caps.
   Note .table th still sets its own all-caps treatment. */
.lbl { font-size: 13px; font-weight: 500; color: var(--text-muted); }
.num { font-size: 34px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; font-variant-numeric: tabular-nums; }

/* Metric tile — standalone, self-bordered. */
.stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 18px 20px; }
.stat .num { margin-top: 10px; }

/* Stat row — labelled numbers sitting directly on the page: no container, no
   dividers, no padding. Deliberately card-less. On a page that also carries a
   bare plot, wrapping numbers in a card makes them read as a different kind of
   thing than the chart beside them; leaving them bare reserves the card for one
   meaning only — a container of rows.
   Numbers use the title step (26px) rather than an invented size, so they sit
   level with a page's h1 and the weight alone marks them as data. */
/* Columns are sized to their content and the whole set is centred, rather than
   stretched to the container — so the group reads as one centred object
   instead of four numbers pinned across the full width. */
.stat-row {
    display: grid; grid-auto-flow: column; grid-auto-columns: auto;
    justify-content: center; gap: 104px; margin: 0; text-align: center;
}
.stat-item { min-width: 0; }
/* Full margin shorthand, not margin-top: these are <dd>s, which carry a 40px
   inline-start margin in the UA stylesheet. */
.stat-item .num { margin: 8px 0 0; font-size: 26px; }
/* Two centred columns rather than one long stack — stacking every stat pushes
   the chart below the fold on a phone. */
@media (max-width: 780px) {
    .stat-row {
        grid-auto-flow: row; grid-auto-columns: auto;
        grid-template-columns: repeat(2, minmax(0, auto)); gap: 28px 56px;
    }
}

/* Plot with a y-axis gutter. The tick labels are HTML, never SVG <text>: the
   plot stretches non-uniformly (see .spark), which would squash any text
   inside it. They line up because both use the same PAD inset — a tick sits at
   8% for the max and 92% for zero. Change PAD in one place and you must change
   the other. */
.plot { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; }
.plot-axis { position: relative; height: var(--spark-h, 96px); }
.plot-axis span {
    position: absolute; right: 0; transform: translateY(-50%);
    font-size: 11px; line-height: 1; color: var(--text-muted); font-variant-numeric: tabular-nums;
}
/* Sparkline — one thin line. No fill, no axes, no chrome: it sits directly on
   the page rather than inside a pane, so anything else added here reads as
   noise. Set --spark-h on any ancestor to size it.
   The SVG stretches non-uniformly (preserveAspectRatio="none") so the plot
   always spans its container exactly; `vector-effect` is what keeps the stroke
   a true 1.5px through that distortion, and is the only reason it looks right.
   Consequence: never put a circle or text in here — both would stretch into
   ellipses. */
.spark { display: block; width: 100%; height: var(--spark-h, 96px); }
.spark .line {
    fill: none; stroke: var(--accent); stroke-width: 1.5;
    stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke;
}
/* Secondary series — sage, so it reads as data rather than as chrome, while
   still sitting behind the coral primary. Only ever one accent line per plot. */
.spark .line-2 { stroke: var(--sage); }

/* Legend for a multi-series .spark. The swatch is a line segment, not a dot,
   so it matches the mark it stands for. */
.legend { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; }
.legend-key { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--text-muted); }
.legend-key .swatch { width: 12px; height: 2px; border-radius: 2px; flex: none; }

/* ── Table ──────────────────────────────────────────────────────────────── */
.table { width: 100%; border-collapse: collapse; }
.table th {
    text-align: left; padding: 16px 16px 12px;
    font-size: 12px; font-weight: 400;
    color: var(--text-muted); border-bottom: 1px solid var(--border);
}
.table td { padding: 14px 16px; font-size: 14px; border-bottom: 1px solid var(--border); }
/* Numeric column — right-aligned with tabular figures, on the header and its
   cells together. A utility class cannot do this on its own: `.table th` sets
   text-align at (0,1,1) and outranks `.text-right` at (0,1,0), which silently
   left-aligned these headers while their cells right-aligned correctly. Put
   .is-num on the <th> and every matching <td>. */
.table th.is-num, .table td.is-num { text-align: right; font-variant-numeric: tabular-nums; }
.table tbody tr { transition: background var(--t-fast) var(--ease); }
.table tbody tr:hover { background: var(--surface-2); }

/* A cell-level affordance. The whole row highlights, but the pointer and the
   chevron live only on this, because only this opens anything — so the row
   invites you in and the name says where to click.
   Hovering anywhere on the row lights it too, which is what connects the
   row-wide highlight to the one thing in the row that acts. */
.cell-link {
    display: inline-flex; align-items: center; gap: 6px;
    color: var(--ink); font-weight: 500; text-align: left; cursor: pointer;
    transition: color var(--t-fast) var(--ease);
}
.cell-link svg { width: 14px; height: 14px; flex: none; color: var(--text-muted); transition: color var(--t-fast) var(--ease); }
.cell-link:hover, .table tbody tr:hover .cell-link { color: var(--accent); }
.cell-link:hover svg, .table tbody tr:hover .cell-link svg { color: var(--accent); }
.cell-link:focus-visible { outline: none; border-radius: 3px; box-shadow: 0 0 0 1.5px var(--accent); }
.table tbody tr:last-child td { border-bottom: none; }

/* ── Sidebar rail ───────────────────────────────────────────────────────── */
.sidebar { background: var(--surface); border-right: 1px solid var(--border); }
.side-link {
    display: flex; align-items: center; gap: 12px;
    height: 40px; padding: 0 12px; border-radius: var(--r);
    color: var(--text-muted); font-size: 14px; font-weight: 500;
    transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease),
                box-shadow var(--t-fast) var(--ease);
}
.side-link:hover { background: var(--surface-2); color: var(--ink); }
.side-link.is-active { background: var(--accent-weak); color: var(--accent-ink); }
.side-link svg { width: 18px; height: 18px; flex-shrink: 0; }

/* The brand link is the rail's first tab stop. It carries no row styling, so it
   only needs a radius for the ring to sit on. */
.side-brand { border-radius: var(--r); }

/* Same halo as .btn — an outline would square off against the row's radius, and
   the ring stays legible over the plain, hover, and is-active backgrounds
   alike, so focus needs no tint of its own. */
.side-link:focus-visible,
.side-brand:focus-visible {
    outline: none;
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent);
}

/* ── Small-screen notice ─────────────────────────────────────────────────────
   Sidebar pages pin the viewport to a static width=1280 (see base.njk), so
   phones always render the desktop layout scaled down to fit. The notice is an
   overlay warning sub-768 devices about that, dismissable with a class flip —
   nothing about the viewport ever changes. Login is outside all of this: it
   uses base.njk directly and has a real mobile layout.

   Two non-obvious pieces:
   · Shown by class, not media query. Media queries evaluate against the
     LAYOUT viewport, which is always 1280 here, so (max-width: 767px) can
     never match on a phone. base.njk classifies via screen.width instead:
     `small-screen` = sub-768 device, `forced-desktop` = dismissed.
   · The overlay counter-zooms by --notice-zoom (1280 / screen width, set in
     base.njk). Inside the zoomed coordinate space the viewport measures one
     screen-width across, so the panel lays out exactly as it would on a real
     phone-sized viewport while the page behind stays fitted desktop. */
.mobile-notice { display: none; }
html.small-screen:not(.forced-desktop) .mobile-notice {
    position: fixed; inset: 0; z-index: 300;   /* above modals (200) */
    display: grid; place-items: center;
    padding: 14px; background: var(--bg);
    zoom: var(--notice-zoom, 1);
}
/* Same surface treatment as .modal .dialog — it can't reuse that rule, which is
   scoped under .modal and carries the [hidden] transition machinery this has no
   use for. Padding is the one intentional difference: the gutter and the panel
   stack, so every px costs 4px of text width on the narrowest phones. */
.mobile-notice .panel {
    width: 100%; max-width: 380px;
    background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--r-lg); box-shadow: var(--shadow); padding: 24px 18px;
}

/* ── Dropdown menu ──────────────────────────────────────────────────────── */
.menu { position: relative; }
.menu-panel {
    position: absolute; right: 0; top: calc(100% + 6px); z-index: 50;
    min-width: 168px; padding: 6px;
    background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--r); box-shadow: var(--shadow);
    animation: pop var(--t-fast) var(--ease) both;
}
.menu-panel[hidden] { display: none; }
/* Items never wrap: the panel is absolutely positioned, so it shrink-to-fits
   and widens to its longest label instead. min-height rather than a fixed
   height so a label that does wrap — a long client name, a translation — grows
   its row instead of spilling over the one below it. */
.menu-item {
    display: flex; align-items: center; gap: 10px; width: 100%;
    min-height: 36px; padding: 7px 10px; border-radius: var(--r-sm);
    font-size: 14px; line-height: 1.35; color: var(--ink); text-align: left;
    white-space: nowrap; cursor: pointer;
    transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.menu-item:hover { background: var(--surface-2); }
.menu-item svg { width: 16px; height: 16px; flex-shrink: 0; color: var(--text-muted); }
.menu-item.danger { color: var(--danger); }
.menu-item.danger svg { color: var(--danger); }
.menu-item.danger:hover { background: var(--danger-weak); }
.menu-sep { height: 1px; margin: 6px 4px; background: var(--border); }

/* ── Modal ──────────────────────────────────────────────────────────────────
   Enter AND exit are driven by the [hidden] attribute. The JS (which only
   toggles `hidden`) animates both ways with no change; `allow-discrete` keeps
   the node displayed until the child transitions finish, `@starting-style`
   gives each child its enter from-state.

   IMPORTANT — two things make backdrop-filter behave here:
   1. `.modal` itself never fades opacity. An opacity<1 ancestor turns the
      scrim into a group, which makes its backdrop-filter recompute then "pop".
      So the fade lives on the scrim's own background/blur and on the dialog.
   2. Page modals are hoisted to <body> (see sidebar.njk) so the scrim's
      backdrop-filter can sample the whole page, including the sticky sidebar,
      instead of being trapped inside the .enter stacking context. */
.modal {
    position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; padding: 24px;
    transition: display var(--t-base) allow-discrete;
}
.modal[hidden] { display: none; }

/* Scrim — tint + light frost. Both fade via the scrim's OWN properties (no
   wrapping opacity), which keeps the blur stable instead of popping. */
.modal .scrim {
    position: absolute; inset: 0;
    background: oklch(0.30 0.012 264 / 0.34);
    backdrop-filter: blur(4px);
    -webkit-backdrop-filter: blur(4px);
    transition: background-color var(--t-base) var(--ease), backdrop-filter var(--t-base) var(--ease);
}
.modal[hidden] .scrim { background: oklch(0.30 0.012 264 / 0); backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); }
@starting-style { .modal:not([hidden]) .scrim { background: oklch(0.30 0.012 264 / 0); backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); } }

.modal .dialog {
    position: relative; width: 100%; max-width: 400px;
    max-height: 86vh; overflow-y: auto;
    background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--r-lg); box-shadow: var(--shadow); padding: 28px;
    opacity: 1;
    transition: opacity var(--t-base) var(--ease);
    scrollbar-width: thin;
    scrollbar-color: var(--border-strong) transparent;
}
/* Dialog crossfades in place — no translate, no scale. The scrim's tint + blur
   coming up behind it carries the sense of arrival, so movement here only reads
   as a pop. The dialog has no backdrop-filter, so opacity is safe here. Exit
   state on [hidden], enter from-state on @starting-style. */
.modal[hidden] .dialog { opacity: 0; }
@starting-style { .modal:not([hidden]) .dialog { opacity: 0; } }
/* Keep the scrollbar thumb clear of the dialog's rounded corners. The thumb's
   transparent border (matched to the surface) insets it; the track margin
   stops it short of the top/bottom radius. */
.modal .dialog::-webkit-scrollbar { width: 12px; }
.modal .dialog::-webkit-scrollbar-track { background: transparent; margin: 14px 0; }
.modal .dialog::-webkit-scrollbar-thumb {
    background: var(--border-strong); border-radius: 999px;
    border: 3px solid var(--surface); background-clip: padding-box;
}
/* Dialog widths. 400 (default) suits a confirmation; 520 a short form, where
   400 crushes a two-column field grid; 680 an editor. Framed dialogs override
   the padding to 0 and put it on the parts instead. */
.modal .dialog-md { max-width: 520px; padding: 28px 30px; }
.modal .dialog-lg { max-width: 680px; padding: 32px 36px; }
/* Wide enough that the band table's five columns are comfortable rather than
   crushed at their ~560px minimum. */
.modal .dialog-xl { max-width: 780px; padding: 32px 36px; }

/* ── Protocol parameters (read-only) ────────────────────────────────────
   Shared by the protocol card and the session-protocol dialog, and shown in
   design.njk — so it lives here rather than in a page's inline <style>. */
/* Protocol parameters as one horizontal table: a row of muted column
   headings and a single row of values. Deliberately NOT .table — no caps,
   no rules, no cell padding beyond the column gutter. One card costs two
   lines of height instead of one row per parameter.
   width:auto, not 100% — the table takes only what its columns need, so the
   values stay grouped left and the rules stop with the last column instead
   of running on across empty card. The action buttons are positioned by the
   surrounding flex row, not by padding the table out. */
.pmeta { border-collapse: collapse; width: auto; }
.pmeta th {
    padding: 0 24px 8px; text-align: left; vertical-align: bottom;
    font-size: 12px; font-weight: 400; color: var(--text-muted); white-space: nowrap;
    border-bottom: 1px solid var(--border);
}
/* nowrap so a value never breaks across lines — "2 min 30s" and
   "AUX (TOP)" are single facts, and the column widens instead. */
.pmeta td {
    padding: 12px 24px 0; vertical-align: top; white-space: nowrap;
    font-size: 13.5px; color: var(--ink); font-variant-numeric: tabular-nums;
}
/* Flush left so the table starts on the card's text edge. */
.pmeta th:first-child, .pmeta td:first-child { padding-left: 0; }
/* Column rules run through the header and the body as one line, because
   both cells carry the same left border and the table is border-collapse. */
.pmeta th + th, .pmeta td + td { border-left: 1px solid var(--border); }
/* The last column keeps its right padding, so the header rule runs a
   gutter's width past the final value instead of stopping hard against it.
   That gives the last column the same breathing space on both sides as
   every other one, rather than looking clipped. */

/* Bands inside a cell. The grid lives on the LIST and each band is
   display:contents, so every band shares one set of columns — put the grid
   on the row instead and each row sizes its own columns, so stacked bands
   drift out of line.

   Direction is an arrow, not a coloured dot: a red/green dot alone fails
   for the commonest colour blindness, and the old design also spelled the
   direction out beside it — encoding the same thing twice. The arrow does
   it once, and carries the word for screen readers. */
.bandlist {
    display: inline-grid; grid-template-columns: 74px 12px 52px;
    gap: 6px 16px; align-items: baseline;
}
.band { display: contents; }
/* Centred in its own column — a grid item stretches and the glyph then sits
   at the column's left edge, which put the arrow off-centre between the
   range and the threshold. */
.band .ar {
    font-size: 12px; font-weight: 600; line-height: 1;
    color: var(--text-muted); text-align: center;
}
.band.reward .ar { color: var(--ok); }
.band.punish .ar { color: var(--danger); }
.band .z { text-align: right; }

/* ── Protocol editor — sectioned body + band mini-table ─────────────────────
   The body reads as a sequence of hairline-divided sections, each led by a
   meta-label. Bands are an aligned mini-table: the field labels live once in a
   column header rather than repeating on every row, so 1–2 bands stay tidy and
   line up instead of crowding a single flex strip. */
.pf-help { margin: 10px 0 12px; font-size: 12.5px; color: var(--text-muted); }

/* Numbered step tabs. The number is the affordance that says these have an
   order; the tabs stay clickable in any sequence because editing an existing
   record means jumping straight to the part you are changing.
   The active tab's 2px underline sits on the track's 1px rule via the -1px
   margin, so it covers the rule rather than stacking two lines. */
.steps { display: flex; flex-wrap: wrap; gap: 2px; border-bottom: 1px solid var(--border); }
.step {
    display: inline-flex; align-items: center; gap: 8px;
    padding: 0 14px 10px; margin-bottom: -1px;
    font-size: 13.5px; color: var(--text-muted); cursor: pointer;
    border-bottom: 2px solid transparent;
    transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.step .n {
    display: inline-grid; place-items: center; width: 18px; height: 18px;
    border-radius: 999px; background: var(--surface-2); color: var(--text-muted);
    font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums;
    transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.step:hover { color: var(--ink); }
.step.is-on { color: var(--ink); border-bottom-color: var(--accent); }
.step.is-on .n { background: var(--accent); color: #fff; }
.step:focus-visible { outline: none; box-shadow: 0 0 0 1.5px var(--accent); border-radius: 4px; }

/* Sized to the tallest panel — Bands carrying two rows — so the dialog holds
   one height across every step instead of resizing as you move between them.
   If a panel grows past this, raise it rather than letting the dialog jump. */
.pf-panels { min-height: 264px; }

/* Styled to match .pmeta on the protocol card — the same data, so reading it
   and editing it should look like the same table. No panel box, no fill: a
   muted sentence-case header over a hairline, and hairlines between rows. */
/* Bottom rule closes the table, so a single band still reads as a row between
   two lines rather than as loose fields under a heading. */
.pf-bands-panel {
    border: 0; border-radius: 0; background: transparent;
    border-bottom: 1px solid var(--border);
    /* fit-content so the rules span exactly the grid's fixed width rather than
       running on past the last column, and auto margins to centre the table in
       the panel. */
    width: fit-content; margin-inline: auto;
}
/* Header + rows are separate grids, so the Direction column is a FIXED width
   (not auto) — otherwise the header sizes it to the label and the rows to the
   direction toggle, and every column drifts out of alignment. */
/* Fixed, tight columns rather than 1fr: these hold two or three digits, so
   letting them share the leftover width just made a small table look big. */
.pf-band-head, .pf-band {
    display: grid; align-items: center; gap: 0;
    grid-template-columns: 90px 102px 194px 102px 40px;
}
/* Padding rather than grid gap, so a divider can sit in the gutter instead of
   hard against the next cell. First and last are flush, and no rule is drawn
   before the remove button — it is an affordance, not a data column, so a
   divider there would imply one more field than there is. */
.pf-band-head > *, .pf-band > * { padding: 0 12px; min-width: 0; }
.pf-band-head > *:first-child, .pf-band > *:first-child { padding-left: 0; }
.pf-band-head > *:last-child, .pf-band > *:last-child { padding-right: 0; }
.pf-band-head > * + *:not(:last-child),
.pf-band > * + *:not(:last-child) { border-left: 1px solid var(--border); }
.pf-band-head {
    padding: 0 0 8px; background: transparent;
    border-bottom: 1px solid var(--border);
    font-size: 12px; font-weight: 400; letter-spacing: 0;
    text-transform: none; color: var(--text-muted);
}
.pf-band { padding: 12px 0; }
.pf-band + .pf-band { border-top: 1px solid var(--border); }

/* Bare delete affordance for a band row (no button chrome until hovered). */
.pf-band-remove {
    display: inline-grid; place-items: center; width: 30px; height: 30px;
    color: var(--text-muted); background: transparent; border-radius: 4px; cursor: pointer;
    transition: color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
.pf-band-remove:hover { color: var(--danger); background: var(--danger-weak); }
.pf-band-remove svg { width: 16px; height: 16px; }

/* Bare icon-only close, sits flush next to a dialog heading (no padding/bg). */
.icon-close {
    flex-shrink: 0; display: inline-grid; place-items: center;
    width: 24px; height: 24px; color: var(--text-muted); cursor: pointer;
    border-radius: var(--r-sm); transition: color var(--t-fast) var(--ease);
}
.icon-close:hover { color: var(--ink); }
.icon-close svg { width: 18px; height: 18px; }

/* Direction toggle — reward / inhibit for a band.
   No track and no raised pill for the active option: the selected side simply
   takes the colour of what it means, and the unselected side stays muted, so
   the state is carried by the thing itself rather than by chrome around it.
   The arrows are the same ones the protocol card prints, so setting a
   direction and reading one back look like the same idea. */
.dir-toggle {
    position: relative; display: inline-grid; grid-template-columns: 1fr 1fr;
    width: 100%; padding: 3px; background: var(--cool-fill-2); border-radius: 999px;
}
/* A real thumb that slides, rather than a background swapping between the two
   halves. Both columns are 1fr, so the thumb is exactly half the track and
   translating by its own width lands it on the second option. */
.dir-toggle .thumb {
    position: absolute; top: 3px; bottom: 3px; left: 3px;
    width: calc(50% - 3px); border-radius: 999px;
    background: var(--surface); box-shadow: var(--shadow-sm);
    transition: transform var(--t-base) var(--spring);
}
.dir-toggle[data-on="punish"] .thumb { transform: translateX(100%); }
.dir-toggle button {
    position: relative; z-index: 1;
    display: inline-flex; align-items: center; justify-content: center; gap: 6px;
    height: 30px; padding: 0 8px; border-radius: 999px;
    font-size: 12.5px; font-weight: 500; color: var(--text-muted);
    background: transparent; cursor: pointer; white-space: nowrap;
    transition: color var(--t-fast) var(--ease);
}
.dir-toggle button.is-on { color: var(--ink); }
/* Arrows keep their meaning whether selected or not — the thumb says which is
   active, so colour here is free to say what each option does. */
.dir-toggle .ar { font-size: 12px; font-weight: 700; line-height: 1; }
.dir-toggle button[data-dir="reward"] .ar { color: var(--ok); }
.dir-toggle button[data-dir="punish"] .ar { color: var(--danger); }
.dir-toggle button:focus-visible { outline: none; box-shadow: 0 0 0 1.5px var(--accent); }

/* Reference channel multi-select — squarish options with a leading tick that
   appears only when selected. Lighter surface + border than the chips were. */
.chip-toggle {
    display: inline-flex; align-items: center; gap: 7px;
    height: 34px; padding: 0 12px;
    font-size: 13px; font-weight: 500; color: var(--ink); cursor: pointer; user-select: none;
    background: var(--cool-fill-2); border: 1px solid transparent; border-radius: 4px;
    transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.chip-toggle:hover { background: var(--cool-fill); border-color: transparent; }
.chip-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
/* The tick: a check glyph that's collapsed (zero width) until checked. */
.chip-toggle .tick {
    width: 0; height: 14px; overflow: hidden; flex-shrink: 0;
    color: var(--accent); opacity: 0;
    transition: width var(--t-fast) var(--ease), opacity var(--t-fast) var(--ease);
}
.chip-toggle .tick svg { width: 14px; height: 14px; }
/* A firmer accent tint than --accent-weak, which sat close enough to the cool
   grey rest state that the two read as a muddy mix rather than "on". The
   border stays transparent like the rest state — a part-accent edge over a
   part-accent fill was most of what made it look greyed. */
.chip-toggle:has(input:checked) {
    background: color-mix(in srgb, var(--accent) 24%, var(--surface));
    color: var(--accent-ink); border-color: transparent;
}
/* Selected chips are tinted, not white — so they get a tinted hover, not the
   generic grey one. */
.chip-toggle:has(input:checked):hover {
    background: color-mix(in srgb, var(--accent) 32%, var(--surface));
    border-color: transparent;
}
.chip-toggle:has(input:checked) .tick { width: 14px; opacity: 1; }
.chip-toggle:has(input:focus-visible) { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent); }
.chip-toggle.is-disabled { opacity: 0.4; pointer-events: none; }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes pop { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: none; } }

/* ── Loading & entrance ─────────────────────────────────────────────────── */
.skeleton {
    background: linear-gradient(90deg, var(--surface-2) 25%, #E5E1DA 37%, var(--surface-2) 63%);
    background-size: 400% 100%; animation: shimmer 1.4s ease infinite; border-radius: 6px;
}
@keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } }

/* Fade only — NO transform. A transform on this page-content wrapper would
   become the containing block for descendant `position: fixed` modals,
   breaking their viewport pinning. Opacity doesn't create a containing block. */
.enter { animation: enter var(--t-base) var(--ease) both; }
@keyframes enter { from { opacity: 0; } to { opacity: 1; } }

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