/*
 * Mediante — a browser player and editor for OctaMED and MED SoundStudio modules.
 * Copyright (C) 2026  Philip Kaulfuss
 *
 * This program is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License as published by the Free Software
 * Foundation; either version 2 of the License, or (at your option) any later
 * version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program; if not, see <https://www.gnu.org/licenses/gpl-2.0.html> or the
 * LICENSE file in the project root.
 */
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Oswald:wght@400;600&display=swap');

:root {
  /* THE FONT ROLES. js/fonts.js swaps these for a chosen font, sized with
     size-adjust and ascent/descent overrides to take exactly the space these
     defaults take -- so no rule here may name a font directly. Titles are
     every Oswald rule; text is everything else; pattern is the grid (#pat-rows
     and js/canvas-grid.js). The same three strings are REF[role].stack in
     js/fonts.js, and stuff/regression/fonts.js holds the two in step. */
  --font-text:    'Share Tech Mono', monospace;
  --font-title:   'Oswald', sans-serif;
  --font-pattern: 'Share Tech Mono', 'Consolas', monospace;
  --marker-slot: 8px;   /* the unused-instrument bullet's own column */
  --bg:          #1c1c1c;
  --bg-mid:      #252525;
  --bg-panel:    #2a2a2a;
  --bg-inset:    #161616;
  /* THE PATTERN WELL, split from --bg-inset 2026-08-22. --bg-inset means "a
     sunken surface in the chrome" -- the transport group, the VU well, dialog
     panes, scrollbar tracks, 20 sites. The pattern grid is not one of those:
     a tracker's pattern view is a DARK field even when the chrome around it is
     light, which is what OctaMED Pro, ProTracker and Ultimate Soundtracker all
     do. With one token the light tributes had to choose, and choosing dark put
     black text on a black transport group.
     var() of --bg-inset, so every existing theme is unchanged. */
  --bg-pattern:  var(--bg-inset);
  /* ── SITES THAT A THEME COULD NOT REACH ───────────────────────────────────
     Each of these was a literal, or a token borrowed from a surface it does not
     sit on. All are defined as var() of what they used to resolve to, so this
     block moves no colour; the tributes override them below.
       --grid-row       the row-number gutter. It took --text-dim, which in a
                        LIGHT-chrome theme is a dark ink generated for the panel
                        -- painted onto a black pattern well it measured 1.66:1
                        (Professional) and 1.39:1 (Nonplusultra).
       --grid-row-play  the same number on the play row.
       --scrollbar-*    the last two untokenised literals in the file. Left as
                        literals by work order D because they are SURFACES and
                        pointing them at --border/--border-hi would have been
                        semantically wrong; these are the surface tokens it
                        asked for.
       --menubar-ink    the menu-bar item ink, so a theme can make that bar a
                        different colour from its panels.
       --play-lit       the transport button while playing, and
       --rec-lit        while recording -- the latter was an inline style
                        written from js/app.js, which no stylesheet can reach. */
  --grid-row:            var(--text-dim);
  --grid-row-play:       var(--accent-hi);
  --scrollbar-track:     var(--bg-inset);
  --scrollbar-thumb:     #3a3a3a;
  --scrollbar-thumb-hover: #555555;
  --menubar-ink:         var(--text);
  /* AND ITS DIM RUNG. #menu-title took --text-dim, which is derived for the
     PANEL -- fine while the menu bar matches the panel, wrong the moment a
     theme gives the bar a colour of its own. Measured: 1.66:1 on Professional
     (a panel-grey dim on a BLACK bar) and 1.62:1 on Blorange (a pale-blue dim
     on a WHITE one). Phil, 2026-08-23: 'The "mediante" text at the top right
     corner is feint and hard to read on Blorange and Professional.' */
  --menubar-ink-dim:     var(--text-dim);
  /* The menu highlight, as a PAIR so a theme can choose its mechanism. The
     default is the accent wash the app has always used. The Amiga tributes
     override it to INVERSE VIDEO -- Phil, 2026-08-22: "those should use
     inverse video for selections", and his own definition of it: "black on
     grey becomes grey on black". */
  --menu-hi-bg:          var(--accent-a16);
  --menu-hi-ink:         var(--accent-hi);
  --menu-hi-key:         var(--accent-ink);
  --status-ink:          var(--text-dim);
  /* Phil, 2026-08-22: "Colours used only for those buttons. I would prefer if
     they fit in with the theme." So neither is a one-off any more: play lights
     up in the LED colour, REC fills with the accent -- and every theme already
     defines both. --green stays for the VU and success states, which are
     semantics, not chrome. */
  --play-lit:            var(--seg-on);
  --rec-lit:             var(--accent-fill);
  --rec-ink:             var(--on-accent);
  /* The button's LIT (top-left) bevel edge, so a theme can raise a plate
     properly: light top-left, dark bottom-right. --border-control draws all
     four sides and is pinned across 23 controls, so it cannot be the light
     one on its own. Per-side border COLOURS are explicitly exempt from
     control-uniformity.js's rule (its BEVEL check), which is how .btn
     already sets its bottom-right. */
  --btn-edge-hi:         var(--border-control);
  /* A LIT BUTTON'S OWN FILL, split from --accent-fill. The pattern cursor
     (.pc .cs) and the lit button (.btn.on) were the same token, so a theme
     could not have a PINK cursor and INVERSE-VIDEO buttons at once -- which
     is exactly what OctaMED does and what was asked for. */
  --btn-on-bg:           var(--accent-fill);
  --btn-on-bg-hi:        var(--accent-fill-hi);
  --btn-on-ink:          var(--on-accent);
  --bg-header:   linear-gradient(180deg, #383028 0%, #2a2018 100%);
  /* THE INK ON THAT HEADER, split from --accent-ink 2026-08-22. A tribute
     whose title bars are BLUE needs white text on them -- and white is 2.0:1
     on the same theme's grey panel, where --accent-ink also has to read. One
     token cannot do both, and MagicWB's title bars really are blue.
     var() of --accent-ink, so every existing theme is unchanged and only a
     theme that colours its header differently has to say so. */
  --header-ink:  var(--accent-ink);
  --border:      #3a3a3a;
  --border-hi:   #555;
  /* EVERY CONTROL'S EDGE AND CORNER, from one place. Phil 2026-08-09, deciding
     the toolbar inventory: "unify the greys and radius". There were FOUR greys
     for the same visual job — #555 on .btn, #333 on the entry flags, #383838 on
     the bar dropdowns, #3a3a3a on the waveform toggle — written at four
     different times, with nothing distinguishing the cases. Anything new points
     at these; stuff/regression/control-uniformity.js fails if it does not. */
  --border-control:  #3a3a3a;
  --radius-control:  2px;
  /* ...and a ROUND control's: a radio is a circle by definition, not a corner
     style (Convert to Mix Mode brought the first one, 2026-09-28). */
  --radius-round:    50%;
  --accent-ink:       #d4890a;
  /* THE FILTER CURVE'S FOUR COLOURS. They live HERE, not on .fx-curve, and
     that is the entire fix of 2026-08-23. Declared on the canvas element they
     were more specific than :root[data-theme=...], so they SHADOWED every
     theme's own values -- which the two generators had been emitting correctly
     all along. Measured before the move: Magique declared --fxc-hot #000000
     (9.57:1 on its own ground) and the canvas drew var(--accent-ink) #FFA997
     instead, at 1.19:1. The curve was invisible on that theme and the generator
     had never once reached the screen.

     LITERAL HEXES, NOT var() ALIASES, for three measured reasons. An alias
     re-creates the same bug one scope up: --accent-ink is hand-overridden away
     from its generated value on three of the four tributes, so a tenth theme
     that tuned its accent and forgot one --fxc- line would silently inherit the
     wrong colour again. derive() skips any value with no hex, so an alias could
     never be seen by the ramp. And the unthemed :root is a REAL state (see
     js/theme.js: "the fallback if the attribute is missing or unknown"), as is
     data-theme="custom" before any pen is stored -- in both, this quad is what
     a user actually sees.

     These four are not hand-picked either: they are fxcRungs() over this
     palette's own tokens, the same function both generators call, run against
     --bg-inset #161616. 6.36 / 5.82 / 3.22 / 2.20, ordering 1.97x and 1.46x.
     stuff/regression/fx-curve-contrast.js holds all nine themes and this base
     to the rule. */
  --fxc-ink:          #9a9184;
  --fxc-line:         #534e48;
  --fxc-hot:          #d4890a;
  --fxc-ghost:        #8d5e0f;
  /* THE ACCENT'S FILL ROLE, split from its ink role 2026-08-22.
     One token cannot be both: as INK it must clear 4.5:1 against the app's
     grounds, as a FILL it must clear 4.5:1 against the ink laid ON it and
     3:1 against the ground it sits in. Amber satisfied all three by luck --
     it sits near the luminance peak of the hue circle. A tribute does not
     get that luck: Workbench 1.3's orange is 3.05:1 as ink on its own blue
     and 8.77:1 as a fill under black, so a readable variant has to lift the
     INK and leave the fill exactly as it shipped. That is impossible while
     one token does both.
     DEFINED AS var() OF THE INK RUNGS, not as copies of their values. That
     is what makes this split cost nothing: every existing theme sets only
     the ink rungs, and its fills follow automatically instead of being
     left behind at the base accent -- which for the tributes would have
     meant an AMBER edit cursor inside a monochrome MED theme. A theme
     whose two roles genuinely differ overrides the fill; that is the
     whole point, and nothing else has to change. */
  --accent-fill:    var(--accent-ink);
  --accent-fill-hi: var(--accent-hi);

  /* BACK-COMPAT ALIASES, deprecated 2026-08-22. `plugins/README.md` has
     published --amber/--amber-hi/--amber-dim as part of the plugin styling
     contract since the WAM host shipped, and a plugin GUI is mounted into the
     light DOM where it reads these directly. Renaming without aliases would
     not error -- every third-party `var(--amber, #e8a33d)` would silently take
     its FALLBACK and render a fixed amber under every theme, which is exactly
     the failure the plugin token audit already found once with
     --border-color / --panel-bg / --panel-fg.
     Aliases, not copies: they cannot drift. Our own plugins have been moved to
     the new names; these exist only for code we do not ship. */
  --amber:     var(--accent-ink);
  --amber-hi:  var(--accent-hi);
  --amber-dim: var(--accent-dim);
  --accent-dim:   #8a5a07;
  /* THE HALO'S VALUE. js/ink-halo.js decides WHERE it goes; this is only what
     it draws when it fires. It began as a static `text-shadow` on every rule
     that inked text with the accent, and that could not work: text-shadow
     INHERITS, so a row that set it handed it to children carrying their own,
     darker inks -- measured, .seq-row passing a 1px black shadow down to a
     .seq-num whose colour is rgb(51,51,51). Phil, 2026-08-23: 'The
     blocks/sequence numbers get drop shadows even though the text is black,
     but not on the instruments list... I still think a dynamic system is worth
     trying.' He is right on both halves: the static version was arbitrary in
     its coverage AND wrong where it landed.
     A theme can set this to `none` to opt out entirely. */
  --ink-halo:  1px 1px 0 #000000;
  /* THE EDIT CURSOR'S OUTLINE. Split from --accent-dim (which 29 other rules
     also read) so a theme can colour the cursor without moving every dim edge
     with it. Professional asks for exactly that: OctaMED Pro 4's cursor is red
     -- #FF0000/#FF8888/#FFAAAA are measurable in octamedpro4xx.png -- while its
     dim edge is the blue-grey #666677. Read by BOTH the DOM renderer and
     js/canvas-grid.js, which is the pairing that has silently diverged twice. */
  --cursor-line:  var(--accent-dim);
  --accent-hi:    #f0a820;
  --green:       #4cad52;
  --green-dim:   #2a5c2e;
  /* THE VU GRADIENT'S FLOOR, split from --green-dim 2026-08-23. That token was
     doing two jobs on two different grounds -- the meter's 0% stop (on
     --vu-track) and the Render button's border (on the dialog panel) -- so
     neither could be fitted without breaking the other. The classic overloaded
     token; this is the fifth one this appearance work has had to split. */
  --vu-floor:    var(--green-dim);
  --text:        #c8c0aa;
  /* Was #686058, which measured 2.33:1 against --bg-panel — below even WCAG's
     3.0 threshold for UI text, and reported as hard to read (Phil 2026-08-06).
     #9a9184 measures 4.62 on panel and 5.82 on inset, so it passes AA for body
     text on both, while staying clearly dimmer than --text at 7.91 — the
     hierarchy that makes a disabled menu row read as disabled survives. */
  --text-dim:    #9a9184;
  --text-hi:     #e8e0c8;
  /* ── THE PAN KNOB (task #11) ──────────────────────────────────────────────
     Phil, 2026-08-25: "we could use a different colour or shade for the pan
     knobs". A volume knob takes --accent-ink; pan needs to read APART from it
     at a glance, on a strip where the two controls sit one above the other.

     --accent-dim was the obvious candidate and is WRONG: it is a GREY in the
     themes that matter (professional #666677, magique #7B7B7B), so "the same
     hue, quieter" would give grey knobs in exactly the themes that separate
     their accents. The solo chips carry a real second hue, but those MEAN solo
     and borrowing them would blur that.

     So: a token of its own, defaulting to the theme's PRIMARY INK -- which is
     light on the dark themes and BLACK on the light ones (professional, magique,
     nonplusultra all set --text-hi: #000000). That is the point rather than an
     accident: the knob is whatever reads hardest against that theme's chrome,
     and it is always a NEUTRAL beside the accent-coloured volume knob.

     Checked against the rail each one sits on rather than assumed:
       professional   knob #000000 on --bg-rail #9999AA
       magique        knob #000000 on #959595
       nonplusultra   knob #000000 on #997755
       blorange       knob #FFFFFF on #0055AA
     and the volume knob keeps its hue in all four, so the two never collide.

     Pan is a POSITION, not a level, which is what the neutral says. A theme that
     wants a distinct pan hue overrides this one line. */
  --pan-ink:     var(--text-hi);
  --btn-face:    #3c3c3c;
  --btn-hi:      #4a4a4a;
  /* The button gradient's BOTTOM stop, separate from the flat face so a theme
     can set how deep the bevel runs without darkening the two flat consumers
     of --btn-face (the colour swatch and the wave-height chip). Phil,
     2026-08-22, on Professional: "the gradients on buttons and title bars are
     too bright, they should be a darker grey (the mid-grey should be the
     middle of the gradient)" -- which is a statement about this stop. */
  --btn-lo:      var(--btn-face);
  --btn-press:   #1e1e1e;
  /* THE UNLIT READOUT GROUND, and nothing else since 2026-08-22. It was also
     serving as the INK on `.btn.on` and `.fr-f.sel`'s accent fills, which is
     --on-accent's whole job. The overload made a light theme impossible to
     express: Magique needs a DARK readout so the .seg glow reads, and WHITE
     ink on its blue fill, and one token cannot be both.
     Re-pointing those three sites moved #1a1008 -> #000000 on them: OKLab
     dE 18.5, below this project's own confusability floor of 25, and the
     contrast on the amber fill IMPROVES from 6.58:1 to 7.38:1. */
  --seg-off:     #1a1008;
  --seg-on:      #f0a820;
  --panel-w-l:   130px;
  --panel-w-r:   238px;
  /* THE UI SCALE, as a unitless ratio. Consumed by `zoom` on body AND by a
     calc() division on every vw/vh below — viewport units resolve against the
     UNZOOMED viewport and are then multiplied by the zoom, so an undivided
     100vh becomes 150vh of content at 1.5. js/ui-scale.js owns the value. */
  --ui-scale:    1;
  --menubar-h:      21px;
  --transport-h:    46px;
  --status-h:       19px;
  /* ── DENSITY-OWNED SPACING ────────────────────────────────────────────────
     Every value a Compact flip is allowed to touch, and nothing else. They are
     SPACING ONLY -- no font size, no hit target, no border width -- because
     density and scale are orthogonal axes and shrinking type is what scale
     already does better. The tight values live in the [data-spacing="tight"]
     block near the themes; keeping the pair adjacent in the vocabulary but
     apart in the file is what lets the gate assert one is componentwise
     smaller than the other. */
  --d-transport-pad: 3px 10px;
  --d-edbar-pad:    2px 10px;
  --d-edbar-gap:    5px;
  --d-dlgtitle-pad: 4px 12px;
  --d-set-pad:      10px 14px;
  --d-set-gap:      12px;
  --d-set-rowgap:   6px;
  --d-sechead-gap:  14px;
  --d-panelhead-pad: 1px 7px;
  /* THE MENUS. Phil, 2026-08-22: "In compact UI mode, the menus (top menu
     bar, right click menus) could be a lot denser. Lots of padding in them."
     A ten-row dropdown gets about 40px shorter from the row padding alone. */
  --d-menubar-item-pad: 0 7px;
  --d-menu-pad:     2px;
  --d-menuitem-pad: 2px 6px;
  --d-menu-gutter:  7px;    /* the tick column left of every label */
  --d-menu-keygap:  14px;   /* label-to-shortcut gap */
  --d-ctx-minw:     150px;
  --d-drop-minw:    170px;
  /* SECOND ROUND OF DENSITY TOKENS, 2026-08-24. Phil: "there's still a lot of
     padding around the UI that could be trimmed just a little more in compact
     mode. The transport bar, the edges of windows etc." These were the fixed
     values those two things were actually made of -- none of them was under
     density control at all, so Compact could not reach them however far the
     existing tokens were pushed. Declared here at exactly what they already
     were, so Normal does not move by a pixel. */
  --d-dlgbody-pad:   12px;        /* .sd-body -- the window edges */
  --d-dlgfoot-pad:   10px 12px;   /* .sd-foot */
  --d-dlgfoot-gap:   10px;
  --d-transport-gap: 8px 8px;     /* between the transport's own groups */
  --d-tright-gap:    4px;
  --d-panes-gap:     12px;        /* between a dialog's side-by-side panes */
  --d-infogrid-pad:  5px 7px;     /* MODULE INFO and the other key/value grids */
  --d-set-dialog-h:  620px;      /* the tallest Settings tab; see .set-dialog */
  /* ZERO IN BOTH DENSITIES (Phil, 2026-08-26: "can we remove all of the
     padding around the pattern view?"). The token stays because the density
     system owns this gap and a future density may want it back -- but the
     grid is the thing people look at, and 8px of dead pane on each side is
     16px of width and height it never got. The 1px border stays: it is the
     focus indicator (its colour changes on .ed-focus), not decoration. */
  --d-pattern-margin: 0px;     /* the gap around the pattern pane itself */
  --d-frrow-gap:     8px;         /* label-to-control inside a dialog row */
  --d-frrow-mb:      9px;         /* and between those rows */
  /* The Mixer window's rail (task #11 part 3). A strip here is NARROW and
     fixed -- the whole point of undocking is seeing the faders "closer
     together, without having to line up with the channels in the pattern" --
     so its width is a density decision rather than --pc-w. */
  /* WIDER SINCE ROUND 3 (Phil, 2026-09-12: "The channel names are now truncated
     in the mixer window", and "'no send target' is truncated and the caret
     overlaps it"). 76 px was chosen when a strip held a fader, a meter and two
     buttons; round 2 added a name, a send picker with its own caret, an output
     footer and a peak readout, and at 76 px every one of those ellipsised. The
     rail scrolls and, since the CONTENTS of a strip outside it are skipped
     entirely (content-visibility on `.mixs > *` — see the long note at .mixs),
     width no longer costs layout on strips nobody is looking at. */
  /* A DOCKED STRIP WITH NO PATTERN COLUMN UNDER IT (round 5): every bus, and
     the master. Narrow because there is nothing to line up with — the channel
     columns are --pc-w so their headers sit over their cells, and these sit
     over the hatched area past the last track. */
  --d-phbus-w:       26px;
  --d-mixstrip-w:    96px;
  --d-mixstrip-gap:  4px;
  --d-mixstrip-pad:  6px;
  /* THE STRIP'S SIX FIXED ROWS (round 4 · the rail rewrite). Every strip
     declares the SAME template from these, which is what makes the rows
     line up without the rail sizing them — see the long note at .mixs.
     Each is the row's own content height, measured across all 36 strips of
     bastardo2 with content-visibility skipping forced OFF so nothing
     under-reported. They are px rather than em because `zoom` carries the
     scale axis (body { zoom: var(--ui-scale) }), so a px here already
     scales; density is the axis that needs its own values, and does not
     currently want different ones. */
  --d-mixs-r-head:   13px;
  --d-mixs-r-ins:    14px;
  --d-mixs-r-sends:  14px;   /* the FLOOR; js/mixer-window.js raises it */
  --d-mixs-r-pan:    11px;
  --d-mixs-r-ms:     13px;
  --d-mixs-r-out:    14px;
  /* ── THE METER COLUMN'S WIDTH, SAID ONCE (round 5 · U5) ────────────────
     Phil: "clip indicator at the top is too wide". It was 88px over a 29px
     meter — the lamp is a full-width flex child and the meter is not, so the
     lamp reported a channel by spanning the whole strip while the thing it
     reports on sat in the middle of it.

     The meter's width is not a number anyone chose: it is two bars, the
     fader's box between them, less the grab the bars are pulled back over,
     plus its own border. Written out here so the lamp can be exactly as wide
     as the meter BY DERIVATION rather than by a 29 that would drift the first
     time a bar changed width. --vu-grab falls back to 6px for the same reason
     .mixs-meter > i does: it is declared on #pat-hdr, and this window is not
     inside it. */
  --d-mixs-bar-w:    11px;
  --d-mixs-scale-w:  16px;
  --d-mixs-meter-w:  calc(3 * var(--d-mixs-bar-w) - var(--vu-grab, 6px) + 2px);
  /* ── A NESTED GROUP BLOCK'S COLOUR (round 3 · P1) ────────────────────────
     Phil: "The colour coding is what really makes this work ... Ableton uses
     the same colour for group members". The colour is the MODULE's — every
     group has one from OPS.fxGroupColor and every track already carries its
     group's — so js/mixer-window.js writes these five onto each block inline
     (blockVars()). A stylesheet cannot enumerate a palette whose length it does
     not know, which is why they are custom properties and not a class per hue.
     What is declared here is what a block with NO colour falls back to, and it
     is deliberately the accent dressing round 2's box already had. */
  --mixg-colour: var(--accent-ground-soft);   /* the bar, and the down stroke */
  --mixg-ink:    var(--accent-hi);            /* ink chosen FOR that colour    */
  --mixg-edge:   var(--accent-dim);           /* the block's frame             */
  --mixg-strong: transparent;                 /* wash on the group's own strip */
  --mixg-soft:   transparent;                 /* ...and on its members         */
  /* Repeated colour values, given names by the WO-29 sweep (2026-08-21).
     Each value is exactly what shipped before — the sweep relocates the
     literals, it moves no colour; colour-tokens.js pins that. */
  --bg-hover:    #333333;
  --seam:        #111111;
  --seam-soft:   #222222;
  --seam-dark:   #0a0a0a;
  --bg-rail:     #1a1a1a;
  --accent-wash: #2a2010;
  --bevel-hi:    #444444;
  --on-accent:   #000000;
  --ok-hi:       #6fe07a;
  --bg-hover-warm:  #383028;
  --bg-rail-hover:  #2a2620;
  --bg-grid-head: #1c1a14;
  --seam-row:    #202020;
  --alert:       #a33;
  --stripe-beat: rgba(255,255,255,0.014);
  --stripe-bar:  rgba(255,255,255,0.028);
  --scrim:       rgba(0,0,0,.6);
  --scrim-soft:  rgba(0,0,0,0.55);
  --shadow-strong:  rgba(0,0,0,0.9);
  --shadow-mid:  rgba(0,0,0,0.7);
  --shadow-deep: rgba(0,0,0,0.8);
  /* THE ACCENT AS BARE CHANNELS. Every alpha of the accent -- here, and in
     js/canvas-grid.js -- resolves from these two, so a theme sets the colour
     ONCE instead of needing a token per alpha. rgb(r g b / a) is CSS Color 4
     and canonicalises to exactly the rgba() these replaced, which is why this
     substitution moves no colour. */
  --accent-rgb:    212 137 10;
  --accent-hi-rgb: 240 168 32;
  --accent-a16:  rgb(var(--accent-rgb) / 0.16);
  /* The play-row tints. Named because the canvas grid draws the SAME two
     colours as `.pr.cur` and `.pr.cur .prn` below, and kept its own literal
     copies of them until the theme engine landed. */
  --play-row:        rgb(var(--accent-rgb) / 0.11);
  --play-row-gutter: rgb(var(--accent-rgb) / 0.14);
  /* The looped region behind an instrument's waveform preview. It was a literal
     rgba(212,137,10,0.10) in js/instrument-waveforms.js -- the BASE amber, so it
     did not follow the theme, and once it did, 10% of a violet or a navy is far
     less visible than 10% of amber was: amber sits near the luminance peak of
     the hue circle and the colours that replaced it do not. 0.18 restores the
     visibility the amber had rather than the number it had. A token, not a
     constant in the canvas, so a theme whose accent needs more or less can say
     so. */
  --wave-loop:       rgb(var(--accent-rgb) / 0.18);
  /* WO-30 semantic families (2026-08-21). One token per meaning, so a
     theme sets a FAMILY rather than a value. Values are exactly what shipped
     before; colour-tokens.js pins CHANGED:0 for this pass. */
  --grid-note:   #6cc;
  --grid-instr:  #c8a060;
  --grid-cmd:    #c080c0;
  --grid-level1: #b8a080;
  --grid-level2: #8e7d62;
  --grid-vol:    #80b880;
  --mute-bg:     #5a1a1a;
  --mute-edge:   #8a2a2a;
  --mute-ink:    #ff8080;
  --solo-bg:     #5a4a1a;
  --solo-edge:   #aa8a2a;
  --solo-ink:    #ffd070;
  --vu-track:    #0e1a0e;
  --vu-track-edge:  #1a2a1a;
  --vu-safe:     #6cd46c;
  --vu-warn:     #e0e040;
  --vu-clip:     #e04040;
  --accent-ground:  #3a2a10;
  --accent-ground-soft:  #2a2415;
  --accent-ground-deep:  #1c1a10;
  --stripe-bar-gutter:  #211f1a;
  --stripe-beat-gutter:  #1b1a17;
  --sel-fill:    rgba(120,170,255,0.20);
  --sel-edge:    rgba(150,190,255,0.85);
  --sel-fill-soft:  rgba(120,170,255,0.12);
  --sel-edge-soft:  rgba(150,190,255,0.9);
  /* THE STEM DIALOG'S SELECTION BAR, re-pointed at the theme 2026-08-23.
     These were a fixed cool blue -- #202830 / #2f3b48 / #7fbce8 -- and no theme
     overrode any of them, so the bar stayed the same slab of navy in all ten.
     In a violet or amber app it simply looked foreign; on the two LIGHT
     tributes it was worse than that, because the ink on it is the theme's dark
     body text and the ground stayed dark navy: 1.41:1, measured, in both
     Magique and Professional. Phil, 2026-08-23: the stem dialog "still has a
     few inconsistencies with the design and colour schemes compared to most of
     the rest of the app".
     Expressed as var() of tokens every theme already sets, so it follows all
     ten with no per-theme work and cannot go stale again. */
  --selbar-bg:   var(--accent-wash);
  --selbar-edge: var(--border-hi);
  --selbar-ink:  var(--accent-ink);
  --row-hover-cool:  #20242a;
  --bg-bar:      #151515;
  --bg-status:   #181818;
  --bg-float:    #1d1d1d;
  /* THE DROPDOWN MENUS' OWN GROUND AND INKS, split out 2026-08-23. Phil:
     "can we make the drop down menu backgrounds in blorange and magique
     white?" -- which is right for both, since a Workbench menu is white and
     Blorange's own menu BAR is already blue-on-white.

     They cannot simply take --bg-float: that token also grounds `select
     option`, and Blorange's --text is WHITE, so pointing the shared token at
     white would have made the native option list white-on-white wherever it is
     honoured. And the item ink cannot stay --text for the same reason. So the
     menus get their own small family, every member defined as var() of what it
     resolves to today -- no theme that does not restate them changes at all. */
  --bg-menu:      var(--bg-float);
  --menu-ink:     var(--text);
  --menu-ink-dim: var(--text-dim);
  --menu-ink-off: var(--ink-off);
  --menu-tick:    var(--accent-ink);
  --bg-menubar-hi:  #2e2e2e;
  --btn-hover-hi: #545454;
  --btn-hover-lo: #424242;
  --ink-off:     #767065;
  --alert-hi:    #d45a3a;
  --ok-ground:   #1d241d;
  --accent-bright:  #ffc44d;
  --sep:         #333333;
  --bevel-lo:    #333333;
  --bg-menubar-lo:  #222222;
  --bg-chip-hover:  #222222;
  --shadow-cursor:  #000000;
  --scrim-modal: rgba(0,0,0,0.55);
}

* { box-sizing: border-box; margin: 0; padding: 0; }

body {
  /* The built-in UI scale. `zoom` reflows (unlike transform:scale, which also
     detaches every position:fixed dialog — measured) and keeps rects and
     pointer coordinates in one space, so hit testing needs no change.
     stuff/ui-scale-probe-2026-08-21.md has the measurements. */
  zoom: var(--ui-scale);
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-text);
  font-size: 12px;
  user-select: none;
  height: calc(100vh / var(--ui-scale));
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

/* ── MENU BAR ───────────────────────────────────────────── */
#menubar {
  height: var(--menubar-h);
  background: linear-gradient(180deg, var(--bg-menubar-hi) 0%, var(--bg-menubar-lo) 100%);
  border-bottom: 1px solid var(--seam);
  display: flex;
  align-items: stretch;
  flex-shrink: 0;
}
.menu-item {
  padding: var(--d-menubar-item-pad);
  display: flex;
  align-items: center;
  color: var(--menubar-ink);
  cursor: pointer;
  white-space: nowrap;
  border-right: 1px solid var(--bg-panel);
  font-size: 12px;
}
.menu-item:hover { background: var(--menu-hi-bg); color: var(--menu-hi-ink); }
#menu-title {
  margin-left: auto;
  padding: 0 12px;
  color: var(--menubar-ink-dim);
  font-size: 10px;
  display: flex;
  align-items: center;
  letter-spacing: 1px;
  border-left: 1px solid var(--bg-panel);
}

/* ── TRANSPORT ──────────────────────────────────────────── */
/* The toolbar WRAPS rather than clipping its controls when the window is
   narrow (Phil 2026-07-27) — so height is a minimum, not a fixed size. */
#transport {
  min-height: var(--transport-h);
  background: var(--bg-mid);
  border-bottom: 2px solid var(--accent-dim);
  padding: var(--d-transport-pad);
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--d-transport-gap);
  flex-shrink: 0;
  /* A CONTAINER, so the rule below can ask how much room this bar actually
     has. See the @container note under .t-right. Nothing here is captured by
     the containment: the only absolutely-positioned descendant is #ed-dirty,
     inside .t-right, and #btn-save is already position:relative, so it was
     never this element's to contain — the same reasoning the #i-list container
     query records for .inr-wave. (An earlier draft of this comment named
     .vu-bar. That conclusion held but the example did not: .vu-bar carries no
     position property at all.) */
  container-type: inline-size;
}
/* The right-hand utility group stops being pinned right once the bar wraps,
   otherwise it strands itself on a line of its own. */
#transport > .t-right { display:flex; gap:var(--d-tright-gap); align-items:center; margin-left:auto; }
/* A CONTAINER QUERY, NOT A MEDIA QUERY, and the difference is the whole point.

   What this rule is really asking is "has the bar wrapped?" — 1100px was only
   ever a proxy for that, because #transport is flex-wrap:wrap and a wrapped
   .t-right strands itself on a line of its own, pushed right by the auto
   margin.

   As a MEDIA query it asked the wrong question once a UI scale existed. Media
   queries are zoom-blind — measured: a 622px viewport at zoom 1.5, i.e. 405
   layout px of actual room, still reported (max-width:500px) as false. So at
   150% on a 1600px window the bar has 1067 layout px and genuinely may wrap,
   while the media query still sees 1600 and never fires. calc() cannot help:
   a media feature cannot read a custom property.

   A container query asks the element how wide it IS, in layout px, which is
   the space the 1100 was written in and which shrinks correctly as the scale
   rises. It needs no JavaScript, no listener, and no re-arming when the scale
   changes — and it keeps working on a plain window resize exactly as before.
   Same pattern as the [data-list] container query further down. */
/* 1120, NOT 1100, and both corrections are measured.
   (a) A container-query length is measured against the container's CONTENT box
       while the old media query measured the VIEWPORT, and #transport has
       padding:6px 10px — so 1100 content px is 1120 viewport px, i.e. the rule
       fired 20px later than the media query it replaced.
   (b) A sweep of the bar's content width in 2px steps found a 16px band —
       content 1102 to 1116 — where the bar has ALREADY wrapped (.t-right's
       offsetTop jumps 34 → 76) but the rule had not yet fired, leaving the auto
       margin to strand .t-right ~890px right of its own line.
   Firing early is free: at content 1140 and 1118, both unwrapped, the USED value
   of margin-left:auto is already 0px, because #song-name-box { flex:1 1 0 } has
   consumed the free space on that line. So raising the threshold cannot change
   any unwrapped layout. (Since 2026-09-28 the box stops at its title's width,
   so above the threshold the auto margin takes what is left; between the wrap
   point and 1120 there are at most ~20px free and the box still takes them.) */
@container (max-width: 1120px) { #transport > .t-right { margin-left:0; } }
.tg {
  display: flex;
  align-items: center;
  gap: 2px;
  background: var(--bg-inset);
  border: 1px solid var(--border);
  padding: 3px 4px;
}
.btn {
  background: linear-gradient(180deg, var(--btn-hi) 0%, var(--btn-lo) 100%);
  border: 1px solid var(--border-control);
  border-radius: var(--radius-control);
  /* The bevel stays: it is what says MOMENTARY. Phil kept the distinction
     between a button you press and a flag that shows state, and the raised
     edge plus the gradient is the whole of it. */
  border-top-color:    var(--btn-edge-hi);
  border-left-color:   var(--btn-edge-hi);
  border-bottom-color: var(--btn-press);
  border-right-color:  var(--btn-press);
  color: var(--text);
  cursor: pointer;
  font-family: var(--font-text);
  font-size: 12px;
  padding: 4px 10px;
  min-width: 32px;
  text-align: center;
  outline: none;
  line-height: 1;
}
/* A fixed-width slot for a state glyph that comes and goes (REC's ◯ / ●).
   Reserving the space when empty keeps the button's width — and the position of
   everything to its right — constant across state changes.
   Width is in PX and measured against the widest glyph actually used: at 12px
   Share Tech Mono, ◯ is 13.00px and ● is 6.61px. The first attempt used 0.95em
   (11.39px), so ◯ overflowed the slot and collided with the "REC" text while ●
   sat visibly off-centre (Phil 2026-07-28). overflow:hidden is a backstop if a
   future glyph is wider still. */
.btn-glyph { display:inline-block; width:14px; text-align:center;
  overflow:hidden; white-space:nowrap;
  /* Fixed HEIGHT too, not just width. An empty inline-block is 0px tall but
     12px once it holds a glyph, and that taller line box grew the button from
     22px to 24px — shifting the whole transport row down 2px when arming REC
     (Phil 2026-07-28). Pinning height + line-height + vertical-align makes the
     box identical whether or not there is a glyph in it. */
  height:12px; line-height:12px; vertical-align:top;
  /* Centre the glyph between the button's left edge and its label. The slot
     used to butt straight up against the text: with 10px padding + 1px border
     the ◯ sat at 18.3px while the label started at 25.3px, so it looked
     crowded to the right. A right margin equal to that 11px left inset puts
     the glyph's centre at the midpoint of the two (Phil 2026-07-28). The
     margin is unconditional, so an EMPTY slot reserves the same space and the
     button width still doesn't change between idle, armed and recording. */
  margin-right:11px; }

/* Transport buttons whose GLYPH changes must not resize with it: ▶ is 10.34px
   and ⏸ is 12px, so play/pause alone shifted the whole row by ~1.7px. Sized to
   the widest glyph + padding + border so every state lands on the same width. */
/* One width for all five transport buttons so the row is even and nothing
   shifts: the labels are now static (PLAY no longer swaps to a pause glyph),
   but a shared min-width also keeps ▶ / ▶❘ / ■ optically aligned. */
#btn-play, #btn-continue, #btn-play-block, #btn-cont-block, #btn-stop { min-width:38px; }
#btn-rec { min-width:56px; }
/* Digits in a changing label must not reflow either — tabular figures keep
   "9" and "11" the same width. */
.btn { font-variant-numeric: tabular-nums; }
.btn:hover:not(:disabled) { background: linear-gradient(180deg,var(--btn-hover-hi) 0%,var(--btn-hover-lo) 100%); color: var(--text-hi); }
/* Pressed = the bevel INVERTED. All four sides are named per-side rather than
   set with a `border-color` shorthand so the resting edge (the token) and the
   bevel (the momentary signal) never share one declaration. */
.btn:active:not(:disabled) { background: var(--btn-press); border-top-color:var(--seam); border-left-color:var(--seam); border-bottom-color:var(--bevel-hi); border-right-color:var(--bevel-hi); }
.btn:disabled { opacity: .3; cursor: default; }
/* `.btn.amber` MARKS A DIALOG'S PRIMARY ACTION, and five buttons still use it:
   wc-open, fr-replace, echo-go, slide-go, cfm-ok. (unsaved-save was the sixth
   until 2026-09-12; its dialog merged into cfm-ok's — mixer round 2, C3 — and
   js/confirm-dialog.js now takes the amber OFF cfm-ok while the save is
   impossible, so the greyed button is not also the highlighted one.) What it no
   longer marks is anything in the TOOLBAR — Phil dropped it from OPEN on
   2026-08-09 ("the open button should not have the amber styling"), where one
   amber button among nine grey ones read as an accident rather than a
   hierarchy. In a dialog it is a hierarchy, so it stays. */
.btn.amber { color: var(--accent-ink); border-color: var(--accent-dim); }
.btn.amber:hover:not(:disabled) { color: var(--accent-hi); }
.btn.playing { color: var(--play-lit); text-shadow: 0 0 8px var(--play-lit); }
/* Recording: a filled state, like .btn.on but in the record colour. Both the
   fill and its ink come from tokens so every theme can restate them -- the old
   inline '#a02020' from js/app.js could never carry an ink at all. */
.btn.recording { background: var(--rec-lit); color: var(--rec-ink);
                 border-color: var(--rec-lit); font-weight: bold; }
/* The button that STARTED playback is usually also the one we disable (it has
   nothing left to do while it's already doing it), and .btn:disabled's
   opacity:.3 was dimming the green "this is what's playing" cue right along
   with it — the one button you most want to pick out was the faintest on the
   bar (Phil 2026-07-28). Which button is live is the more useful signal, so
   keep it at full strength and let the disabled state read only as a
   non-responsive cursor. Specificity: 2 classes + a pseudo-class beats
   .btn:disabled's 1 + 1, so this wins without !important. */
.btn.playing:disabled { opacity: 1; cursor: default; }

.tsep { width: 1px; height: 36px; background: var(--border); margin: 0 2px; }
/* Divider INSIDE a .tg group (e.g. transport buttons | REC). */
.tg-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 3px; }
/* Group captions inside the transport (SONG / BLOCK) — they name what the two
   buttons after them act on, which is what removes the ambiguity. */
.tg-lbl { font-size:8px; letter-spacing:1px; color:var(--text-dim); padding:0 3px 0 1px;
  align-self:center; user-select:none; }

/* THE ONE ELASTIC ITEM IN THE TRANSPORT. It used to sit before the readouts
   with `max-width:260px`, which ellipsed most real module names. Now that it is
   after the meters it can take the leftover width instead: flex:1 with a min-
   width small enough that the fixed clusters never get squeezed first, so a
   long name shows in full whenever the window allows. min-width on a flex
   child defaults to auto, which refuses to shrink below the content — hence
   the explicit 0, or a long title would push .t-right off the bar.
   ONLY AS WIDE AS IT NEEDS TO BE (Phil, 2026-09-28: the box "became scalable to
   fill the entire available width, so that part of the UI turns into a big
   button"). max-width:max-content stops the growing at the title's own width;
   the width left over goes to .t-right's auto margin, which is empty bar, not
   button. The basis stays 0, so where the bar wraps is unchanged: a flex item's
   BASIS is what line-breaking measures, and a long title still shrinks and
   ellipses rather than pushing .t-right onto a line of its own. */
#song-name-box { display:flex; flex-direction:column; justify-content:center; gap:2px;
                 flex:1 1 0; min-width:0; max-width:max-content; cursor:pointer; padding:2px 4px;
                 border:1px solid transparent; }
#song-name-box:hover { border-color:var(--border); background:var(--bg-inset); }
#song-name-box:focus-visible { outline:1px solid var(--accent-ink); outline-offset:1px; }
#song-title { color:var(--accent-ink); font-size:12px; letter-spacing:1px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* Elapsed / total. Tabular figures so the seconds ticking over do not shuffle
   the title above them left and right once a second. */
#song-time { color:var(--text-dim); font-size:10px; letter-spacing:1px; white-space:nowrap;
             font-variant-numeric:tabular-nums; }
#song-elapsed { color:var(--text); }
/* (#fmt-badge removed 2026-07-28 — the side panel's INFO shows the format.) */

/* Readout clusters. `.sgrp` is the atomic unit: it never wraps internally, so
   ORDER/BLOCK/LINE and BPM/TICKS each stay whole. #readouts may still wrap
   BETWEEN the two clusters on a very narrow window rather than overflowing. */
#readouts { display:flex; align-items:center; flex-wrap:wrap; gap:8px; }
.sgrp { display:flex; align-items:center; flex-wrap:nowrap; gap:8px; flex:none; }

/* The transparent border is in the BASE rule on purpose, so:hover only changes
   its colour. Adding a border on hover instead would grow the box by 2px and
   shove the whole cluster sideways every time the pointer crossed it — the same
   reason #song-name-box declares one. Only the readouts that open something
   carry .sw-hot; LINE has no window, so it stays inert and unstyled. */
.sw { display:flex; flex-direction:column; align-items:center; gap:1px; flex:none;
      border:1px solid transparent; }
.sw[role="button"] { cursor:pointer; }
.sw[role="button"]:hover { border-color:var(--border); }
.sw[role="button"]:focus-visible { outline:1px solid var(--accent-ink); outline-offset:1px; }
/* Fixed box height + nowrap: the BPM/TICKS readouts used to grow taller when
   their text changed (e.g. the "---" placeholder while stopped vs "125.00"),
   because the glyphs could wrap inside a shrunk box (Phil 2026-07-27). */
.seg {
  background: var(--seg-off);
  border: 1px solid var(--seam-dark);
  border-bottom-color: var(--bevel-lo); border-right-color: var(--bevel-lo);
  padding: 0 6px;
  /* Both follow the font-size axis (--font-room, js/fonts.js): the box is
     fixed for the reason above, but fixed AT THE CURRENT TYPE SIZE -- at 140%
     the readout's own digits were being trimmed by it. 1 at 100%. */
  height: calc(26px * var(--font-room, 1)); line-height: calc(24px * var(--font-room, 1));
  box-sizing: border-box;
  white-space: nowrap; overflow: hidden;
  font-size: 18px; letter-spacing: 2px;
  color: var(--seg-on);
  /* AN LED GLOWS ITS OWN COLOUR. This was --accent-ink, which matches --seg-on
     by coincidence in the amber scheme and stops matching the moment a theme
     sets them apart -- Magique ended up with a BLUE glow around a salmon
     readout. OKLab dE from the old value in the base theme is 9.2, on a 5px
     blur rather than an edge, so nothing perceptible moves here. */
  text-shadow: 0 0 5px var(--seg-on), 0 0 12px rgb(var(--accent-hi-rgb) / 0.2);
  font-variant-numeric: tabular-nums;
  min-width: 2.2ch; text-align: right;
}
/* LINE is the readout whose digit count actually changes in normal use — a
   module with blocks either side of 100 lines made the box jump 37.45px ->
   49.17px and nudged the whole cluster (Phil 2026-07-28). Give it a floor wide
   enough for three digits so it never resizes. The inherited 2.2ch min-width is
   inert here: it lands below the 14px of padding+border alone, so width was
   entirely content-driven. Constant = 3 letter-spacings (6px) + 2x padding
   (12px) + 2x border (2px); recompute it if any of those change above. */
/* +21 not +20: at exactly 3ch the floor and the content width tie, and
   sub-pixel rounding still flickered the box by 0.01px. One extra pixel keeps
   the floor strictly above the widest content, so the width is constant. */
#d-row { min-width: calc(3ch + 21px); }
/* SEQ and BLOCK get the SAME floor, but only when the module can actually reach
   three digits — Phil, 2026-08-12: "can we pre-size the seq and block counters
   in the top bar if the module contains more than 99? Like we did for lines".
   LINE carries its floor unconditionally because every module has rows; most
   modules have neither 100 sequence positions nor 100 blocks, and widening
   their cluster for a case that cannot arise is a permanent cost for nothing.
   js/app.js sets .wide3 from the parsed counts on load and on every structural
   edit. Same constant, same derivation as #d-row above — these three share a
   font-size, so the `3ch` means the same thing in all of them. */
.seg.wide3 { min-width: calc(3ch + 21px); }
.sl { font-size: 8px; color: var(--text-dim); letter-spacing: 1px; }

/* Block audition: the ORDER readout and the song-list position are pinned on
   purpose while a single block loops, so they are dimmed to say "parked", not
   "stuck". BLOCK / LINE keep their full brightness — those are still live.

   MUST stay scoped to the SEQUENCE list: the BLOCK list rows carry the SAME
   .seq-row class (js/app.js builds both lists with it), so an unscoped
   `.seq-row.playing` dimmed the block-list marker too — measured at 0.45 on
   both lists. That marker denotes the block you are HEARING, which is the one
   thing that is definitely live. Phil 2026-08-06.

   Scoped by ROLE (`[data-list="song"]`) rather than by `#song-list`, so it
   still applies when the sequence is rendered into a floating window. The id
   version was a silent failure waiting to happen: the dimming would simply
   stop, with no error and no visual clue, and the marker would look live while
   playback was parked — the exact confusion the rule exists to prevent. */
body.block-audition #d-ord { opacity:0.32; text-shadow:none; }
body.block-audition #d-ord + .sl { opacity:0.5; }
body.block-audition [data-list="song"] .seq-row.playing { opacity:0.45; }

#vu { display:flex; align-items:center; gap:6px; background:var(--bg-inset); border:1px solid var(--border); padding:5px 6px; height:36px; }
/* Stacked L over R (Phil, 2026-08-25). Two 8px tracks plus a 3px gap is 19px
   inside the 24px of content height 36px leaves, so this fits without touching
   the transport's height — which everything else in the row is aligned to. */
.vu-rows { display:flex; flex-direction:column; gap:3px; }
.vu-row  { display:flex; align-items:center; gap:3px; }
/* Fixed width so the two tracks start at the same x. They are both one
   character today and would line up by luck; this stops that being luck. */
.vu-label { font-size:9px; color:var(--text-dim); width:7px; flex:0 0 auto; }
/* The numeric readout the stacking paid for. Tabular figures so the digits do
   not jitter as the value changes — a readout that shifts around is harder to
   read than no readout. */
.vu-read {
  min-width:40px; text-align:right; font-size:10px; line-height:1;
  color:var(--text-dim); font-variant-numeric:tabular-nums;
  user-select:none; white-space:nowrap;
}
.vu-read.over { color:var(--vu-clip); }
.vu-read.hidden { display:none; }
.vu-track { width:80px; height:8px; background:var(--vu-track); border:1px solid var(--vu-track-edge); position:relative; overflow:hidden; }
/* The master bar reveals its gradient too, and for the same reason as the
   strips above — it had the identical bug: `width:50%` painted the whole
   gradient into half the track, so a quiet mix drew a short RED-tipped bar. */
.vu-bar { height:100%; width:100%; clip-path:inset(0 100% 0 0); background:linear-gradient(90deg,var(--vu-floor) 0%,var(--vu-safe) 70%,var(--vu-warn) 88%,var(--vu-clip) 100%); transition:clip-path 0.04s; }
/* The CLIP LED (task #11), drawn INSIDE the meter it belongs to.

   The first cut was a badge appended to the #vu flex row, which shifted the
   whole transport every time it appeared. Phil asked for no shift AND no
   reserved gap, which rules out display:none and visibility:hidden both — so
   it is absolutely positioned inside .vu-track (already position:relative for
   the bar) and costs no layout at all. It also lands where a hardware meter
   puts its clip LED: the top of the scale.

   3px, full track height, hard against the right edge. It draws OVER the bar,
   which is correct — when this is lit the bar is pinned there anyway. */
.vu-led { position:absolute; top:0; right:0; bottom:0; width:5px; box-sizing:border-box;
          background:transparent; pointer-events:none; }
/* THE SEPARATOR IS NOT DECORATION. The bar's own gradient ends in --vu-clip at
   100%, so a lamp in that colour would blend into a pinned bar — invisible at
   exactly the moment it lights. A hairline of the track's own background keeps
   it a discrete lamp at any bar width, in every theme, without inventing a
   colour outside the token set. box-sizing keeps the 5px total, so lighting it
   still moves nothing. */
.vu-led.lit { background:var(--vu-clip); border-left:1px solid var(--vu-track); }
/* Only offer the pointer when there is something to reset — a permanently
   clickable meter implies an action that usually does nothing. */
#vu.clipped { cursor:pointer; }

/* ── WORKSPACE ──────────────────────────────────────────── */
#workspace { flex:1; display:flex; overflow:hidden; }

/* ── LEFT PANEL ─────────────────────────────────────────── */
#left-panel {
  width: var(--panel-w-l);
  flex-shrink: 0;
  border-right: 1px solid var(--border);
  background: var(--bg-panel);
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.panel-header {
  background: var(--bg-header);
  color: var(--header-ink);
  font-size: 9px;
  letter-spacing: 2px;
  padding: var(--d-panelhead-pad);
  border-bottom: 1px solid var(--border);
  border-top: 1px solid var(--seam);
  flex-shrink: 0;
}
.panel-body { padding: 4px 6px; overflow-y: auto; }

/* ── PANEL COLLAPSE RAILS ───────────────────────────────────
   Thin always-visible strips beside each side panel: click to fold the
   panel away for more pattern room, click again to restore (Phil
   2026-07-23). Collapsing sets width:0 on the panel and hides its
   splitter, so nothing else in the flex row needs to know. */
.panel-collapse {
  flex: 0 0 11px; width: 11px; align-self: stretch;
  background: var(--bg-header); color: var(--header-ink);
  border: none; border-left: 1px solid var(--seam); border-right: 1px solid var(--seam);
  font-size: 10px; line-height: 1; cursor: pointer; padding: 0;
  font-family: inherit;
}
.panel-collapse:hover { background: var(--accent-ground-soft); color: var(--accent-hi); }
#left-panel.collapsed, #right-panel.collapsed { width: 0; border-width: 0; }
#left-panel.collapsed + #left-collapse,
#right-panel.collapsed { /* keep the rail itself visible */ }
.splitter-v.hidden { display: none; }
.panel-section { border-bottom: 1px solid var(--border); display:flex; flex-direction:column; }

/* Song and block lists share the vertical space, with a draggable
   splitter between them. Default split: 35% / 65% via flex-basis on
   #song-list. The splitter's mousedown handler in app.js adjusts
   #song-list's height in pixels (overriding flex-basis) — the
   #block-list stays flex:1 to fill remaining space. */
/* THE HOST CONTRACT. Anything a list needs in order to WORK — scrolling, no
   inherited panel padding, a floor height, and the container context the narrow
   -panel rule queries — belongs to the role class, so a floating window gets it
   by wearing the same class. Only the docked LAYOUT basis stays on the ids:
   flex:0 1 35% is meaningful solely inside #left-panel's column and would fight
   whatever layout a window uses. */
/* NO scrollbar-gutter. It was added on 2026-08-12 to stop the content width
   stepping by a scrollbar mid-animation, and REVERTED the same day: Phil,
   "I don't like the permanent gutter. Let's revert that until we can find
   something better." Reserving 11px on every list left row borders, hover
   fills and waveforms stopping short of the panel edge on every module that
   never scrolls, which is most of them. The jitter it fixed is real and is
   still open — see the note on ::-webkit-scrollbar for why it bites here. */
.med-list   { overflow-y:auto; padding:0; min-height:24px; }
#song-list  { flex:0 1 35%; }
#block-list { flex:1 1 0; }

/* Horizontal splitter — 5px tall draggable bar between two stacked
   panels. Hover gives a subtle amber tint to advertise interactivity. */
.splitter-h {
  height: 5px;
  background: var(--bg-rail);
  border-top: 1px solid var(--seam-dark);
  border-bottom: 1px solid var(--seam-dark);
  cursor: ns-resize;
  flex-shrink: 0;
  user-select: none;
}
.splitter-h:hover    { background: var(--bg-rail-hover); }
.splitter-h.dragging { background: var(--accent-dim); }

/* Vertical splitter — 5px wide draggable bar between side-by-side
   panels (left↔centre and centre↔right). */
.splitter-v {
  width: 5px;
  background: var(--bg-rail);
  border-left: 1px solid var(--seam-dark);
  border-right: 1px solid var(--seam-dark);
  cursor: ew-resize;
  flex-shrink: 0;
  user-select: none;
}
.splitter-v:hover    { background: var(--bg-rail-hover); }
.splitter-v.dragging { background: var(--accent-dim); }

.seq-row { display:flex; align-items:center; gap:5px; padding:2px 7px 2px 3px; border-bottom:1px solid var(--seam-soft); cursor:default; font-size:11px; }
.seq-row:hover { background:var(--bg-hover); }
.seq-row.playing { background:rgb(var(--accent-rgb) / 0.12); color:var(--accent-ink); font-weight:600; }
/* THE INDEX NEVER CHANGES WEIGHT. `.playing` and `.sel` bold the whole row to
   emphasise its NAME, and the number inherited that — measured identical in size,
   width and position, differing only 400 → 600, which at 10px on a mono face
   whose bold is synthesised reads as the number shrinking (Phil 2026-08-09).
   The index is chrome: it says which row this is, not what state it is in. */
.seq-num  { color:var(--text-dim); min-width:2ch; text-align:right; font-size:10px;
            font-weight:400; }
.seq-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* Right-aligned secondary info — block index in the sequence list,
   row count in the block list. Same visual treatment as the instrument-
   list type tag. */
.seq-tag  { color:var(--text-dim); font-size:9px; letter-spacing:0.5px; flex-shrink:0; }

/* NARROW PANEL: the NAME wins.
   Phil 2026-08-08 — "when the panel is scaled small enough, we could remove the
   right aligned block numbers from sequence list, and the row counts from block
   list, to leave more space for the names."

   A CONTAINER query, not a media query, and that is the point: the side panel is
   drag-resizable and its width has nothing to do with the viewport's. This reacts
   to the splitter itself, with no JS and no resize listener — the same reason the
   character-count tooltip threshold it replaces had to go. A fixed number cannot
   answer a question whose answer the user is dragging.

   150px is where the tag stops being worth its ~30px: below that a name has under
   110px and starts losing whole words rather than a character or two. Nothing
   becomes unreachable — the tag returns on widening, and the full name is on
   hover either way. One number to change.

   THE INSTRUMENT LIST IS IN TOO, at the same 150px (Phil 2026-08-09), but it
   needs its own selector because it shares none of this markup: its rows are
   `.inr` with `.inn` / `.inname` / `.intype`, so the thing to hide is `.intype`
   rather than `.seq-tag`.
   150px is not a coincidence there — it is MIN_PANEL_W in
   js/instrument-waveforms.js, the width below which that file already drops the
   waveform backdrops. Phil asked for the type tag to go "at the same time as the
   waveform", and the same number is what makes that true rather than
   approximately true. If one moves, move both.
   The containment this declares on #i-list does NOT disturb the waveforms: the
   `.inr-wave` overlays are `inset:0` inside `.inr`, which is position:relative
   and is therefore already their containing block. */
[data-list="song"], [data-list="block"], [data-list="instr"] { container-type:inline-size; }
@container (max-width: 150px) {
  .seq-tag { display:none; }
  .intype  { display:none; }
  /* THE SIZE CHIP TOO, at the same width. It is `flex: none` and so cannot
     ellipsise; below this it was the item squeezing the name to nothing.
     Same threshold as the tag and the waveform on purpose — one width at
     which the row sheds its extras, rather than three. */
  .insize  { display:none; }
}


/* A BLOCK THE PLAYING SEQUENCE NEVER REACHES. Quiet on purpose — unused blocks
   are normal while you are working (sketches, alternate takes, a chorus you have
   not placed yet), so this is information rather than a warning. The dot sits in
   the number column; only the NUMBER dims, because dimming the name would make
   an unused block harder to read at exactly the moment you are looking for it. */
/* ONE RULE FOR BOTH LISTS. The instrument rows already carried .unused — the
   code that adds it is a deliberate sibling of the block one — but nothing
   styled it, so only blocks got the amber number (Phil, 2026-08-12: "can we
   make the styling for unused instruments match that of blocks"). */
/* --accent-ink, not --accent-dim: "can we use the slightly brighter amber as it's
   too hard to see against the dark grey" (Phil, 2026-08-12). An EMPTY slot
   joins them — it is unused in the plain sense, and a full-brightness number
   on the one row with nothing in it was the loudest thing in the list. No dot
   for those: the bullet means "the song never plays this", which for an empty
   slot is noise rather than news. */
.seq-row.unused .seq-num,
.inr.unused .inn,
.inr.islot-empty .inn { color:var(--accent-ink); }

/* THE DOT IS A PSEUDO-ELEMENT, not a character spliced into the number text.
   Both lists used to prefix a '·' into textContent, so it inherited the
   number's 10px and its colour and could not be sized or brightened on its own
   — "basically a 1 pixel dot, almost invisible". As ::before it takes its own
   size and colour, and the digits keep their tabular right-alignment because
   the marker is no longer part of the string being aligned.

   A BULLET rather than a middle dot: at this size a middle dot is a couple of
   pixels of antialiasing. Brighter than the number beside it (--accent-ink against
   --accent-dim) so the marker reads first and the digits stay the label.
   line-height:0 stops the taller glyph growing the row. */
/* A RESERVED SLOT FOR THE MARKER, on every row whether or not it has one, so
   the digits never move — and INSIDE the number's own box rather than hanging
   off its left edge. Hanging outside put the bullet almost on top of the
   selected row's amber bar in the docked panel, and in the TABLE it fell
   outside the grid cell entirely, which is why it looked "very small and
   strangely positioned" there (Phil, 2026-08-12). */
/* THE MARKER SLOT, as a token. It is added to the ROW's own left padding,
   not instead of it, so a hardcoded 10px here pushed the digits to 17px from
   the edge — Phil: "a lot of padding to the left". The row now gives up most
   of its own left padding to pay for it, and .inplay below is positioned from
   the same token so the audition glyph lands ON the number instead of well
   to its left. The bullet measures 7.03px, so 8 is enough. */
.seq-row .seq-num, .inr .inn { position: relative; padding-left: var(--marker-slot); }
.seq-row.unused .seq-num::before, .inr.unused .inn::before {
  content: '\2022';
  color: var(--accent-hi);
  font-size: 13px;
  line-height: 0;
  /* OUT OF FLOW, so the DIGITS STAY PUT. In flow the bullet pushed the number
     right, so unused rows sat out of line with every other row — "can we find a
     way to make unused instrument numbers aligned with the rest of them while
     also keeping the dot?". Hanging it to the left of the number's box costs
     the digits nothing, and the row's padding is where it lands. */
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  vertical-align: middle;
}

/* THE PICKED ROW. Distinct from `.playing` on purpose, and both can be on one
   row at once: `.playing` is where playback or the pattern view IS, `.sel` is
   what you have PICKED and what the +/-/reorder buttons will act on. One click
   picks, two go there (Phil 2026-08-09, matching Windows MED).

   A LEFT BAR rather than a fill, so it reads as different in kind from the
   amber `.playing` tint rather than competing with it — and so a row that is
   both still shows both. `.sel` is used elsewhere in this file for the
   find-replace field and menu rows, hence the .seq-row scope. */
.seq-row.sel { box-shadow:inset 2px 0 0 var(--accent-ink); }
/* BRIGHT AMBER AND BOLD, not white. The first cut used --text-hi, so picking a
   row turned its name white and un-picking it turned it back to amber — a
   colour SWAP, which reads as "different thing" rather than "same thing,
   emphasised" (Phil 2026-08-09: "it would be better and more readable if the
   text goes bold bright amber"). Weight is the signal; the amber stays put. */
.seq-row.sel .seq-name { color:var(--accent-hi); font-weight:600; }

/* THE LEAD of a multi-row pick. With several rows selected, one of them is
   still the one an INSERT or a MOVE will use, and a pick that could not say
   which would be guessing. Only drawn when the pick is bigger than one — a
   single picked row is trivially its own lead and a second marker on it would
   be noise. */
.seq-row.sel.lead, .inr.sel.lead { box-shadow:inset 3px 0 0 var(--accent-hi); }

/* THE THREE LISTS AGREE. Phil 2026-08-09: "Selections on the instrument list
   look different to selections on block/sequence list — instruments get amber
   background colour. Everything should be consistent."
   So the instrument list drops its background fill and takes the same treatment
   as the other two: a left bar for the pick, bold bright amber for the name.
   `.inr.selected` — the list's own older class, still written by
   markInstrSelection — is neutralised here rather than removed, because it is
   what markInstrSelection still writes and what the preview path keys off, so
   it keeps its amber TEXT and loses only the fill. Its own rule further down
   the file is where that happens — a neutraliser here lost to it, same
   specificity, later wins. */
.inr.sel { box-shadow:inset 2px 0 0 var(--accent-ink); }
/* The detail line follows the NAME, not the row. On a picked row the name is
   lifted to --accent-hi by this rule while everything else inherits the row's
   --accent-ink, so without the second selector the two lines sat a shade apart —
   which is the thing Phil asked to be equal. Not the font-weight: bolding a
   line of numbers reads as emphasis it has not earned. */
.inr.sel .inname { color:var(--accent-hi); font-weight:600; }
.inr.sel .indet  { color:var(--accent-hi); }

/* THE GHOST ROW — "+ position" / "+ new block" at the end of each list.
   Dashed and dim so it reads as an invitation rather than an entry: it must not
   be mistaken for the last block at a glance, which is the whole risk of putting
   a control inside a list. It brightens on hover like any other row, because it
   IS clickable.

   No .seq-num and no .seq-tag — the columns beside a real row's name are its
   NUMBER and its size, and the ghost has neither. Leaving them empty would line
   the text up under the names above it and make it look like one of them. */
.seq-row.seq-add {
  border-bottom:none; border-top:1px dashed var(--border);
  color:var(--text-dim); font-style:italic; cursor:pointer;
  opacity:0.7;
}
.seq-row.seq-add:hover { opacity:1; color:var(--accent-ink); background:var(--accent-wash); }
/* ── DRAGGING A ROW (task #28) ───────────────────────────────────────────
   The insertion line is one FIXED element in the body, not a child of the list:
   the lists are rebuilt on every model change and scroll independently, so a
   child would have to be re-created after each rebuild and would be clipped by
   the scroller at exactly the two places it matters most — the top and bottom
   edges, where you drop to move something to an end.

   `.no` is the same line in the refusal colour, used when the drop would break a
   ceiling. It is drawn rather than hidden on purpose: a line that vanishes reads
   as "the app lost the drag", and a red one reads as "not there". */
#lst-drop {
  position: fixed; z-index: 1200; height: 2px; display: none;
  pointer-events: none; background: var(--accent-fill);
  box-shadow:0 0 4px var(--accent-fill);
}
#lst-drop.no { background: var(--alert); box-shadow: 0 0 4px var(--alert); }
/* The rows being carried stay legible but clearly lifted. Dimming the SOURCE
   rather than drawing a floating copy of them keeps this to two CSS rules and
   no per-frame layout: the insertion line already says where they will land,
   which is the thing you actually need to see. */
.seq-row.dragging, .inr.dragging { opacity: 0.4; }
/* While a drag runs, nothing in a list should look clickable or select. */
body.lst-dragging { cursor: grabbing; }
body.lst-dragging .med-list { user-select: none; }
body.lst-dragging .seq-row:hover, body.lst-dragging .inr:hover { background: none; }

/* FULL — the list has hit its ceiling (63 instruments, 255 sequence positions,
   the parser's cell budget for blocks). Greyed and inert, with the reason as its
   tooltip, and the hover rule turned back off: a row that lights up under the
   pointer and then does nothing is worse than one that never invited the click.
   `cursor:default`, not `not-allowed` — nothing is being refused here, there is
   simply nothing left to add. */
.seq-row.seq-add.full,
.seq-row.seq-add.full:hover {
  opacity:0.32; color:var(--text-dim); background:none; cursor:default;
}

/* The per-row audition control. Small and quiet: the row's JOB is now
   selection, and this is the secondary action beside it. Kept to a glyph
   because the docked row is ~118px wide and there is no room for a word. */
/* BIGGER AND BRIGHTER THAN "small and quiet". Being the secondary action is a
   reason to be visually subordinate to the NAME, not a reason to be unreadable
   — and it sits over a waveform, where --text-dim at 10px disappeared entirely
   (Phil, 2026-08-12). Still smaller and cooler than the name; just legible. */
.inplay {
  margin-left:auto; flex-shrink:0; padding:0 3px; cursor:pointer;
  color:var(--text); font-size:13px; line-height:1; position:relative; z-index:1;
}
.inplay:hover { color:var(--accent-hi); }

/* IN THE DOCKED PANEL IT SITS ON THE NUMBER, and only while the pointer is on
   the row (Phil, 2026-08-12: "move the audition button to where the instrument
   number is. In the docked view, this would appear as you hover over it").
   That buys back its whole column in the narrowest host, where it was competing
   with the name.
   POSITIONED, NOT REORDERED IN FLOW: swapping the two would resize the row's
   first item on hover and shove the name sideways. Same left offset and the
   same 2ch box as .inn, so it lands exactly on it. Scoped away from the table,
   where the two have a column each. */
.med-list[data-list="instr"]:not(.itable) .inplay {
  position:absolute; left:calc(3px + var(--marker-slot)); top:2px; margin-left:0;
  min-width:2ch; padding:0; text-align:center;
  opacity:0; pointer-events:none; transition:opacity .1s ease;
}
.med-list[data-list="instr"]:not(.itable) .inn { transition:opacity .1s ease; }
.med-list[data-list="instr"]:not(.itable) .inr:hover .inplay { opacity:1; pointer-events:auto; }
/* While an audition sounds on the row the glyph is a stop square, and it stays
   visible without the hover (js/instrument-playheads.js marks the row). */
.med-list[data-list="instr"]:not(.itable) .inr.sounding .inplay { opacity:1; pointer-events:auto; color:var(--play-lit); }
.med-list[data-list="instr"]:not(.itable) .inr.sounding .inn { color:transparent; text-shadow:none; }
.itable .inr.sounding .inplay { color:var(--play-lit); }
/* Hover swaps the NUMBER for the ▸, but it was fading the bullet out too —
   so an unused instrument stopped being marked exactly while you pointed at
   it. The digits go; the marker stays. */
/* ...and the SHADOW has to go with the colour. A text-shadow is painted from
   the glyph's alpha INDEPENDENTLY of `color`, so `color:transparent` hides the
   digits and leaves their shadow behind -- a smudge under the play triangle.
   Invisible in the dark themes, where the shadow is black on a dark row;
   obvious in a light one, where it is white. Phil found it in Magique.
   The bullet is a ::before on the same element and keeps its own shadow,
   because that one really is lifting a marker off the waveform behind it. */
.med-list[data-list="instr"]:not(.itable) .inr:hover .inn { color:transparent; text-shadow:none; }
.med-list[data-list="instr"]:not(.itable) .inr:hover .inn::before {
  text-shadow:0 1px 2px var(--shadow-strong), 0 0 3px var(--shadow-mid); }
.med-list[data-list="instr"]:not(.itable) .inr.unused:hover .inn::before,
.med-list[data-list="instr"]:not(.itable) .inr.islot-empty:hover .inn::before {
  color:var(--accent-hi); }

/* ── LIST WINDOWS + THEIR CONTROL STRIPS (task #6) ───────────────
   Phil 2026-08-08: "compact strip docked, full float". The strip is the same
   component in both places; what differs is what sits NEXT to it — a rename
   field in the window, nothing in the panel, because at 118px there is no room
   for one. So the strip itself needs no docked/floating variant beyond size. */
.lw-strip {
  display:flex; gap:3px; padding:3px 4px;
  border-top:1px solid var(--border); background:var(--bg-inset);
  flex-shrink:0;
}
.lw-strip .btn { padding:2px 0; flex:1 1 0; min-width:0; font-size:11px; line-height:1.4; }

/* DOCKED: four buttons across ~118px, so they share the width evenly and carry
   no text beyond the glyph. flex-shrink:0 above matters here — the strip sits
   in #left-panel's column flex beside two scrolling lists, and without it the
   lists would push it to zero height rather than give up a row of their own. */
.lw-strip-docked { padding:2px 3px; }
.lw-strip-docked .btn { font-size:10px; }

/* FLOATING: the window is sized to be worked in rather than glanced at. The
   list is the part that grows; the strip and the name row keep their height. */
/* the width lives with the other per-dialog widths — see .sd-dialog.lw-dialog */
.lw-body   { display:flex; flex-direction:column; gap:0; padding:0; }
/* A FIXED HEIGHT, not a range. With `flex:1 1 auto; min-height:180px;
   max-height:52vh` the list grew and shrank with its CONTENT, so adding or
   deleting a row resized the whole window and the controls beneath it jumped
   under the pointer (Phil 2026-08-09). A list is the one thing in a window that
   should absorb a change in row count by SCROLLING; everything below it should
   never move.
   min() rather than a bare px so a short screen still gets a usable window. */
.lw-list   { flex:0 0 auto; height:min(360px, calc(52vh / var(--ui-scale))); border-bottom:1px solid var(--border); }
.lw-name   { padding:5px 6px; border-top:1px solid var(--border); gap:5px; }
.lw-name input { flex:1 1 auto; min-width:0; }
.lw-body .set-hint { padding:4px 6px; }

/* ── CENTRE ─────────────────────────────────────────────── */
#centre { flex:1; display:flex; flex-direction:column; overflow:hidden; position:relative; }

/* ── RIGHT PANEL ────────────────────────────────────────── */
#right-panel {
  width: var(--panel-w-r);
  flex-shrink: 0;
  border-left: 1px solid var(--border);
  background: var(--bg-panel);
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
/* --d-infogrid-pad, NOT --d-panelbody-pad. The first attempt tokenised
   .panel-body, which was dead on arrival: `.med-list { padding:0 }` comes
   later and wins for the three lists that rule was aimed at, so the token
   changed nothing anywhere. Measured before believing it. This is the panel
   inset that actually paints. */
.info-grid { display:grid; grid-template-columns:auto 1fr; gap:2px 8px; font-size:11px; padding:var(--d-infogrid-pad); }
.ik { color:var(--text-dim); white-space:nowrap; }
.iv { color:var(--accent-ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }

/* ── Instrument Properties (task #23) ─────────────────────────────────────
   A READOUT sitting beside its field: the hex volume next to the volume box,
   and the frequency the default pitch works out to next to the note. Both are
   readouts in BOTH reference programs — Windows MED draws "Hex: 7F" and
   "Default Freq.: B-2 | 15360", Amiga packs the same pair into its slider —
   and neither accepts typing. Dimmed and tabular so it reads as a fact rather
   than as an empty box someone should fill in. */
.ipr-ro { color:var(--text-dim); font-size:11px; letter-spacing:0.5px;
  font-variant-numeric:tabular-nums; white-space:nowrap; }
/* A row whose field THIS module's format cannot store — Bank and Output port
   on an Amiga-flavour module, which js/writer.js emits only in the 24-byte
   instrExt entry. js/instrument-props.js has already disabled the input; this
   dims the label with it, so the row reads as unavailable at a glance rather
   than as an empty field somebody forgot to fill in. */
.ipr-na { opacity:0.55; }

/* ── The instrument library ──
   A TREE now, not a flat list. The comment here used to argue the opposite —
   "a flat list, because a pool is browsed by what a sound IS rather than by
   where it sits" — and that was right for a few hundred files and wrong for
   Phil's 23,000, where the folder a sample sits in IS most of what is known
   about it. Search covers the by-what-it-is case and flattens the tree while
   it is live.

   THE LIST IS VIRTUALISED (js/library-ui.js): a fixed pool of absolutely
   positioned rows inside #lib-rows, recycled as the window scrolls, so the
   document holds ~(viewport + 24) rows however large the library is. Two
   things below are load-bearing for that:

     · #lib-rows must be the positioning parent. `.lib-list` is position:
       static, so an absolutely positioned row would otherwise resolve against
       `.sd-overlay` — which is position:fixed — and land relative to the
       VIEWPORT rather than the list.
     · EVERY ROW MUST BE THE SAME HEIGHT, including folder rows. Row height
       is not declared anywhere; it comes from `.lib-kind`, whose 9px text plus
       1px padding and 1px border is taller than every sibling. So the folder
       chevron reuses `.lib-kind` and only hides its border with a transparent
       colour — removing the border would make folder rows shorter than file
       rows and put the whole window out of step. */
.lib-bar { display:flex; align-items:center; gap:6px; margin-bottom:8px; min-width:0; }
/* The bar's buttons keep their one-line width. Without this the Category
   select added on 2026-09-04 took its room out of Add Folder… / Add Files…,
   which broke onto two lines at the window's default width; the search box
   is the one thing meant to give way (see .lib-search below). */
.lib-bar .btn { flex:0 0 auto; white-space:nowrap; }
.lib-spacer { flex:1 1 auto; }
.lib-filter-lbl { color:var(--text-dim); font-size:10px; letter-spacing:1px;
  text-transform:uppercase; }
/* Narrower and taller than the default dialog (Phil, 2026-09-05): a list of
   files wants rows, not width. */
.sd-dialog.lib-dialog { width:640px; }
.lib-list { height:400px; overflow-y:auto; overflow-x:hidden;
  background:var(--bg-inset); border:1px solid var(--border-control);
  border-radius:var(--radius-control); }
.lib-empty { padding:14px; color:var(--text-dim); font-size:11px; }
/* fold · name · waveform · folder (search only) · meta · format tag.

   THE TAG IS ON THE RIGHT (Phil, 2026-09-06: "the file format label in the
   library should be positioned to the right, it causes too much visual
   clutter on the left"), and a 14px fold column took its place.

   NOT ONE `auto` TRACK. The meta column used to be `auto`, so its CONTENT
   decided the width of the two 1fr tracks beside it — which meant a row
   carrying "in this project (slots 03, 06)" put its waveform somewhere else
   than the row above (Phil: "The position of the waveform preview should be
   aligned, it moves around depending on what info is being shown"). Every
   track here resolves from the CONTAINER, so every row has identical geometry
   and the waveform column is a straight line down the list. Overflow is
   ellipsis inside the cell, which is what minmax(0,…) makes possible.

   EVERY COLUMN IS NAMED EXPLICITLY below rather than auto-placed, because the
   play glyph is a seventh child that has to land ON the waveform. */
.lib-row { display:grid; grid-template-columns:14px minmax(0,1.4fr) 64px minmax(0,0.8fr) minmax(0,1.5fr) 48px;
  gap:6px; align-items:center; padding:3px 8px; cursor:default; font-size:11px; }
.lib-row > .lib-fold { grid-area:1/1; }
.lib-row > .lib-name { grid-area:1/2; }
.lib-row > .lib-wave { grid-area:1/3; }
.lib-row > .lib-dir  { grid-area:1/4; }
.lib-row > .lib-meta { grid-area:1/5; }
.lib-row > .lib-kind { grid-area:1/6; }
.lib-row > .lib-play { grid-area:1/3; }
.lib-wave { width:64px; height:14px; display:block; }
/* THE FOLD GLYPH: + and −, never a triangle. Phil, 2026-09-06: "The carets
   used for folder heirarchy are too similar to the audition play button". It
   keeps a fixed box so a row with no fold is exactly as tall. */
.lib-fold { font-size:11px; line-height:10px; min-height:10px; text-align:center;
  color:var(--text-dim); cursor:pointer; font-variant-numeric:tabular-nums; }
.lib-row:hover .lib-fold { color:var(--text-hi); }
.lib-row.lib-modrow .lib-name { color:var(--text-hi); }
.lib-searchbar .lib-search { flex:1 1 auto; }
/* The module view keeps the search row and hides the two the index needs. */
#lib-local-pane.lib-moduleview .lib-bar:not(.lib-searchbar), #lib-local-pane.lib-moduleview .lib-banners { display:none; }
/* THE SLICE READOUT TAKES A LINE OF ITS OWN, and that is what stops it
   moving the controls beside it. Phil, 2026-09-06: "The 3 dropdowns (bit
   depth, mono/stereo, clip) still get reflowed by the slice readout."
   #ipr-play-row is a flex line; the span was sized to its content, and its
   text swings between "no slice - the whole sample plays" and
   "slice: 2000 of 45678 frames", so every set or clear pushed the three
   selects along. flex-basis 100% forces the line break after the controls
   (the span is last in the row now), which fixes their positions whatever
   the text says and keeps the sentence whole - the alternative, a fixed
   width with an ellipsis, would have had to cut it at about half. */
/* AN ID IN THE SELECTOR, and that is not decoration: .ipr-dialog .ipr-ro
   (0,2,0) and .ipr-dialog .ipr-grid .ipr-ro (0,3,0) both set flex on this
   element and both sit LATER in this file, so a bare .ipr-play-note lost to
   them and the span went back to being sized by its text. Measured with it:
   the note was 195px wide and only left the controls alone because it
   happened not to fit beside them at that dialog width. */
.ipr-dialog #ipr-play-row .ipr-play-note { flex:0 0 100%; }
.ipr-play-note { color:var(--text-dim); font-size:10px; align-self:center; }
.ipr-inline { margin-left:6px; font-size:10px; white-space:nowrap; }
.ipr-rate-note { margin-left:8px; }
/* The Library strip's chosen region (Shift-drag): the part a Load takes. */
.lib-aud-wave.lib-aud-hasregion { outline:1px solid var(--sel-edge); outline-offset:-1px; }

/* The scroll content: full height for an exact scrollbar, and the positioning
   parent for the pooled rows. See the header for why this element has to exist
   rather than the rows being positioned against .lib-list itself. */
#lib-rows { position: relative; width: 100%; }
/* Pooled rows are placed by an inline `top`. `right:0` rather than a width so
   the row still spans the list at any scrollbar width. */
.lib-list .lib-row { position: absolute; left: 0; right: 0; }
/* A pool slot with no row to show. Parked off-screen rather than display:none
   so its box never has to be re-created. */
.lib-list .lib-row.lib-spare { visibility: hidden; }

/* A FOLDER ROW LOOKS LIKE A FOLDER (Phil, 2026-09-06: "it should be easier to
   tell folders and files apart"). Its name is bolder and brighter, and the
   column where a file names its format says FOLDER or SOURCE instead — a
   dashed, dimmer box, so the eye can sort the list at a glance without any
   row changing height. */
.lib-treerow .lib-name { color: var(--text-hi); font-weight:600; }
.lib-treerow.lib-srcfolder .lib-name { color: var(--accent-ink); letter-spacing:0.3px; }
.lib-kind-folder { border-style:dashed; opacity:0.75; }
.lib-treerow:hover .lib-kind-folder { opacity:1; }

/* The banners strip above the scroller. It has no height of its own — it is
   empty most of the time — and only its children carry styling. */
.lib-banners:empty { display: none; }
.lib-banners { border: 1px solid var(--border-control);
  border-bottom: 0; border-radius: var(--radius-control) var(--radius-control) 0 0;
  background: var(--bg-inset); }
.lib-row:hover { background:var(--bg-hover); }
/* THE ROW'S PLAY GLYPH SITS ON THE WAVEFORM. Phil, 2026-09-06: "if we
   generated waveform previews for every file on scanning like I asked for, we
   could put the play button there". It shares the waveform's grid cell rather
   than being positioned by hand, so it is centred on the picture at any width
   and cannot drift out of it. The canvas under it answers the same click, so a
   row whose envelope has not been read yet still has a target of the same size
   in the same place. */
/* THE GLYPH NEVER TAKES THE CLICK — the CANVAS UNDER IT DOES. It is 15px wide
   and sits in the middle of a 64px cell, so with `pointer-events:auto` the
   centre of the waveform behaved differently from its edges: measured
   2026-09-06, a press dead-centre landed on the ROW (selecting) while one
   20px to the left played. One target, the whole cell, and the glyph is
   decoration over it. */
.lib-row .lib-play { justify-self:center; align-self:center; z-index:1;
  color:var(--text); font-size:12px; line-height:10px; min-height:10px;
  text-shadow:0 0 3px var(--bg-inset), 0 0 3px var(--bg-inset);
  opacity:0; pointer-events:none; transition:opacity .1s ease; }
.lib-row.lib-auditionable:hover .lib-play { opacity:1; }
.lib-row.lib-auditionable:hover .lib-wave { opacity:0.45; cursor:pointer; }
.lib-row.lib-auditionable .lib-wave:hover ~ .lib-play { color:var(--accent-hi); }
/* THE AUDITION STRIP. Phil, 2026-09-06: "the audition waveform in the library
   is tiny now that the load & load new buttons are there" — measured at 37px
   of picture in a 640px dialog, because Load, Load new, the timer, the NOTE
   label, the slider and the note readout were all taking their room out of the
   one flexible thing on the row.

   The buttons moved to a COLUMN OF THEIR OWN at the strip's end, stacked, so
   they cost 70px of width instead of 120 and no height at all; the NOTE label
   went (the readout says the note, and the slider carries the tooltip); and the
   timer and readout were trimmed. The strip keeps ONE height in every mode —
   two stacked buttons are shorter than the waveform beside them. */
.lib-audition { display:flex; align-items:center; gap:6px; margin:6px 0 4px; height:36px; }
.lib-aud-play { flex:0 0 auto; min-width:40px; }
/* Load / Load new / Open share one fixed place and show or hide in it. */
.lib-aud-actions { flex:0 0 78px; display:flex; flex-direction:column; justify-content:center;
  gap:2px; height:100%; }
.lib-aud-actions .btn { width:100%; padding-top:0; padding-bottom:0; min-height:15px; line-height:14px; }
.lib-open-btn { display:none; }
.lib-audition.lib-aud-module .lib-open-btn { display:block; }
.lib-audition.lib-aud-module .lib-load-btn, .lib-audition.lib-aud-none .lib-load-btn { display:none; }
.lib-audition.lib-aud-module .lib-aud-play, .lib-audition.lib-aud-none .lib-aud-play { visibility:hidden; }
.lib-aud-info { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  color:var(--text-dim); font-size:11px; }
.lib-aud-info[hidden] { display:none; }
/* The picked file's own line: its date, size and path, one line high whether
   or not anything is picked (js/library-ui.js fileLine). */
.lib-aud-file { height:14px; line-height:14px; margin:-2px 0 4px; font-size:10px; color:var(--text-dim);
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* WHERE IT CAME FROM: the attribution line under the strip, only for a file
   with a provenance record. Two rows - the facts, then the tags - in the dim
   text the strip's info line uses; the licence badge is the one the Online
   pane's rows wear (.lib-lic-badge, shared). */
.lib-attr { display:flex; flex-direction:column; gap:3px; margin:0 0 4px; font-size:11px; color:var(--text-dim); }
.lib-attr[hidden] { display:none; }
.lib-attr-line { display:flex; align-items:center; gap:6px; min-width:0; white-space:nowrap; overflow:hidden; }
.lib-attr-line > span, .lib-attr-line > a { overflow:hidden; text-overflow:ellipsis; }
.lib-attr-line a { color:inherit; }
.lib-attr-line .btn { flex:0 0 auto; }
.lib-attr-state.edited { color:var(--accent-hi); }
.lib-attr-tags { display:flex; flex-wrap:wrap; align-items:center; gap:4px; }
.lib-tag { display:inline-flex; align-items:center; gap:3px; font-size:10px; padding:1px 5px; border-radius:2px;
  background:var(--bg-inset); color:var(--text-dim); }
.lib-tag.src { opacity:.7; }
.lib-tag button { all:unset; cursor:pointer; padding:0 1px; line-height:1; }
.lib-attr-input { width:8em; font-size:10px; }
.lib-aud-time { flex:0 0 auto; min-width:7em; text-align:right; color:var(--text-dim); font-size:10px;
  font-variant-numeric:tabular-nums; }
/* WHAT KEEPS ITS PLACE IS THE TWO ENDS. The play button on the left and the
   actions column on the right never move — those are the controls a hand goes
   to. The note slider, its readout and the timer belong to a sample and are
   taken OUT of the flow for a module or for nothing picked, so the line about
   the module gets the 260px they were holding empty: it used to be cut off at
   "abreed3d - MMD3 · 15 instr · 4 ch…", which is the description Phil asked
   the strip to show (2026-09-06). The play button is only hidden, not removed,
   because removing it WOULD move the left edge. */
.lib-audition.lib-aud-module .lib-aud-note,
.lib-audition.lib-aud-module .lib-aud-notename, .lib-audition.lib-aud-module .lib-aud-time,
.lib-audition.lib-aud-none .lib-aud-note,
.lib-audition.lib-aud-none .lib-aud-notename, .lib-audition.lib-aud-none .lib-aud-time { display:none; }
.lib-audition.lib-aud-module .lib-aud-wave, .lib-audition.lib-aud-none .lib-aud-wave { display:none; }
/* A PLUGIN ROW takes the module layout's INFO LINE — it has no waveform to
   draw and no position to time either — but keeps the play button, the note
   slider and its readout, which is the whole difference: you choose the pitch
   you want to hear it at, exactly as you do for a sample. It keeps Load too,
   because a plugin row loads into a slot like any other; only Open is
   meaningless, so only Open is hidden. */
.lib-audition.lib-aud-wam .lib-aud-wave,
.lib-audition.lib-aud-wam .lib-aud-time,
.lib-audition.lib-aud-wam .lib-open-btn { display:none; }
.lib-audition.lib-aud-wam .lib-load-btn { display:block; }
.lib-audition.lib-aud-wam .lib-aud-play { visibility:visible; }
.lib-audition.lib-aud-wam .lib-aud-note,
.lib-audition.lib-aud-wam .lib-aud-notename { display:inline-block; }
.lib-aud-wave { flex:1 1 auto; min-width:0; height:34px; background:var(--bg-inset); cursor:text;
  border:1px solid var(--border-control); border-radius:var(--radius-control); }
/* WIDTH AS WELL AS THE FLEX BASIS: a range input carries a UA width that wins
   over `flex-basis` alone, and it was 129px where 96 was asked for — a third
   of the waveform, given away silently. */
.lib-aud-note { flex:0 0 90px; width:90px; min-width:0; }
.lib-aud-notename { flex:0 0 8em; color:var(--accent-hi); font-size:11px; white-space:nowrap; overflow:hidden; }
/* THE PICKED ROW, the way the sequence, block and instrument lists draw it
   (see .seq-row.sel): a left bar and a bold bright-amber name, not a fill.
   Phil, 2026-09-05: "hover/selection highlighting doesn't follow the same
   styling as the rest of the app". */
.lib-row.sel { box-shadow:inset 2px 0 0 var(--accent-ink); }
/* The kind tag doubles as the "not yet" signal: a kind that cannot be loaded is
   dimmed and carries its reason in the tooltip, rather than the file being
   hidden. A pool that omits files the user can see in the folder is worse than
   one that explains them. */
/* line-height AND min-height, both explicit: `.lib-kind` is what sets the
   height of every row in a uniform-pitch virtualised list, and since
   2026-09-06 a folder row's tag can be EMPTY. A box with no line box in it is
   zero-high, which would have made folder rows shorter than file rows and put
   the scroll window permanently out of step. */
.lib-kind { font-size:9px; letter-spacing:0.5px; text-transform:uppercase;
  color:var(--text-dim); border:1px solid var(--border-control);
  border-radius:2px; padding:1px 4px; text-align:center; white-space:nowrap;
  line-height:10px; min-height:10px;
  overflow:hidden; text-overflow:ellipsis; min-width:0; }
.lib-kind-soon { opacity:0.5; border-style:dashed; }
.lib-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  color:var(--text-hi); }
.lib-row.sel .lib-name { color:var(--accent-hi); font-weight:600; }
.lib-dir { overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  color:var(--text-dim); font-size:10px; direction:rtl; text-align:left; }
.lib-meta { color:var(--text-dim); font-size:10px; white-space:nowrap;
  font-variant-numeric:tabular-nums; }
.lib-row.sel .lib-meta, .lib-row.sel .lib-dir { color:var(--text-dim); }
/* --alert, the token this project already uses for a refusal, and NOT an
   invented name with a fallback: `var(--unknown, var(--text-dim))` renders
   the fallback forever and nothing ever says so. Two gates catch that —
   colour-tokens and control-uniformity — and both caught this one. */
.lib-bad { color:var(--alert); }
/* THE META COLUMN NOW CARRIES SENTENCES, not just "44100 Hz". It sits in the
   `auto` track opposite two minmax(0,1fr) tracks, so without these three it
   would push the name and folder columns out rather than being clipped — the
   one text column that was missing them. */
.lib-meta { overflow:hidden; text-overflow:ellipsis; }

/* ── Folder sources ───────────────────────────────────────────────────────
   A remembered folder comes back needing permission, and the browser will only
   re-grant it during a click — so the ask is a ROW WITH A BUTTON at the top of
   the list, not a prompt at load. It sits above the entries because "the pool
   is empty" would be a lie when the truth is that it is full and locked. */
.lib-srcbar { margin-top:-4px; }
.lib-srcnote { color:var(--text-dim); font-size:10px; }
/* THE DESCRIBE PASS'S PROGRESS LINE (Phil, 2026-09-06). It lives in the
   source bar between Show and the source note, and it is HIDDEN when idle
   rather than moved: Pause and Stop keep their place and grey out, which is
   the same rule Rescan and Forget follow. Fixed width, so a count going from
   9 to 1,204 cannot reflow the bar. */
.lib-progress { display:flex; align-items:center; gap:6px; flex:0 1 auto; min-width:0; }
/* The search box gives way to it rather than the other way round: the box is
   already the widest thing in the row and the line is at most a sentence.
   MIN-WIDTH 0, NOT 120px (2026-09-12). A flex item's default min-width:auto
   is its CONTENT's width, and for an <input> that is its size attribute, not
   its text - measured at 404px here, which laid the row out 92px wider than
   the dialog: a horizontal scrollbar, Stop clipped off the right, and the box
   truncated. 0 is what lets it actually give way, and it is what the rule
   below (.lib-bar .lib-search) already said before this one overrode it. */
.lib-bar.lib-searchbar .lib-search { min-width:0; }
.lib-progress-track { width:70px; height:5px; flex:none; background:var(--bg-inset); border:1px solid var(--seam); border-radius:3px; overflow:hidden; }
.lib-progress-fill { display:block; height:100%; width:0; background:var(--accent-hi); transition:width .2s linear; }
.lib-progress-text { color:var(--text-dim); font-size:10px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
  /* 60ch is wider than this dialog: cap it against the ROW instead, so a long
     path ellipses rather than widening the bar (2026-09-12). */
  max-width:min(60ch, 28vw); min-width:0; }
/* Paused reads as paused: the bar stops being the accent colour. */
.lib-progress-paused .lib-progress-fill { background:var(--text-dim); transition:none; }
/* The same line in the app status bar. It shares .status-right's row, so it
   is clamped rather than allowed to push the keys hint off the end. */
#status-lib { color:var(--accent-ink); max-width:46ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.lib-srcrow { display:flex; align-items:center; gap:8px; padding:5px 8px;
  font-size:11px; background:var(--bg-hover);
  border-bottom:1px solid var(--border-control); }
.lib-srcrow .lib-name { flex:0 1 auto; }
.lib-srcrow .lib-meta { flex:1 1 auto; white-space:normal; }
/* Locked and missing are DIFFERENT things and must not read the same. A locked
   row is intact and one click from working, so it only dims. A missing row is
   a file that is not there, so it also strikes through — the distinction the
   whole permission-versus-reachability split exists to preserve. */
.lib-row.lib-locked .lib-name { opacity:0.65; }
.lib-row.lib-gone .lib-name { opacity:0.65; text-decoration:line-through; }

/* ── The HighLife body ────────────────────────────────────────────────────
   READ-ONLY by design: js/writer.js carries a HighLife instrument byte-for-byte
   rather than re-encoding it, so this panel shows and does not offer. The zone
   list is a five-column grid because "which keys does this cover" is the first
   question and a keyboard map answers it more prettily but less directly. */
.hl-facts { display:grid; grid-template-columns:auto 1fr; gap:4px 10px;
  align-items:baseline; }
.hl-zonehead, .hl-zone { display:grid;
  grid-template-columns:minmax(0,1fr) 92px 52px 92px 76px; gap:8px;
  align-items:center; padding:2px 8px; font-size:11px; }
.hl-zonehead { color:var(--text-dim); font-size:10px; letter-spacing:1px;
  text-transform:uppercase; border-bottom:1px solid var(--border-control); }
/* 118px shows five zones and scrolls the rest. The panel is the tallest of the
   four and the dialog is tabbed BECAUSE of height (the head plus every body
   stacked measured 887 px against a 415 px viewport), so the zone list is the
   part that gives — a bank can have dozens of zones and no height would ever
   be "enough". */
.hl-zones { max-height:118px; overflow-y:auto; overflow-x:hidden;
  background:var(--bg-inset); border:1px solid var(--border-control);
  border-radius:var(--radius-control); }
.hl-zone { cursor:default; }
.hl-zone:hover { background:var(--bg-hover); }
.hl-zone.sel { background:var(--accent-fill); color:var(--on-accent); }
.hl-zname { overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  color:var(--text-hi); }
.hl-zone.sel .hl-zname { color:var(--on-accent); }
.hl-zkeys, .hl-zroot, .hl-zrate, .hl-zloop { color:var(--text-dim);
  font-variant-numeric:tabular-nums; white-space:nowrap; }
.hl-zone.sel .hl-zkeys, .hl-zone.sel .hl-zroot,
.hl-zone.sel .hl-zrate, .hl-zone.sel .hl-zloop { color:var(--on-accent); opacity:0.85; }
.hl-canvas { display:block; width:100%; height:70px; margin-top:8px;
  background:var(--bg-inset); border:1px solid var(--border-control); }

/* ── The synth body ───────────────────────────────────────────────────────
   Two sequences side by side, because a MED synth IS two programs running
   against each other — showing them apart would hide the only relationship
   that matters (and JWS / JVS jump between them). */
.syn-speeds { display:flex; align-items:center; gap:6px; margin-bottom:8px; }
.syn-speeds label { color:var(--text-dim); font-size:10px; letter-spacing:1px;
  text-transform:uppercase; }
.syn-speeds input[type="number"] { width:52px; }
.syn-spacer { flex:1 1 auto; }
.syn-seqs { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.syn-seq-head { color:var(--text-dim); font-size:10px; letter-spacing:1px;
  text-transform:uppercase; margin-bottom:3px; }
.syn-rows { height:150px; overflow-y:auto; overflow-x:hidden;
  background:var(--bg-inset); border:1px solid var(--border-control);
  border-radius:var(--radius-control); outline:none; }
.syn-rows:focus-visible { border-color:var(--accent-dim); }
.syn-row { display:grid; grid-template-columns:30px 24px 1fr; gap:6px;
  padding:1px 6px; font-size:11px; font-variant-numeric:tabular-nums;
  cursor:default; }
.syn-row:hover { background:var(--bg-hover); }
.syn-ln { color:var(--text-dim); }
.syn-by { color:var(--text-hi); }
/* WHAT THE WALK SAYS EACH BYTE IS — not what it looks like on its own. A
   command's argument is dimmed toward the value colour because it is data, and
   an undefined opcode is alerted because MED would run it as an end. */
.syn-cmd .syn-tx { color:var(--accent-ink); }
.syn-arg .syn-tx, .syn-arg .syn-by { color:var(--text-dim); }
.syn-bad .syn-tx, .syn-bad .syn-by { color:var(--alert); }
/* Everything after END or HLT is unreachable. MED draws those bytes exactly
   like any other, which is why "my sequence stops halfway" has no visible cause
   there; here the tail simply recedes. */
.syn-unreached { opacity:0.35; }
.syn-row.sel { background:var(--accent-fill); }
.syn-row.sel .syn-ln, .syn-row.sel .syn-by, .syn-row.sel .syn-tx { color:var(--on-accent); }
/* The live program counter while Preview runs. Deliberately a different
   channel from selection — you are watching one row and pointing at another. */
.syn-row.running { box-shadow:inset 2px 0 0 var(--accent-hi); }
.syn-row.running .syn-ln { color:var(--accent-hi); }
.syn-entry { margin-top:8px; }
.syn-entry input[type="text"] { width:46px; text-transform:uppercase; }
.syn-waves { margin-top:8px; }
.syn-wave-info { display:flex; align-items:center; gap:6px; margin-bottom:4px; }
.syn-wave-info label { color:var(--text-dim); font-size:10px; letter-spacing:1px;
  text-transform:uppercase; }
.syn-canvas { display:block; width:100%; height:78px; background:var(--bg-inset);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
  cursor:crosshair; touch-action:none; }
.syn-ops { display:flex; align-items:center; gap:4px; margin-top:6px; }

/* ── The sample editor ────────────────────────────────────────────────────
   One canvas, because a sample is 64 kB of numbers and the only way to see it
   is as a shape — and the only way to say "this part" is to drag across it. */
.smp-editor { margin-top:10px; padding-top:8px; border-top:1px solid var(--border-control); }
/* IT WRAPS RATHER THAN CLIPS. Phil, 2026-08-30: "it would be helpful if
   selections and ranges were visible in hex and decimal, as that can be used
   for commands like sample offset." Both bases plus the cmd 19 hint is a long
   line - 354px of text in 283px of row at --ui-scale 0.75 - and an ellipsis
   falls on the END, which is exactly where the hex and the hint are. So the
   two readouts sit side by side while they fit and drop to a second line when
   they do not, instead of quietly truncating the numbers they exist to show. */
/* ONE LINE, ALWAYS - Phil, 2026-09-02: "the info row above the waveform view
   can wrap and cause the UI height to shift." The left readout keeps its
   width; the right one takes what is left and ellipsises at its END, where the
   hex and the cmd-19 hint are - and both are repeated in its tooltip, so
   nothing is lost, only moved off the line. The bar reserves its height. */
.smp-bar { display:flex; align-items:center; gap:6px; margin-bottom:4px;
  flex-wrap:nowrap; min-height:14px; }
/* The left readout may ellipsise too (its full text is in its tooltip):
   Phil, 2026-09-05, "the info text truncates at small window sizes" - it
   was being cut by the bar rather than shortened. */
/* AND THE ROW MUST NOT RE-LAY ITSELF OUT WHEN THE LEFT READOUT GROWS.
   Phil, 2026-09-06, on setting a slice: "if a play region is selected, the
   readout can make the UI shift around". It could: the info span was sized to
   its CONTENT (flex:0 1 auto), so adding "· slice 1000-3000 (2000)" to it ate
   the spacer and dragged the right-hand readout left. The info span takes the
   free space instead (flex:1 1 0) and ellipsises inside it, so text arriving
   or leaving moves nothing at all; the selection readout keeps its own place
   at the right. The spacer between them is then only a gap. */
.smp-bar #ipr-smp-info { flex:1 1 0; min-width:60px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.smp-bar .syn-spacer   { flex:0 0 6px; }
.smp-bar #ipr-smp-sel  { flex:0 0 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; }
.smp-canvas { display:block; width:100%; height:112px; background:var(--bg-inset);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
  cursor:text; touch-action:none; }
/* THE WAVEFORM TAKES FOCUS, so it must show that it has it. Its keyboard
   bindings (+/-, Home/End, Shift+E, the arrows) are scoped to this element
   precisely so they cannot collide with the pattern editor or a text field -
   which makes "is the waveform focused" a question the user has to be able to
   answer by looking. :focus-visible rather than :focus so an ordinary mouse
   click does not paint a ring nobody asked for. */
.smp-canvas:focus { outline:none; }
.smp-canvas:focus-visible { outline:2px solid var(--accent-ink); outline-offset:-1px; }
/* THE PLAYHEAD OVERLAY. The canvas is not touched: a head is a DOM sliver
   moved with transform, exactly as the instrument-list heads work
   (js/instrument-playheads.js). Drawing it on the canvas would mean redrawing
   the envelope underneath it ~47 times a second — and that envelope is built
   by walking every frame of the sample, so a 64KB sample would cost 65,536
   reads per head movement. inset:1px keeps the sliver inside the canvas's own
   1px border rather than straddling it. */
.smp-wave { position:relative; }
.smp-heads { position:absolute; inset:1px; overflow:hidden; pointer-events:none; }
.smp-head {
  position:absolute; top:0; bottom:0; left:0; width:1px;
  background:var(--accent-hi); will-change:transform;
}
/* ── spectral (package D): the spectrogram canvas ───────────────────────────
   .spg-on is put on .smp-editor by js/sample-body.js whenever ui.smpSpectral
   is split or only.

   IT IS THE SAME BOX AS THE WAVEFORM CANVAS, deliberately: `inset:1px` puts it
   exactly inside the waveform canvas's own 1px border, which is where
   .smp-heads already sits, so all three surfaces share one coordinate space and
   a frame is at the same x on every one of them. Nothing here maps frames to
   pixels; the one xOf() in js/sample-body.js does, for all of them.

   BEHIND, VIA z-index, not by DOM order. An absolutely positioned element
   paints above a static sibling whatever the order, so the waveform canvas has
   to be lifted rather than the spectrogram pushed back — and lifting it would
   put it above .smp-heads too, which is why the heads take a z-index of their
   own. Three explicit rungs beat two implicit ones.

   AND THE WAVEFORM CANVAS GOES TRANSPARENT while this is on, because its
   background is --bg-inset and would otherwise hide the whole picture. The
   spectrogram takes that background instead, so the panel is the same colour it
   always was: the ramp's own bottom entry IS --bg-inset, so silence in the
   spectrogram and the ground behind it are one colour by construction.

   left/top ONLY, with the SIZE SET INLINE by paintSpectral. `inset:1px` — what
   .smp-heads uses — measures .smp-wave, and the waveform canvas is not 100% of
   .smp-wave: render() pins its width to a whole number of DEVICE pixels, which
   under box-sizing:border-box leaves its CONTENT box two px narrower again.
   Measured in the running app: .smp-wave 736, canvas border box 734, canvas
   content box 732 — so an inset:1px spectrogram is 734 px wide over the
   waveform's 732 and drifts 2 px from it by the right edge. The two must agree
   about x for the same frame or the whole design gives way, so the size comes
   from the waveform canvas rather than from a second guess at the layout. */
.smp-spec {
  position:absolute; left:1px; top:1px; display:none; z-index:0;
  pointer-events:none; background:var(--bg-inset);
  border-radius:var(--radius-control);
}
.smp-editor.spg-on .smp-spec   { display:block; }
.smp-editor.spg-on .smp-canvas { position:relative; z-index:1; background:transparent; }
.smp-editor.spg-on .smp-heads  { z-index:2; }
/* TALLER WHEN THE VIEW IS SPLIT, so the waveform keeps the 112px it had rather
   than being halved to make room. `only` does not grow: nothing was displaced.
   The expanded window overrides both with height:100% further down, and this
   must not fight it — hence the plain .smp-canvas selector, which
   .ipr-expanded .smp-canvas beats on specificity. */
.smp-editor.spg-split .smp-canvas { height:224px; }
/* …and the expanded window still wins, which needs saying explicitly: the rule
   above is three classes and `.ipr-expanded .smp-canvas` is two, so without
   this the pinned 224px would beat height:100% and expanding with the split
   view on would leave the canvas at 224px inside a full-screen dialog. */
.ipr-expanded .smp-editor.spg-split .smp-canvas { height:100%; }
.smp-ops { display:flex; align-items:center; gap:4px; margin-top:6px; flex-wrap:wrap; }
.smp-ops input[type="number"] { width:58px; }

/* A GLYPH BUTTON IS STILL A BUTTON, and must stay hittable.
   NOT .btn-glyph: that class is a fixed 14x12 SLOT for a glyph sitting inside
   a labelled transport button, and its height:12px is load-bearing (it is what
   stopped the transport row shifting 2px when arming REC). These are
   standalone buttons whose whole content is the glyph, so they get their own
   class and their own box. min-width rather than width so a wide glyph is not
   clipped, and the same height as their worded neighbours so the row reads as
   one row. */
.smp-glyph {
  min-width:26px; padding-left:6px; padding-right:6px;
  font-size:12px; line-height:1; text-align:center;
}
/* A RULE, NOT A GAP. Transport and zoom are different jobs; four pixels of
   whitespace does not say so, and a divider costs nothing. */
.smp-sep { width:1px; align-self:stretch; margin:2px 4px; background:var(--border-control); }
/* A GROUP LABEL, not a control. It names the two glyphs beside it so they are
   not mistaken for the time-zoom pair using the same two symbols. */
.smp-lbl { color:var(--text-dim); font-size:10px; letter-spacing:0.5px;
  padding:0 1px 0 2px; user-select:none; }

/* ── THE OVERVIEW BAR ───────────────────────────────────────────────────────
   The trough is the whole sample; the thumb is the window on screen above it.

   IT EXISTS BECAUSE THE ZOOM GOES DEEP. At the 32 px/sample ceiling the canvas
   shows about eighteen frames out of a hundred thousand, and without an
   overview nothing on screen says where those eighteen are.

   CURSORS CARRY THE AFFORDANCE and no label is needed: the middle of the thumb
   scrolls (grab), the two grips zoom (ew-resize), the trough pages. That is the
   one convention every surveyed editor shares. */
.smp-scroll {
  position:relative; height:12px; margin-top:4px;
  background:var(--bg-inset);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
  touch-action:none; cursor:pointer; overflow:hidden;
}
.smp-scroll-thumb {
  position:absolute; top:0; bottom:0; left:0; width:100%;
  /* 0.28 measured as too faint to find against --bg-inset on the dark theme -
     the bar was there, 424x12 with a 26px thumb, and still read as an empty
     gap in a screenshot. A scrollbar nobody can see is not a scrollbar. */
  background:var(--accent-ink); opacity:0.5;
  border-radius:calc(var(--radius-control) - 1px);
  cursor:grab; will-change:left, width;
}
.smp-scroll-thumb:hover { opacity:0.68; }
.smp-scroll-thumb:active { cursor:grabbing; opacity:0.78; }
/* A WHOLE-SAMPLE VIEW IS NOT A SCROLLBAR. When the thumb fills the trough there
   is nothing to scroll, and a full-width grabbable bar invites a drag that
   cannot do anything. */
.smp-scroll.at-full .smp-scroll-thumb { opacity:0.14; cursor:default; }
.smp-scroll.at-full .smp-scroll-grip { display:none; }
/* THE GRIPS ARE WIDER THAN THEY LOOK is the intent, but width alone cannot do
   it inside overflow:hidden, so they are simply drawn at a size that survives
   --ui-scale 0.75 - the scale at which Windows MED's icon strip stops being
   readable, and the one this whole panel is checked against. */
.smp-scroll-grip {
  position:absolute; top:0; bottom:0; width:7px;
  cursor:ew-resize; background:var(--accent-hi); opacity:0.85;
}
.smp-scroll-grip[data-grip="a"] { left:0;  border-radius:2px 0 0 2px; }
.smp-scroll-grip[data-grip="b"] { right:0; border-radius:0 2px 2px 0; }

/* THE MESSAGE LINE RESERVES ITS SPACE. Phil, 2026-08-30: "Messages at the
   bottom, like 'already at full scale' cause the UI to shift."

   The cause is `.fr-help:empty { display:none }` further down this file: with no
   message the line is not laid out at all, and the first refusal makes a whole
   row of layout appear under the buttons - which moves every button the user was
   aiming at. Reserving one line's height always is the same answer the readouts
   above the waveform already got.

   THE 112px INDENT GOES TOO. It belongs to Find & Replace, whose help text lines
   up under a 112px label column; there is no such column here, and it was
   pushing every message a third of the way across the panel. */
#ipr-smp-note {
  display:block;                 /* beats .fr-help:empty, on ID specificity */
  margin:4px 0 0 0;
  min-height:1.6em;              /* exactly the line-height .fr-help sets */
  /* ONE LINE, ALWAYS. The reserve above is a single line box, so a message long
     enough to wrap would put the jolt straight back — and the longest reachable
     one is 81 characters ("Instrument N holds M octaves in one buffer - editing
     one would desync the others"), which does not fit. The full text is still in
     the status bar, which every op writes to as well. */
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}

/* ═══ FULL-WINDOW WAVEFORM ═══════════════════════════════════════════════
   The expanded state fills the viewport and hands every spare pixel to the
   canvas. Written in the DIALOG's units, not the viewport's: the dialog sits
   inside a body scaled by --ui-scale, so 100vw here would be scaled a second
   time and overhang by a third at scale 0.75.

   SCOPED ENTIRELY UNDER .ipr-expanded, so the ordinary docked-panel layout
   above cannot be touched by any of it - the normal path keeps its fixed 112px
   canvas and its block body, and none of these rules apply until the button is
   pressed. */
.sd-overlay.floating > .sd-dialog.ipr-expanded {
  left:0 !important; top:0 !important;
  width:calc(100vw / var(--ui-scale));
  height:calc(100vh / var(--ui-scale));
  max-width:none; max-height:none;
  display:flex; flex-direction:column;
}
/* The chain from the dialog down to the canvas has to be flexible at every
   link, or the growth stops at the first block element and the canvas keeps
   its 112px inside a very tall window. */
.ipr-expanded .sd-body { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; }
.ipr-expanded #ipr-panel-sample { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; }
.ipr-expanded .smp-editor { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; }
.ipr-expanded .smp-wave { flex:1 1 auto; min-height:120px; }
/* height:100% against the flexed .smp-wave, so the canvas tracks the space its
   parent was given rather than the 112px the base rule pins. */
.ipr-expanded .smp-canvas { height:100%; }

/* align-items IS flex-start IN BOTH VIEWS, and that is the fix for a real
   glitch: "the top instrument jumps down slightly, the others move with it,
   then they animate back to compact" (Phil, 2026-08-12).
   Expanded pinned the content to the top; compact centred it. align-items is
   NOT an animatable property, so collapsing flipped it back to `center` on
   frame 0 while the row was still 40px tall — measured, the name dropped from
   3px to 13px below the row top instantly — and the height animation then
   carried it back up. The jump was the alignment, not the height.
   Holding flex-start throughout removes it. It only looks identical to
   `center` in compact because the children below are given a shared
   line-height; without that the 10px number and the 13px glyph would top-align
   against the 12px name instead of sharing its centre line. */
/* flex-wrap + a SEPARATE row-gap, so the detail line below the name costs
   nothing when it is collapsed. `gap:5px` would have applied 5px BETWEEN the
   two flex lines as well as between the items, so a zero-height second line
   still grew every compact row by 5px. column-gap alone keeps the horizontal
   spacing and makes the wrapped line free. */
/* NOWRAP, AND THE DETAIL LINE IS OUT OF FLOW.
   This was `flex-wrap: wrap`, because the second line needed a way to exist —
   .indet took `flex: 0 0 100%` and forced its own line. The cost was that
   line ONE could wrap too: a flex container breaks lines on each item's
   HYPOTHETICAL size, before any shrinking is considered, so an auto-width
   name or a `flex: none` size chip pushed itself onto a new line rather than
   ellipsising. Measured on a 238px panel dragged narrower — at 210px the type
   tag and size dropped to a second line (row 20px → 35px) and by 175px the
   name followed it (→ 50px), which is Phil's report on 2026-08-12: "the text
   starts wrapping to a new line instead of being truncated or removed".
   No amount of min-width:0 fixes that; the break happens first.
   So .indet is absolutely positioned now, like .inst2 beside it, and the row
   never wraps at all. overflow:hidden is the backstop for the two chips that
   genuinely cannot shrink. */
.inr { position:relative; display:flex; flex-wrap:nowrap; align-items:flex-start;
       overflow:hidden;
       column-gap:5px; row-gap:0; padding:2px 7px 2px 3px; border-bottom:1px solid var(--seam-soft); cursor:default; }
.inr:hover { background:var(--bg-hover); }
/* NO FILL. The block and sequence lists mark a pick with a left bar and bold
   bright amber text; this used a filled amber background instead, which is the
   inconsistency Phil reported on 2026-08-09. The bar and the text now come from
   `.inr.sel` above, shared with the other two lists, and this rule keeps only
   the amber text that says "current instrument" to everything else reading it. */
.inr.selected { color:var(--accent-ink); }
/* EVERY FOREGROUND ITEM IN THE ROW SITS OVER A WAVEFORM, so all of them need
   contrast and a shadow — not just the name, and not only when expanded.
   Phil, 2026-08-12: "it's quite hard to make out the instrument number, bit
   depth and audition button at either height". They were the three dimmest
   things in the app: the number and the ▸ at --text-dim (#9a9184) 10px, and
   the type tag at --accent-dim (#8a5a07) NINE px, over a moving backdrop.
   The shadow is on the shared rule below so it applies at BOTH heights; the
   expanded-only rule that used to carry it for .inname alone is gone. */
/* min-width covers the slot as well as the digits, and it must not SHRINK:
   a two-digit index has nothing to give, so on a narrow panel the number was
   being squeezed against the name. .inname already carries min-width:0 and an
   ellipsis, so it is the one that should absorb it. */
.inn { color:var(--text); min-width:calc(2ch + var(--marker-slot)); flex-shrink:0;
       text-align:right; font-size:10px; }
.inname { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* The bit-depth tag. Moved out of an inline style in js/app.js buildInstrList,
   which no stylesheet rule could have overridden. Brighter and a size up. */
.intype { color:var(--accent-ink); font-size:10px; flex-shrink:0; }
/* Provenance on a chain row whose plugin is not installed: where the
   module expects it from. Dimmed because it is a fact about the file,
   not an action — the url is never a load target (js/wam-host.js
   resolveLoadUrl). */
.wam-prov { color:var(--text-dim); font-size:10px; margin-left:6px; opacity:.75; }
/* ── Instrument-list waveform backdrop (instrument-waveforms.js) ─────────
   Per-slot canvas painted BEHIND the name text (compact) / filling a taller
   slot (expanded). Text spans get a stacking context above it. */
/* .inplay joins the list: the audition glyph is foreground too, and it was
   stacking correctly only because it set its own z-index. The shadow is what
   makes any of this readable over a waveform, so it belongs here rather than
   on the expanded-only rule. */
.inr > .inn, .inr > .inname, .inr > .intype, .inr > .inplay, .inr > .indet,
.inr > .insize, .inr > .inst2 {
  position:relative; z-index:1;
  text-shadow:0 1px 2px var(--shadow-strong), 0 0 3px var(--shadow-mid);
  /* ONE LINE-HEIGHT FOR ALL FOUR, so they are the same BOX height despite
     spanning 10px to 13px of font. That is what lets the row hold
     align-items:flex-start in both views (see .inr above): with equal boxes,
     top-aligning and centring are the same picture, and the text inside each
     box is centred by its own line-height. Without it, dropping `center` would
     have top-aligned the number and the glyph against the taller name. */
  /* Scaled by the font-size axis so the four boxes stay equal AND stay big
     enough for the type: at 120% the name's descenders were trimmed here. */
  line-height: calc(15px * var(--font-room, 1));
}
/* THE DETAIL LINE, under the name in the expanded view (task #44). Its own
   flex line via flex-basis:100%, NOT a switch of .inr to a column — the four
   items above share one line-height precisely so compact and expanded can hold
   one alignment, and changing that caused a measured collapse-animation jump.
   Injected by js/instrument-detail.js.

   HEIGHT-DRIVEN, both ends explicit, because the row's own min-height animates
   and anything sized by its content would snap while the row grew. 15px matches
   the shared line-height above, so the collapsed state is a true zero and the
   expanded state is exactly one line. It rides --row-delay like the height and
   the waveform, so a row's name, its backdrop and its detail all move together
   rather than the list rolling down in three passes. */
/* `.inr > .indet`, NOT `.indet` — exactly the trap documented on .inst2 above,
   walked into a second time. The shared foreground rule at .inr > .inn, …
   matches at (0,2,0) and sets position:relative, so a bare `.indet {
   position:absolute }` at (0,1,0) LOSES and the detail line stays in the flex
   line: measured position "relative", sitting at top:1 left:170 — the right
   end of line ONE, which is what Phil saw. Same specificity, later in the
   file, wins.
   (It also made the wrap fix look right for the wrong reason: no row grew a
   second line, because the second line's content had been squeezed onto the
   first.) */
.inr > .indet {
  /* OUT OF FLOW, so line one cannot be pushed around by it and cannot wrap.
     It kept `flex: 0 0 100%` while the row wrapped; pinning it to the row's
     bottom edge is the same result without a second flex line. The height
     transition still drives the reveal — an absolutely positioned box
     animates height exactly as an in-flow one does. */
  position: absolute; left: 7px; right: 7px; bottom: 2px;
  height: 0; overflow: hidden; opacity: 0;
  font-size: 10px; line-height: 15px; letter-spacing: .5px;
  /* NO COLOUR OF ITS OWN — it inherits from .inr exactly as .inname does, so
     the two lines are the same brightness and the selected-row amber reaches
     both. It was --text-dim, which is the same mistake the row's number and
     audition glyph had: dim text over a waveform is unreadable (Phil,
     2026-08-12: "it should be the same brightness as the the first line"). */
  white-space: nowrap; text-overflow: ellipsis;
  transition: height .18s ease, opacity .14s ease;
  transition-delay: var(--row-delay, 0ms);
}
.med-list[data-list="instr"].inwave-expanded .indet { height: 15px; opacity: 1; }
/* Empty text still means no line, whatever the view. */
.indet:empty { height: 0 !important; opacity: 0 !important; }
/* ROOM FOR THE STEREO FLAG when there is one, and none when there is not.
   .inst2 is pinned to the same bottom-right corner, so without this the
   detail text would run underneath it on a wide row. */
.inr:has(.inst2:not(:empty)) .indet { padding-right: 34px; }

/* LINE ONE GAINS THE SIZE, beside the name — "put the file size next to the
   instrument name on that same line? Just spreads it around a little". DOM
   order cannot place it (js/instrument-detail.js appends everything), so the
   row is ordered explicitly: number, name, size, type tag, then the detail
   line on its own row. .inname is flex:1, so the size is pushed hard against
   the tag and the name still ellipsises first. */
/* NAME then SIZE, both hard LEFT — "I wanted the file size to be on the left
   next to the instrument name, separated with a bullet". The name was flex:1,
   which meant it ATE the free space and shoved the size across to the tag, so
   the two never read as a pair. It sizes to content now (still ellipsising via
   min-width:0), the size follows it immediately, and the TAG takes the
   margin-left:auto that pushes it right. */
.inr > .inn    { order: 0; }
.inr > .inname { order: 0; flex: 0 1 auto; min-width: 0; }
.inr > .insize { order: 1; }
.inr > .intype { order: 2; margin-left: auto; }
/* .indet has no `order`: it is out of flow (see .inr > .indet above). */
.insize { flex:none; font-size:9px; opacity:.75; white-space:nowrap; }
.insize:empty { display:none; }
/* The same bullet the attribute run uses, so one separator means one thing
   throughout the row. */
.insize::before { content:'·'; margin:0 4px 0 1px; opacity:.7; }
/* In the table the name has a column and the size has its own, so neither the
   content-sizing nor the separator belongs there. */
.itable .inname { flex:initial; }

/* STEREO SITS UNDER THE BIT-DEPTH TAG — "maybe we could also put the stereo
   flag under the bit depth?" — so the two format facts read as a little
   column on the right. Absolutely positioned rather than made a flex item:
   as a sibling of .indet it would have wrapped onto a THIRD line, and the
   row's height is animated, so a third line would have fought the transition.
   It follows the detail line's visibility exactly. */
/* `.inr > .inst2`, not `.inst2` — the shared foreground rule below matches at
   (0,2,0) and sets position:relative, so a bare `.inst2 { position:absolute }`
   lost to it and the flag stayed in the flow, landing to the LEFT of the size
   instead of under the tag. Same specificity, later in the file, wins. */
.inr > .inst2 {
  position:absolute; right:7px; bottom:2px;
  font-size:9px; line-height:15px; opacity:0; white-space:nowrap;
  transition:opacity .14s ease; transition-delay:var(--row-delay, 0ms);
  pointer-events:none;
}
.med-list[data-list="instr"].inwave-expanded .inst2 { opacity:.75; }
.med-list[data-list="instr"].inwave-off .inst2 { opacity:0; }
.inst2:empty { opacity:0 !important; }
/* The table has real columns for both, so neither belongs there. */
.itable .insize, .itable .inst2 { display:none; }
/* ── THE FLOATING WINDOW'S TABLE (.itable) ───────────────────────────────
   ONE grid-template, shared by the heading row and every instrument row, so a
   column width is changed in exactly one place. The cells are placed by
   explicit grid-column rather than by source order, because .inn/.inname come
   from buildInstrList, .inplay from a wrapper in app.js and the .idc cells from
   js/instrument-detail.js — three writers whose DOM order is not a contract.

   The waveform canvas does not participate: it is position:absolute, inset:0,
   so it still spans the whole row behind the columns exactly as in the panel. */
/* THE AUDITION GLYPH HAS ITS OWN COLUMN, second: "we have space for both next
   to each other" (Phil, 2026-08-12). In the docked panel it appears over the
   NUMBER on hover instead — see the :not(.itable) rules below. */
/* Column 1 is 32px, not 24: the number now reserves a 10px slot at its left
   for the unused marker, so the digits need the room back. */
/* The name column: 96px truncated most modules' names in the undocked window
   (Phil, 2026-09-05); 140px holds a 20-character name at the list's size. */
:root { --itable-cols: 32px 18px minmax(140px,1fr) 54px 62px 34px 30px 62px 60px 44px 38px 46px; }
.itable-head {
  display: grid; grid-template-columns: var(--itable-cols); column-gap: 6px;
  padding: 3px 7px; border-bottom: 1px solid var(--border);
  font-size: 8px; letter-spacing: 1px; color: var(--text-dim);
  text-transform: uppercase; white-space: nowrap;
}
.itable .inr { display: grid; grid-template-columns: var(--itable-cols);
               column-gap: 6px; align-items: center; }
/* EVERY CELL ON GRID ROW 1. Without this the row silently became TWO rows of
   15px: the cells carry an explicit grid-column but auto-placement also assigns
   a ROW, and because .inplay sat at the far column BEFORE them in DOM order the
   cursor had already passed it, so every .idc wrapped onto a second line. That
   is why the window's compact row measured 35px against the docked 20px, and
   why the columns read as out of step with their headings. */
.itable .inr > *  { grid-row: 1; }
.itable .inn      { grid-column: 1; }
.itable .inplay   { grid-column: 2; margin-left: 0; text-align: center; }
.itable .inname   { grid-column: 3; }
/* COLUMN ORDER (Phil, 2026-08-12): what an instrument IS comes before what it
   does — Mem Use, Format, Vol, then the loop group, then the tuning group.
   Windows MED's own order put Loop Start / Loop Len third and fourth, which
   pushed Format and Vol past the middle of a wide window; these are the two
   you scan for, and the loop numbers are blank on most rows anyway. */
.itable .idc-size   { grid-column: 4; }
.itable .idc-fmt    { grid-column: 5; }
.itable .idc-vol    { grid-column: 6; }
.itable .idc-loop   { grid-column: 7; }
.itable .idc-lstart { grid-column: 8; }
.itable .idc-llen   { grid-column: 9; }
.itable .idc-tr     { grid-column: 10; }
.itable .idc-fine   { grid-column: 11; }
.itable .idc-env    { grid-column: 12; }
/* The type tag is redundant beside a Format column, and .indet is the DOCKED
   presentation of the same facts — neither belongs in the table. */
.itable .intype, .itable .indet { display: none; }
.itable .idc {
  position: relative; z-index: 1;
  font-size: 10px; line-height: 15px; white-space: nowrap; overflow: hidden;
  text-shadow: 0 1px 2px var(--shadow-strong), 0 0 3px var(--shadow-mid);
}
/* Numbers right-align so digits line up down the column, as both MEDs do;
   Format and Env are word-ish and stay left. */
.itable .idc-size, .itable .idc-lstart, .itable .idc-llen,
.itable .idc-tr, .itable .idc-fine, .itable .idc-vol { text-align: right; }
.itable .idc-loop { text-align: center; }
/* THE HEADINGS MUST MATCH THEIR CELLS' ALIGNMENT, or a right-aligned number
   under a left-aligned title reads as a misaligned column even when the grid
   is exact. Indices follow the column order above. */
/* The numeric columns: Mem Use, Vol, Loop Start, Loop Len, Transp, Fine.
   PAIRED with the .idc rules above — a column moves, both move. */
.itable-head span:nth-child(4), .itable-head span:nth-child(6),
.itable-head span:nth-child(8), .itable-head span:nth-child(9),
.itable-head span:nth-child(10), .itable-head span:nth-child(11) { text-align: right; }
.itable-head span:nth-child(7) { text-align: center; }
/* And column 1: .inn is right-aligned like the other numerics (both MEDs line
   their digits up), so its heading is too — it was the last one still reading
   as offset. */
.itable-head span:nth-child(1) { text-align: right; }

/* AND NOTHING WHEN THE PANEL IS TOO NARROW TO READ IT. Phil, 2026-08-12: "I'm
   actually not sure if we need the right click menu, as I think everything
   could fit in a fairly narrow sized panel view. we could just not show it when
   the panel is too narrow." .inwave-off is exactly that threshold already —
   js/instrument-waveforms.js sets it per host below MIN_PANEL_W, the same 150px
   cutoff that hides the waveform and the type tag — so the detail line rides a
   measurement that exists rather than inventing a second one. */
.med-list[data-list="instr"].inwave-off .indet { height: 0; opacity: 0; }

/* OPACITY LIVES HERE NOW, not in the canvas pixels. drawSlot used to bake two
   fillStyles — 0.26 compact against 0.5 expanded — and repaint every canvas the
   instant the view changed, while the row heights rolled down over ~380ms. That
   mismatch is what popped. .52 against 1 reproduces those two alphas exactly,
   and shares the --row-delay ladder with the height so the two stay in step. */
/* DIMMER IN BOTH VIEWS AND AT BOTH SIZES (Phil, 2026-08-12) — .52/1 became
   .34/.62. The waveform is a BACKDROP: it has to be readable as a shape while
   the text sitting on top of it stays the thing you read first, and the row's
   foreground was brightened earlier the same day, which shifted that balance. */
.inr-wave { position:absolute; inset:0; width:100%; height:100%; z-index:0; pointer-events:none;
            opacity:.34; transition:opacity .18s ease; transition-delay:var(--row-delay, 0ms); }
/* SCOPED BY data-list, NOT BY #i-list — this rule and the four below it.
   inject() has always stamped these classes on EVERY [data-list="instr"] host,
   but every rule reacting to them was keyed to the DOCKED list's id. So the
   floating window received .inwave-expanded and nothing styled it: the class
   landed and the rows did not move. That is why the view toggle could not
   simply be duplicated into the window — the button would have looked broken
   rather than absent. The selector now follows the same host set the JS does;
   a hard-coded #lwi-list twin would only be a second place to forget. */
.med-list[data-list="instr"].inwave-expanded .inr-wave { opacity:.62; }
/* FADE, NOT display:none — display cannot animate in either direction, so the
   previews vanished and reappeared outright when the panel crossed the narrow
   threshold (Phil, 2026-08-12). Keeping the layout box costs nothing: the
   canvas is already absolute, inset:0 and pointer-events:none. It also stops
   drawSlot's `clientWidth <= 0` early-bail firing on the hidden state. */
.med-list[data-list="instr"].inwave-off .inr-wave { opacity:0; }

/* ── Phase C: live playheads (js/instrument-playheads.js) ─────────────────
   A sliver per sounding voice, sitting between the waveform canvas and the
   row text. `transform` is the ONLY thing the script writes per report, so
   a moving head is a compositor job — no layout, no repaint of the envelope
   underneath it. `contain` keeps that promise even if a head is ever given
   a box that could reflow its neighbours.

   The head is a gradient rather than a flat line so it reads at 20px as
   well as 40: a hard 1px rule against the envelope's own hard edges is hard
   to pick out, where a bloom around it is not. Width is 3px with the colour
   concentrated in the middle pixel, which is also what stops it shimmering
   as it crosses the pixel grid at sub-pixel steps. */
.inr-heads { position:absolute; inset:0; z-index:0; pointer-events:none;
             contain:layout style; overflow:hidden; }
.inr-head  { position:absolute; top:0; bottom:0; left:-1px; width:3px; display:none;
             background:linear-gradient(90deg,
                        rgba(255,196,64,0) 0%,
                        rgba(255,208,96,.85) 50%,
                        rgba(255,196,64,0) 100%);
             will-change:transform; }
/* Compact rows are short enough that a full-height head is a heavy mark;
   inset it slightly so it reads as a cursor over the wave rather than a
   divider between rows. Expanded rows have the height to carry the lot. */
.med-list[data-list="instr"]:not(.inwave-expanded) .inr-head { top:2px; bottom:2px; }
/* No previews, no playheads — same threshold, same fade, one signal. */
.med-list[data-list="instr"].inwave-off .inr-heads { opacity:0; }
/* Expanded: taller slots, name pinned to the top so the waveform reads below it
   (and leaves room for the Phase-C playhead). */
/* align-items is NOT restated here — the row holds flex-start in both views on
   purpose. Switching it was the collapse glitch; see .inr. */
.med-list[data-list="instr"].inwave-expanded .inr { min-height:40px; padding-top:3px; }
/* (The expanded-only text-shadow on .inname moved to the shared foreground rule
   above — the waveform is behind the text at BOTH heights, so the shadow was
   missing in exactly the mode where the row is tightest.) */
/* Panel-header inline toggle (e.g. the instrument waveform view switch). */
.ph-btn { background:none; border:1px solid var(--border-control); color:var(--text-dim); font-size:11px; line-height:1; padding:1px 6px; border-radius:var(--radius-control); cursor:pointer; }
.ph-btn:hover { color:var(--accent-hi); border-color:var(--accent-dim); }
.ph-btn.on { color:var(--accent-hi); border-color:var(--accent-dim); background:var(--accent-wash); }

/* (The SETUP SCREEN styles were removed 2026-07-14 along with the libopenmpt
   onboarding screen — the native MED engine needs no external library, so the
   app boots straight to the drop zone.) */

/* ── DROP ZONE ──────────────────────────────────────────── */
/* No cursor:pointer — the zone is a DROP target, not a click target (the
   click-to-open shortcut was removed 2026-07-28; OPEN is the click target). */
#dropzone { flex:1; display:none; flex-direction:column; align-items:center; justify-content:center; gap:12px; margin:10px; border:2px dashed var(--border); color:var(--text-dim); transition:border-color .2s,color .2s; }
#dropzone.show  { display:flex; }
#dropzone.hover { border-color:var(--accent-ink); color:var(--accent-ink); background:rgb(var(--accent-rgb) / 0.03); }
#dropzone .di  { font-size:48px; opacity:.18; }
#dropzone .dh  { font-size:11px; color:var(--text-dim); }

/* ── PATTERN EDITOR ─────────────────────────────────────── */
#pattern-area {
  display: none;
  flex: 1;
  overflow: auto;
  background: var(--bg-pattern);
  /* THE PANE'S OWN GAP, density-controlled since 2026-08-24. Phil: "the module
     pattern's padding border is still unchanged, that could be trimmed." It was
     a fixed 8px on every side, and with the 2px border that is 10px of chrome
     per side -- 20px of height and 20px of width the grid never got. The border
     stays 2px in both densities: it is the focus indicator (the colour changes
     on .ed-focus), and thinning the one cue that says "typing goes here" to buy
     two pixels is a bad trade. */
  margin: var(--d-pattern-margin);
  border: 1px solid var(--border);
}
#pattern-area.show { display: block; }

#pat-hdr {
  position: sticky; top:0;   /* #ed-bar moved out to the top region (2026-07-27) */
  display: flex;
  background: var(--bg-grid-head);
  border-bottom: 1px solid var(--accent-dim);
  z-index: 10;
  font-size: 10px; color: var(--accent-ink);
  /* Natural content width, matching the rows (which are now the natural
     track width, not viewport-stretched). NO min-width:100% — tying the
     header width to the pane made every panel-resize frame re-lay-out all
     N column headers (the "resizing is jerky" report, Phil 2026-07-24).
     max-content is content-driven, so a pane resize no longer touches it. */
  width: max-content;
}
/* Row-number column, header + rows — FROZEN at the left edge so the row
   numbers stay visible when the pattern is scrolled sideways (Phil
   2026-07-24, the "split so it's visible at all times" request). Opaque bg
   so scrolled-under track cells don't bleed through; sits above the cells
   (z within the row) but below the sticky header bar. */
/* z-index ABOVE #pat-hdr-splitter's 12 (task #11 step 7). position:sticky makes
   this a STACKING CONTEXT, so the fold button's own z-index is scoped inside it
   and could never beat the splitter from here -- measured with elementFromPoint
   at the button's centre, which returned pat-hdr-splitter.

   The cost is deliberate and small: the splitter spans left:0 to right:0 at
   bottom:0, so its leftmost 38px -- the gutter's width -- stops being a drag
   zone. That corner is the fold control's now, and because the dead zone IS this
   sticky element, it stays aligned with the gutter at any horizontal scroll. */
.phrow { position:sticky; left:0; z-index:12; background:var(--bg-grid-head);
         min-width:38px; padding:3px 6px 3px 0;
         /* Same divider token as the channels and the pattern cells, so the
            master strip is separated from channel 1 the way the channels are
            separated from each other (Phil, 2026-08-26: "there's no line
            separating master and channel 1"). */
         border-right:1px solid var(--btn-press); text-align:right; flex-shrink:0; }
/* Channel header column. Fixed width — must match `.pc` below to keep
   header labels aligned over their data cells. Previously used `flex:1`
   which stretched each column to fill the pattern-area width and could
   diverge subtly from `.pc`'s min-width on many-channel mods (e.g.
   TheStomper.med, 8+ tracks), breaking alignment. Phil 2026-05-22.
   If you change this, update `.pc` below to the same value.
   Width tightened to 132px (was 158) — the cell content `nn ii vv cc p1 p2`
   takes ~115px of monospace 11px, leaving comfortable padding. Compacter
   layout lets more channels fit before horizontal scroll. */
/* Track column width. --pc-w is set per block by renderPattern from the
   measured content: blocks with a param2 (cmdExt) column need more room
   than those without. Header and data cell MUST use the same var or the
   columns drift apart. (Phil 2026-07-23: the fixed 132px left ~30-45px of
   dead space per track once the vestigial volume column was removed.) */
/* 16px of bottom padding: the 11px fold bar plus the 5px splitter under it.
   Reserved on the CELL rather than on #pat-hdr, because the header is a flex row
   and padding there would not push the sticky gutter's contents up too. */
/* line-height follows the font-size axis (--font-room): a unitless one is
   relative to font-size, which that axis does not change, so at 115% the
   track names inside were trimmed by their own line-clamp box. */
.phch  { flex:0 0 var(--pc-w, 115px); padding:3px 4px 16px; text-align:center; border-right:1px solid var(--btn-press); font-size:9px; letter-spacing:1px; line-height:calc(1.2 * var(--font-room, 1)); display:flex; flex-direction:column; align-items:stretch; gap:2px; overflow:hidden; min-width:0; }
/* ── THE STRIP'S TWO COLUMNS (task #11, re-laid out 2026-08-25) ─────────────
   Phil's layout: buttons down the left, the vertical fader and meters beside
   them, the readout at the top of that column, pan under everything.

   The BODY is what grows. The name row and the pan row keep their own heights,
   so every pixel the strip gains goes into the meters -- which is the whole
   point of a vertical meter and the reason the old horizontal one was welded to
   the cell's bottom edge instead.

   NO SHOW/HIDE BUTTONS ANYWHERE. Renoise puts a toggle on each of these rows
   and the result is dense and busy; here the strip's HEIGHT decides what fits
   and the tier classes do the rest, so there is nothing to configure. */
.phch-body { flex:1 1 auto; min-height:0; display:flex; flex-direction:row;
             align-items:stretch; gap:3px; }
/* FULL HEIGHT, always. align-self:stretch is what makes the meter monotonic in
   the strip height: it starts at the top and ends at the bottom at every tier, so
   no row appearing beside it can move or shrink it. */
.phch-body > .phch-meter { align-self:stretch; }
/* ROW at tier 1, COLUMN once there is height for it. The buttons never move to
   a different part of the strip — the container reflows, which is the one kind
   of change feedback_ui_fixed_places allows: same place, same order, different
   axis. Stacked, they leave the width to the right of them for the meters,
   which is what Phil's layout asks for. */
/* ── AS WIDE AS THE NAME ABOVE THEM, AND ALL ONE HEIGHT ────────────────────
   Phil, 2026-08-26: "make the mute, solo & group buttons slightly bigger to
   match the width of the channel name, and make sure they are the same height
   also". They were content-sized and centred, so the row was a short huddle
   under a full-width field and the three buttons were three different widths.

   align-items:STRETCH is what makes them one height: the group button carries a
   colour dot and the other two carry text, so left to themselves they size to
   different content. At flex-end they were merely bottom-ALIGNED, which looks
   the same until one of them is a pixel taller. */
.phch-btns { flex:0 0 auto; display:flex; flex-direction:row; gap:2px;
             align-items:stretch; flex-wrap:nowrap; }
/* The right-hand column: readout pinned to the TOP, meter taking the rest. */
/* align-items:center so the METER HUGS ITS CONTENTS instead of stretching. A
   vertical well stretched across the column is a big dark box with a thin meter
   lost in the middle of it -- the bars are a few px each and the lane 11px, so
   ~30px is the whole object. The readout still spans, because a centred number
   wants the width. */
/* ── THE LEFT COLUMN ────────────────────────────────────────────────────────
   Everything textual and clickable; the meter is its sibling and gets the whole
   strip height beside it (Phil, 2026-08-26: "the channel name should be to the
   left of the meter/fader so that the meter/fader can use the full height").

   Name and readout are pinned to the TOP, buttons and pan to the BOTTOM, and the
   slack sits between them -- so every row is measured from one edge or the other
   and NOTHING MOVES as the strip grows. The old layout stacked the readout above
   the meter, which is exactly why the meter jumped: measured, its top went
   15 -> 17 -> 30px and its height DROPPED 32 -> 31 the moment the pan row
   appeared. */
.phch-lcol { flex:1 1 auto; min-width:0; min-height:0;
             display:flex; flex-direction:column; align-items:stretch; gap:2px; }
.phch-read { flex:0 0 auto; align-self:stretch; }
/* ── NOTHING MOVES WHEN A ROW APPEARS ───────────────────────────────────────
   Phil, 2026-08-26: "there's still too much shifting about when resizing the
   channel headers: when the faders appear, when the pan controls appear."

   Two groups, anchored to opposite edges, with the slack between them:

     TOP     name, buttons        — pinned to the top, never move
     ~slack~
     BOTTOM  readout, pan         — pinned to the BOTTOM, grow UPWARD

   The order inside the bottom group is what makes it work. Pan arrives first
   (tier 78) and the readout later (93), and the readout sits ABOVE pan -- so when
   the readout appears the group grows upward into the slack and PAN DOES NOT
   MOVE. Had they been the other way round, the later arrival would have pushed
   the earlier one up, which is the shifting this replaces.

   The rule generalises: within a bottom-anchored group, whatever appears LATER
   must sit HIGHER. */
.phch-read { margin-top:auto; }
/* Moved below the .strip-pan rule -- see there. It sets `margin:1px 0 0`, a
   SHORTHAND at equal specificity and later in the file, so an auto margin
   declared up here lost silently and the pan sat under the buttons instead of on
   the bottom edge: measured 32.2px off the bottom at the pan tier against 16 at
   the readout tier. */
/* Win MED accepts very long track names (measured: an 80-char name wrapped to
   5 lines and pushed the whole header from 36px to 78px, since flex stretches
   every column to the tallest). Clamp to 2 lines; app.js sets a title= with the
   full text. `break-all` because a name can be one unbroken token. */
.phname {
  color:var(--text-dim); font-size:8px; letter-spacing:0; text-transform:none;
  display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical;
  overflow:hidden; overflow-wrap:anywhere; word-break:break-all; max-width:100%;
}
/* A LITTLE LARGER ONCE THERE IS ROOM (Phil, 2026-08-25: "the channel names
   could become a little larger and be editable"). 8px is a tier-1 compromise --
   the header is song-width, so every pixel there is paid on all 28 tracks of a
   28-track module. Once the strip has been dragged open the room exists, so the
   name takes it and the third line clears too.

   The tier-1 size is UNTOUCHED, deliberately: that header was measured full to
   the pixel and growing the name there would push the meter off the bottom. */
/* ONE SIZE AT EVERY TIER. This used to grow 8px -> 10px when the fader tier
   arrived, which pushed the button row under it down by 2.4px -- a shift at
   exactly the threshold Phil named ("when the faders appear"). The larger size
   was the point of the earlier change, so it is now the ONLY size: the name has
   a full-width row of its own at the top and can afford it, and STRIP_NATURAL
   carries the extra 2px so the collapsed strip still fits. */
.phname { font-size:10px; }
#pat-hdr.strip-fader .phname { -webkit-line-clamp:3; }
/* The name is a rename target, so it says so on hover rather than only in the
   tooltip. Not a permanent field: see beginRenameTrack in js/app.js. */
/* THE TOP BLOCK DOES NOT MOVE (Phil, 2026-08-25: "when you resize the fader
   panel, the mute/solo etc buttons jerk around. They shouldn't get pushed up by
   the faders appearing").

   The name was flex:1, so it absorbed every pixel of slack and the M/S row rode
   on top of it. Measured across a height sweep, M/S sat 13.2px from the cell top
   at h=40 and 52.9px at h=140 -- and it moved BACKWARDS at each tier, 29.7 ->
   23.7 when the pan row appeared and again when the readout did, because a new
   row takes its space out of the flexible one. That reversal is the jerk: the
   thing you are pointing at moves the opposite way to the drag.

   flex:0 0 auto pins the name to its own height, so name and M/S sit at the top
   and stay there; the slack collects below them, and the optional rows hang off
   the bottom with the meter (see margin-top:auto). */
/* ── THE NAME IS A FIELD (Phil, 2026-08-26: "can we make the channel names
   match the usual styling for text fields and just make it a single click to
   edit?") ─────────────────────────────────────────────────────────────────
   The BOX belongs to this div, not to the input, so renaming changes nothing
   about the layout: the same bordered box is there before, during and after,
   and the input simply fills it. That is what the old markup was reaching for
   with "a mode change, not a layout change" -- but the input carried its OWN
   1px border and the label had none, so opening a rename grew the row by 2px
   every time. One box, two contents.

   Tokens are the app's field family (--bg-inset ground, --border-control at
   rest, --accent-ink on focus), the same three .sd-dialog input[type=text]
   uses, so a channel name reads as the same kind of object as every other
   text field in the app. */
.phch-name {
  /* 0 1 auto -- IT MAY NOT GROW, BUT IT MUST BE ABLE TO YIELD.
     flex:0 0 auto was half a rule. The no-GROW half is load-bearing and stays:
     as flex:1 the name ate every pixel of slack and the button row rode up and
     down on it, which is the jerk the two-column layout was built to end. But
     0 0 also means it can never SHRINK, and a long track name wraps to two or
     three lines -- so the name simply pushed the bottom group off the bottom of
     the cell. Measured with a 33-character name: 20.1px of pan and readout
     hanging past the fold bar at every tier below 110.
     Shrinking clips lines off a name that was already ellipsised; not shrinking
     hides a CONTROL. .phname is a -webkit-box with overflow:hidden, so the
     clipping is the same clamp it already does, one line lower. */
  flex:0 1 auto; min-height:0; overflow:hidden;
  /* CENTRED, so the squeeze is shared. The name's line box is taller than this
     box when the strip is collapsed, and top-aligned it put all of the
     overflow at the BOTTOM -- where descenders live -- while 2-3px sat unused
     above the letters. Measured collapsed, "Gypsy lead": 2.8px of its tails
     cut at 100% with the shipped font, 1.8px at 135% with a proportional local font, and 2-3px
     spare above in both. */
  display:flex; align-items:center;
  cursor:text; box-sizing:border-box; min-width:0;
  background:var(--bg-inset); border:1px solid var(--border-control);
  border-radius:var(--radius-control); padding:0 2px;
}
.phch-name:hover { border-color:var(--border-hi); }
/* focus-WITHIN, because the focused thing is the input inside it. */
.phch-name:focus-within { border-color:var(--accent-ink); }
/* THE INPUT TAKES THE LABEL'S OWN BOX, which is what makes this a mode change
   rather than a layout change — nothing around it moves when the field appears.
   Sized in em so it follows the tier that just enlarged the name. */
/* THE INPUT IS BARE. Its box is .phch-name's above -- background, border,
   radius and focus colour all live there -- so the field does not gain a second
   border inside the first when the rename opens. */
.phch-rename {
  width:100%; box-sizing:border-box; margin:0; padding:0;
  font-family:inherit; font-size:10px; line-height:1.2; text-align:center;
  text-transform:none; letter-spacing:0;
  appearance:none; -webkit-appearance:none;
  background:transparent; color:var(--text);
  border:none; outline:none; min-width:0;
}
/* ── A STRIP WITH NO COLUMN UNDER IT (round 5) ─────────────────────────────
   Phil: "they show no pattern data (yet), so they can just be thinner strips.
   The master channel should also be drawn like this, at the farthest right."

   It is still a `.phch` — the docked meter host finds it with the selectors it
   already has, and `data-src` says which bus it is — so all this changes is the
   width and what is inside. No name field, no mute/solo, no pan: a bus has all
   of those in the Mixer window, and at 26px none of them would be legible.
   Meter, fader, readout and a tag.

   THE MASTER IS ONE OF THESE NOW rather than a pinned gutter. It was
   position:absolute inside .phrow, which is what made it the odd one out; here
   it is simply the last strip, and .phrow goes back to being the row numbers. */
.phch-thin { flex:0 0 var(--d-phbus-w); min-width:0; padding:3px 2px 16px;
             display:flex; flex-direction:column; align-items:stretch; gap:2px; }
.phch-thin .phch-body { flex:1 1 auto; min-height:0; display:flex;
                        flex-direction:column; align-items:stretch; gap:2px; }
/* The tag sits where a channel's pan does, so the tops of every fader and every
   readout in the row line up — the same argument that moved MST to the bottom
   of the master strip (Phil, 2026-08-26). */
.phch-thin .phch-thin-lbl { flex:0 0 auto; font-size:8px; letter-spacing:1px;
                            color:var(--text-dim); text-align:center;
                            overflow:hidden; white-space:nowrap; }
.phch-thin.phch-return .phch-thin-lbl { color:var(--accent-ink); }
/* THE FADER IS THE METER'S MIDDLE LANE, exactly as it is on a channel strip and
   on the master — one gesture, three strips, and no second control to align. */
.phbus-fader { -webkit-appearance:none; appearance:none; background:transparent;
               writing-mode:vertical-lr; direction:rtl;
               flex:0 0 auto; width:var(--vu-lane, 9px); min-height:0; margin:0;
               align-self:stretch; cursor:ns-resize; }
/* THE MASTER, ONCE IT IS IN THE ROW. Its children keep their .phmst-* classes
   (the meter host and the fader wiring look for them) — only the box changes
   from an absolutely-positioned fill of .phrow to a flow item like its
   neighbours. */
#pat-hdr > .phmst { position:static; flex:0 0 var(--d-phbus-w); min-width:0;
                    padding:3px 2px 16px; margin:0;
                    /* THE SAME HEIGHT AS EVERY OTHER STRIP. --strip-h is what
                       the splitter drags and what .phch reads; the master used
                       to fill .phrow instead, which is why it needs saying
                       here rather than inheriting it. */
                    height:var(--strip-h, auto); align-self:flex-start; }
/* …and the gutter it used to live in is the row numbers again. */
.phch:last-child { border-right:none; }
/* ── THE MASTER STRIP (task #11 step 4) ─────────────────────────────────────
   In the gutter, absolutely positioned so the header's flex row is untouched
   and its columns still line up with the data cells. Hidden until the faders
   appear: at tier 1 the transport VU already is the master meter, always
   visible, and a second one would be clutter for no new information.

   VERTICAL, because the gutter is 38px wide and tens of pixels tall — the
   opposite proportions to a docked channel strip, which is why those are
   horizontal. It is also a preview of the vertical mode the channel strips get
   at large heights (step 5). */
/* ── THE FOLD BAR (task #11) ────────────────────────────────────────────────
   Phil, 2026-08-25: "I want to change the expand/collapse arrow to a full bar
   like the left and right panels." It borrows .panel-collapse's tokens exactly,
   so the three folds in the app are visibly one control in three orientations.

   It sits ABOVE the splitter rather than at the very edge: the bottom 5px stay
   the drag zone, because grabbing the boundary to resize is the more frequent
   gesture and it belongs on the boundary. Clicking the bar folds; dragging the
   last few pixels resizes.

   The header can be far wider than the viewport, so a centred glyph would scroll
   away. The bar spans the header (it has to, or it would not be a full bar) and
   the GLYPH is sticky inside it. */
#pat-hdr-fold {
  position:absolute; left:0; right:0; bottom:5px; height:11px;
  z-index:13; padding:0; margin:0; cursor:pointer;
  display:flex; align-items:center;
  background:var(--bg-header); color:var(--header-ink);
  border:none; border-top:1px solid var(--seam); border-bottom:1px solid var(--seam);
  /* 10px, .panel-collapse's size — the caret is that control's glyph and was
     being drawn three sizes smaller than it. */
  font-family:inherit; font-size:10px; line-height:1;
}
/* ── THE CARET (Phil, 2026-08-26: "change the style of the caret on the
   collapse/expand bar to match the side panels, and also centre it on the bar
   (don't make it scroll with the rest of the pattern view)") ───────────────

   IT WAS NOT STICKY AT ALL. The rule here was `#pat-hdr-fold > span`, and
   syncMixerFoldBtn set `btn.textContent`, which DESTROYS the span the markup
   ships and leaves a bare text node -- so from the first sync onward this
   selector matched nothing and the glyph scrolled away with the header, which
   is exactly what was reported. It writes into the caret element now.

   TWO ELEMENTS, because they do two different jobs and one cannot do both:
     .phfold-rail   sticky, spans the VISIBLE pane width, centres its content
     .phfold-caret  inline-block, carries the rotation

   The rail has to be a block as wide as the viewport for `text-align:center` to
   centre anything, and a transform on a full-width block would rotate the whole
   bar. --pat-view-w is the pane's clientWidth, published by the ResizeObserver
   in js/app.js; the 100% fallback keeps it centred on the header until the
   first measurement lands. */
#pat-hdr-fold > .phfold-rail {
  position:sticky; left:0; display:block;
  width:var(--pat-view-w, 100%); text-align:center; padding:0;
}
/* ── DRAWN, NOT TYPED ──────────────────────────────────────────────────────
   This was U+2039, the side rails' own glyph, rotated a quarter turn -- on the
   reasoning that reusing the glyph is the surest way to match its weight. It is,
   and it still looked wrong: Phil, 2026-08-26, "it doesn't look like an arrow.
   It's just a curve."

   He is right, and the reason is the typeface. Share Tech Mono draws U+2039 as a
   ROUNDED chevron. Sideways the eye reads it as an arrow because it sits on a
   vertical rail beside the thing it folds and the context supplies the meaning;
   turned on its end, with nothing beside it, there is no context left and what
   is on screen is a curve.

   Rendered every candidate at 34px and looked at them: the arrowhead codepoints
   (U+2304, U+02C5, U+2228) come out of the fallback font as lopsided ticks -- the
   left stroke shorter than the right -- so they read as check marks. The solid
   triangle U+25BC reads correctly but is much heavier than anything else in the
   chrome. A pair of borders on a rotated box is the only one that is a clean
   symmetric arrowhead, and it has the further virtue of being drawn at whatever
   weight is asked for rather than whatever weight a font happens to have.

   currentColor, so it inherits the bar's --header-ink and its --accent-hi hover
   without naming either. */
.phfold-caret {
  display:inline-block; width:4px; height:4px;
  border-right:1.5px solid currentColor;
  border-bottom:1.5px solid currentColor;
  /* The ink sits on the box's bottom-right corner, so a rotated arrowhead hangs
     off the box's centre; the translate puts it back on the bar's midline and
     flips sign with the rotation. */
  transform:translateY(1px) rotate(225deg);   /* points UP — the EXPANDED state */
}
/* ── THE DIRECTION THE EDGE WILL TRAVEL ────────────────────────────────────
   The side rails' actual convention, read off wireCollapse rather than assumed:
   the LEFT panel shows a left arrow while OPEN (clicking moves its edge left,
   closing it) and a right arrow while COLLAPSED (clicking moves it right, back
   out). The glyph points where the boundary goes.

   THIS BAR HAD IT BACKWARDS — expanded showed DOWN and collapsed UP, the
   opposite of both side rails. Phil, 2026-08-26: "the arrow direction on the
   channel header collapse/expand bar is the opposite to the side bars."
   It predates the drawn caret (the original was `open ? '▼' : '▲'`) and survived
   the glyph change because that change preserved the behaviour faithfully —
   including a comment claiming it followed the side rails, which it did not.

   The strip grows DOWNWARD from a bar at the header's bottom edge, so:
     expanded  -> clicking collapses -> the edge travels UP   -> point UP
     collapsed -> clicking expands   -> the edge travels DOWN -> point DOWN

   Driven off aria-expanded so the glyph and the accessible state cannot
   disagree — one source for which way this control goes. */
#pat-hdr-fold[aria-expanded="false"] .phfold-caret {
  transform:translateY(-1px) rotate(45deg);   /* points DOWN — the COLLAPSED state */
}
#pat-hdr-fold:hover { background:var(--accent-ground-soft); color:var(--accent-hi); }
/* AT EVERY TIER, like the channel strips beside it (Phil, 2026-08-26: "the
   master meters should be visible at the collapsed size to match the other
   channels"). It used to be gated on .strip-fader, on the reasoning that the
   transport VU is already a master meter -- but the transport VU is across the
   window, and the thing this sits in a row with is four channel meters that ARE
   drawn at that height. A gutter that goes blank while its row keeps metering
   reads as a bug, not as economy.

   The fader inside it stays gated on the tier exactly as the channel faders do
   (see .phmst-fader below), so what appears at tier 1 is a METER, not a control
   too small to use. */
.phmst {
  display:flex; flex-direction:column; align-items:center; gap:2px;
  /* top:2px now that the "ROW" label is gone (Phil, 2026-08-26). That word was
     costing the master strip the top 16px of the only column that survives
     horizontal scroll, which is why it would not fit at the smallest panel
     height. bottom still clears the fold bar (11px) and the splitter (5px). */
  /* top:3px, not 2px, so the master's well starts on the SAME line as the
     channel meters: .phch's padding-top is 3px and the meter is the first thing
     under it. Measured at 2px the master sat one pixel proud of the row. */
  position:absolute; left:0; right:4px; top:3px; bottom:16px;
  text-align:center; pointer-events:auto;
}
/* LAST, in the row the channels give to pan (Phil, 2026-08-26: "can we move
   the 'MST' label to the bottom, taking the space where the pan controls are on
   other channels? That way the tops of faders and the peak readout all line
   up"). It was FIRST, so it pushed the master's well down by its own height plus
   the gap -- measured 10px -- and the master fader started 10px below every
   channel fader it is read against.

   `order` rather than moving it in the markup: the label is what a screen reader
   meets first when it enters the strip, and that is the right reading order for
   a thing whose name is MST. Source order stays; only the painting moves. */
.phmst-lbl { font-size:8px; letter-spacing:1px; color:var(--text-dim); flex:0 0 auto;
             order:9; }
/* THE MASTER'S FADER IS ITS METER'S MIDDLE LANE TOO (Phil, 2026-08-25: "And I
   also wanted it on the master fader"). Same idea rotated: the master meter is
   VERTICAL, so its two bars are side by side and the lane is a vertical strip
   between them. No gap and no second control -- .phmst-body is now just the
   well's box. */
.phmst-body { flex:1 1 auto; display:flex; align-items:stretch;
              justify-content:center; min-height:0; width:100%; }
/* writing-mode is the modern vertical range; the WebKit-only appearance
   keyword was removed from the platform, so this is the one that survives. */
/* writing-mode is the modern vertical range; the WebKit-only appearance keyword
   was removed from the platform, so this is the one that survives. direction:rtl
   makes it run bottom-to-top, which is what a fader means. */
.phmst-fader {
  --vu-knob: calc(var(--vu-lane, 1px) + var(--vu-grab));
  writing-mode:vertical-lr; direction:rtl;
  /* Same grab margin as the channel faders, rotated: a 5px-wide hit target is
     not one, so the input's box is lane + grab and the bars overlap the surplus.
     position/z-index for the same reason too — the knob belongs over the meter,
     and the second bar is painted after the input. */
  flex:0 0 calc(var(--vu-lane, 1px) + var(--vu-grab));
  width:calc(var(--vu-lane, 1px) + var(--vu-grab));
  position:relative; z-index:2;
  margin:0; padding:0;
  /* Fades with its tier, like the channel faders — see .phch-fader. */
  visibility:hidden; pointer-events:none; opacity:0;
  transition:opacity .14s ease, visibility .14s;
  accent-color:var(--accent-fill);
  -webkit-appearance:none; appearance:none; background:transparent;
}
#pat-hdr.strip-fader .phmst-fader { visibility:visible; pointer-events:auto; cursor:pointer;
                                    opacity:1; }
/* No track of its own: the well IS the track, exactly as on a channel strip. */
/* THE CHANNELS' RAIL, EXACTLY (Phil, 2026-08-26: "the master volume fader
   doesn't have the same line width in between the two meters ... the same as the
   other channels I mean"). It was a bare 1px line of --border-control against
   the channels' 3px groove inside a 1px border -- a different object, in a row
   with four of the other kind. Same declarations as .phch-fader's rail below. */
.phmst-fader::-webkit-slider-runnable-track {
  width:3px; background:var(--bg-rail);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
}
.phmst-fader::-moz-range-track {
  width:3px; background:var(--bg-rail);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
}
/* And the knob overlaps both bars by the same 2px. Rotated, so the width is the
   cross-axis and the height is the travel axis. */
.phmst-fader::-webkit-slider-thumb {
  -webkit-appearance:none; appearance:none;
  width:var(--vu-knob); height:7px; margin-left:calc((var(--vu-knob) - 1px) / -2);
  background:var(--accent-ink); border:1px solid var(--border-control);
  border-radius:var(--radius-control); cursor:pointer; box-sizing:border-box;
}
.phmst-fader::-moz-range-thumb {
  width:var(--vu-knob); height:7px; box-sizing:border-box;
  background:var(--accent-ink); border:1px solid var(--border-control);
  border-radius:var(--radius-control); cursor:pointer;
}
/* Two 3px columns, L then R, matching the strips' L-over-R order read
   top-to-bottom as left-to-right. Revealed from the BOTTOM, which is what a
   vertical meter means — inset() hides from the top. */
/* Bar, lane, bar -- read left to right, the same order the channel strips read
   top to bottom. The bars take what the lane leaves, so one variable sizes all
   three here as well. */
/* THE SAME WELL THE CHANNELS HAVE (Phil, 2026-08-26: "we don't draw a box
   around them like we do for the other channels"). .phch-meter has carried
   `border:1px solid var(--vu-track-edge)` since the strips went vertical and
   this did not, so the master was the one meter in the row without an edge --
   it read as a stripe painted on the gutter rather than as a meter in a well.
   box-sizing:border-box so the border comes out of the width it already had. */
/* SAME WIDTH AS A CHANNEL WELL (Phil, 2026-08-26: "the master meters should be
   the same width as the other channels"). It was `--vu-lane + 8`, a formula of
   its own that came out at 13px against the channels' 21 -- so the master read
   as a thinner, lesser meter in a row of four identical ones.
   flex:0 0 auto and let it size to its contents, exactly as .phch-meter does:
   two bars, the fader box between them, less the grab each bar overlaps. Making
   the CONSTRUCTION the same is what keeps them the same, rather than two
   formulas that have to be kept in step by hand. */
.phmst-meter { flex:0 0 auto; display:flex;
               align-items:stretch; justify-content:center;
               background:var(--vu-track); overflow:hidden; position:relative;
               box-sizing:border-box; border:1px solid var(--vu-track-edge); }
.phmst-meter > i:first-child { margin-right:calc(var(--vu-grab, 0px) / -2); }
.phmst-meter > i:last-child  { margin-left:calc(var(--vu-grab, 0px) / -2); }
.phmst-meter > i {
  /* 7px, the channel bars' width -- see .phmst-meter above. */
  display:block; flex:0 0 7px; height:100%; min-width:0;
  clip-path:inset(100% 0 0 0);
  background:linear-gradient(0deg,var(--vu-floor) 0%,var(--vu-safe) 70%,var(--vu-warn) 88%,var(--vu-clip) 100%);
}
/* CENTRED, unlike the channel readouts (Phil: "just keep it centred for the
   master channel"). The reason the channel readout is flush right is that it
   shares a column with a full-width pan control and had to stop reading as part
   of it. The master strip has no pan row -- MST sits there instead -- and the
   gutter is 38px wide with the well centred in it, so centred is what lines the
   number up with the thing it measures. */
/* The same box as the channel readouts — it is the same control, and it resets
   the same latch. line-height 7 inside a 9px row for the same reason theirs is 9
   inside 11: the height is stated by the tier rule and the border comes out of
   it, so nothing moves when the box appears. */
.phmst-read { flex:0 0 auto; font-size:8px; line-height:7px; color:var(--text-dim);
              font-variant-numeric:tabular-nums; white-space:nowrap;
              align-self:center; text-align:center;
              box-sizing:border-box; padding:0 2px;
              background:var(--bg-inset); border:1px solid var(--border-control);
              border-radius:var(--radius-control); }
/* ── PER-TRACK LEVEL METER (task #11, the Mixer) ───────────────────────────
   Phil, 2026-08-25: "the docked strip rail could be the channel headers
   themselves. We should be able to squeeze small level meters into the current
   view?" — so the header IS the mixer strip rather than a second rail above it,
   and the meter has to cost NO HEIGHT, because the cell had none to give.
   Measured before building: a 98px-wide cell is 38px tall and its contents
   (3px pad + 17px name + 2px gap + 13px M/S + 3px pad) come to exactly 38.

   So it is ABSOLUTELY POSITIONED on the bottom edge — the same trick, and for
   the same reason, as .vu-led in the transport meters: out of flow, zero
   layout, and clipped by the cell's existing overflow:hidden. Full cell width
   rather than the ~27px spare in the M/S row, because width IS the resolution
   here and --pc-w varies per block (98px measured, 115px default, more with a
   cmdExt column) — an inline meter would be squeezed to nothing on a narrow
   one.

   The FILL is scaled, not resized: transform:scaleX stays on the compositor,
   where a width change would relayout every header cell 60 times a second. */
/* EXACT HEIGHT, NOT A MINIMUM. As a min-height the strip snapped: crossing a
   tier adds a row, the CONTENT then exceeded the minimum, and the cell jumped
   ahead of the pointer before tracking smoothly again. Measured at both
   thresholds — dragging 64->68 moved the strip 64->74.5, and 76->80 moved it
   76.5->89.4. Phil: "sometimes it will pull out gradually, other times it snaps
   and then moves smoothly."
   Worse, it fed back: the meter grows with the strip and sits in the flow via
   padding-bottom, so a taller strip made taller content which made a taller
   strip. An exact height breaks the loop — the cell is what was dragged, and
   overflow:hidden (already set) handles anything that does not fit. */
/* ── THE FOLD ANIMATES (Phil, 2026-08-26: "the channel header split doesn't
   animate smoothly when you collapse/expand like the side panels do") ───────
   Exactly what #left-panel/#right-panel do one rule below, on the axis this
   control folds: they transition `width .18s ease`, this transitions height.
   --strip-h is a custom property and cannot be transitioned without registering
   it, but it does not need to be -- the property that actually changes is this
   `height`, and animating that carries the gutter, the master strip (absolutely
   positioned against these cells' box) and the pattern rows below with it.

   NOT DURING A DRAG -- see body.lay-dragging further down. A height transition
   on a splitter drag makes the header lag the pointer by the whole duration,
   which reads as broken rather than smooth. The strip splitter goes through
   _wireSplitter, so it already sets that class. */
.phch { position:relative; height:var(--strip-h, auto); transition:height .18s ease; }
/* ── THE TRACK FADER (task #11, tier 2) ─────────────────────────────────────
   Present in the markup at every tier and revealed by a class on #pat-hdr —
   never inserted and removed. Things have a fixed place: a control that appears
   somewhere different depending on height is exactly what that rule prevents.

   HORIZONTAL, against mixer convention, because the geometry here is not a
   mixer's. A docked strip is ~100px wide and tens of pixels tall, so a vertical
   fader would get the short axis and the wide axis would go to waste. The
   undocked window (compact, tall strips) is where vertical earns its keep, and
   that is a different host with its own layout.

   accent-color + <input type=range> is the same fader idiom as the WAM mix row
   (#wam-gui-mix-row .wam-gui-fader) — one slider in the app, and keyboard and
   screen-reader support for free rather than reimplemented. */
/* ── PANNING (task #11) ─────────────────────────────────────────────────────
   Its own tier ABOVE the readout: a second control crammed beside the fader is
   how a strip stops being readable, which is the same reason the readout got a
   row of its own.

   Hidden until #pat-hdr says otherwise, like every other tier — present in the
   markup at all heights so it never appears in a different place. */
/* ── APPEARING IS AN ANIMATION, NOT A CUT ──────────────────────────────────
   Phil, 2026-08-26: "can we also make them fade in smoothly like we do for
   elements in the instrument list panel". Same idiom as .indet / .inst2 there:
   an opacity transition on the element itself, driven by a class on an ancestor.

   `display` IS IN THE TRANSITION LIST, with transition-behavior:allow-discrete.
   These rows are display:none until their tier arrives, and without that keyword
   the browser applies the display flip instantly and the opacity transition
   never gets a chance to run -- the row would still appear as a cut, just a
   cut into an already-opaque element. With it, showing flips display at the
   START (so the row takes its space immediately and the layout is exactly what
   it was before this change) and fades the ink in; hiding fades out and flips
   display at the END.

   @starting-style supplies the opacity to animate FROM. A transition needs an
   old value, and an element that was display:none has no old value to offer --
   without this block the row would jump straight to opacity 1.

   Both are supported in this app's targets (verified in-page: CSS.supports
   'transition-behavior: allow-discrete' and CSSStartingStyleRule both true).
   Where they are not, the row simply appears the way it does today. */
/* ── APPEARING AND DISAPPEARING BOTH ANIMATE ───────────────────────────────
   Phil, 2026-08-26: "make the volume faders and peak readout fade in and out
   like the panning controls. I'm not sure the fade out is there yet."

   It was not, deliberately, and the reason it was left out is worth keeping
   because it says why THIS version is built the way it is. The first attempt
   faded opacity while `display` rode the transition (allow-discrete) to keep the
   row rendered -- so on the way out the row went on taking its full space for
   140ms after its tier was gone. Measured: dragging back down past the pan tier
   left an 11px control and its 2px gap in a column with no room for them, and
   the track name above squeezed by up to 13px and sprang back. So I shipped the
   exit as a cut.

   That was solving the wrong half. The problem is not that the row is still
   THERE, it is that its space vanishes DISCONTINUOUSLY -- and the fix is to
   animate the space too, which is exactly what the instrument list Phil pointed
   at does: `.indet` transitions `height` AND `opacity` together (0 -> 15px), and
   nothing jumps because nothing is ever asked to move instantly.

   So there is no `display` here at all. The rows are always in the flow; their
   HEIGHT is what the tier changes, from 0 to the row's own, alongside opacity.
   The name above them takes up the slack continuously instead of being squeezed
   and released.

   `visibility` is in the list for the part opacity cannot do: a 0-height input
   at opacity 0 is still focusable and still a tab stop. visibility transitions
   with special timing that suits this exactly -- it flips to `visible` at the
   START of a transition INTO view and holds `visible` until the END of one out
   of it -- so the control is reachable precisely while it can be seen, with no
   discrete-transition keyword needed. */
.phch-pan, .phch-read, .phmst-read {
  /* NO margin-top HERE, AND NONE IN THE TRANSITION. It had both, and that broke
     the bottom anchoring: the whole bottom group hangs off `.phch-read
     { margin-top:auto }`, and a `margin-top:0` in this rule sits LATER in the
     file at equal specificity, so it silently won and the group floated up under
     the buttons. Phil, 2026-08-26: "the panning controls are no longer aligned
     to the bottom like they used to be."
     The 1px gap above the pan control is stated in its tier rule and does not
     animate. One pixel appearing in one frame is not worth a property that
     fights the anchor for the same declaration. */
  display:block; opacity:0; visibility:hidden;
  height:0; overflow:hidden;
  transition:opacity .14s ease, height .18s ease, visibility .18s;
}
#pat-hdr.strip-pan  .phch-pan,
#pat-hdr.strip-read .phch-read,
#pat-hdr.strip-read .phmst-read { opacity:1; visibility:visible; }
/* ── AND THE MASTER READOUT'S HEIGHT, WHICH IS ITS OWN RULE ────────────────
   The base rule above collapses all three optional rows to height:0 -- that is
   what lets them animate -- and each needs a height to come back to. The channel
   rows already had one, in tier rules that existed before the fade (11px each).
   The master readout never did: it was display:none below its tier and its
   NATURAL height the rest of the time, so collapsing it left nothing to return
   to and it vanished outright. Phil, 2026-08-26: "where has the peak readout on
   the master channel gone?"  9px is its own line-height, one line of an 8px face.

   A RULE OF ITS OWN, not a fourth line on the selector list above: a comment
   inside a selector list is still inside the list, so writing it there gave the
   height to the two channel rows as well and quietly shortened them from 11px.
   Different value, different rule. */
#pat-hdr.strip-read .phmst-read { height:9px; }
/* THE BOTTOM GROUP IS BOTTOM-ANCHORED. `margin-top:auto` in a flex column eats
   the free space, so pan/readout/meter stack upward from the cell's bottom edge
   while the name and M/S stay put at the top. Growth goes into the gap between
   the two groups, which is the only part of the strip that has nothing in it.

   On the FIRST VISIBLE optional row only: two auto margins would split the slack
   between them and push the rows apart. The readout takes it when there is no
   pan row -- which the tier heights make unreachable today (pan arrives 15px
   below the readout), but the rule states the intent rather than relying on that
   ordering holding. */
/* The bottom group is anchored by .phch-btns above; pan simply follows it, so
   the two stay together at the bottom edge instead of drifting apart. */
#pat-hdr.strip-pan .phch-pan {
  /* LAST IN THE STRIP, under everything, spanning its full width — the one
     control here that is not about level, and the width buys the travel a
     -16..+16 range wants.
     flex:0 0 auto — A CONTROL MUST NOT BE COMPRESSIBLE, measured once already
     on the volume fader, which flex squashed to 6.5px at its tier threshold. */
  flex:0 0 auto; order:9;
  /* margin-top, not the `margin` shorthand: the shorthand would also reset the
     bottom-anchor's auto margin on every tier change. */
  display:block; width:100%; height:11px; margin-top:1px; padding:0; min-width:0;
  cursor:pointer;
}
/* ── THE APPEARANCE RESET BELONGS TO THE CONTROL, NOT TO ITS TIER ──────────
   `-webkit-appearance:none` and the transparent ground were declared in the tier
   rule above. That was invisible while the row was display:none below its tier
   -- but the row FADES now, so for 140ms on the way out it was a visible,
   unstyled, platform-default range input. Phil, 2026-08-26: "when they fade out
   their styling changes to the chrome default."
   What a control looks like is not a function of how tall the strip is. */
.phch-pan {
  -webkit-appearance:none; appearance:none; background:transparent;
}
/* ── THE READOUT CARRIES THE ANCHOR AT EVERY TIER NOW ──────────────────────
   This used to hand the auto margin to PAN whenever the readout was not visible,
   because the readout was display:none below its tier and an absent element
   cannot anchor anything.
   Since the rows fade, they are never absent: the readout is in the flow at
   height 0, and `margin-top:auto` works on a zero-height item exactly as well as
   on a tall one. So the readout holds the anchor at all times and this rule
   became actively harmful -- two auto margins split the slack between them and
   push the pair apart, which is the failure its own comment warned about. */

/* A CENTRE-DETENT RAIL. The track carries a tick at the middle, because dead
   centre is the one position on a pan control anybody looks for and a plain
   groove gives no way to see it. Drawn on the TRACK rather than as an element:
   the rail is a pseudo-element already and 28 strips do not need another node. */
.phch-pan::-webkit-slider-runnable-track {
  height:3px; border-radius:var(--radius-control);
  background:linear-gradient(90deg,
    var(--bg-rail) 0, var(--bg-rail) calc(50% - 1px),
    var(--border-control) calc(50% - 1px), var(--border-control) calc(50% + 1px),
    var(--bg-rail) calc(50% + 1px), var(--bg-rail) 100%);
  border:1px solid var(--border-control);
}
.phch-pan::-moz-range-track {
  height:3px; border-radius:var(--radius-control);
  background:linear-gradient(90deg,
    var(--bg-rail) 0, var(--bg-rail) calc(50% - 1px),
    var(--border-control) calc(50% - 1px), var(--border-control) calc(50% + 1px),
    var(--bg-rail) calc(50% + 1px), var(--bg-rail) 100%);
  border:1px solid var(--border-control);
}
/* NARROWER AND TALLER than the volume knob, and in a different ink. Two knobs
   on one strip have to be told apart at a glance, and shape carries that as
   well as colour does — a pan knob is a pointer, a volume knob is a grip. */
.phch-pan::-webkit-slider-thumb {
  -webkit-appearance:none; appearance:none;
  width:5px; height:11px; margin-top:-5px; box-sizing:border-box;
  background:var(--pan-ink); border:1px solid var(--border-control);
  border-radius:var(--radius-control); cursor:pointer;
}
.phch-pan::-moz-range-thumb {
  width:5px; height:11px; box-sizing:border-box;
  background:var(--pan-ink); border:1px solid var(--border-control);
  border-radius:var(--radius-control); cursor:pointer;
}
/* Off the mixer path the engine never reads trackPan, so the control is inert
   and says so — greyed rather than gone, because absent would read as "this app
   cannot pan" instead of "not on this module". */
.phch-pan:disabled { cursor:not-allowed; opacity:0.3; }

/* ── THE PER-TRACK READOUT (task #11 step 3) ────────────────────────────────
   Phil, 2026-08-25: "Could we show the peak/current level for each channel
   independently?" — on the SHARED ui.vuReadout setting, so a strip and the
   master cannot disagree about whether they are showing peak or current.

   Its own tier, above the fader's: the row is genuinely optional (ui.vuReadout
   'off' removes it) and a number crammed in beside a control it does not belong
   to is how a strip stops being readable.

   Tabular figures for the same reason the master's readout has them — a value
   that shifts sideways as it changes is harder to read than no value. */
/* THE HEIGHT THE FADE ANIMATES TO. It was already stated here; what changed is
   that the base rule is now 0 rather than display:none, so this is one end of a
   transition instead of a fixed size. */
/* ── WHAT IT LOOKS LIKE IS NOT A FUNCTION OF THE STRIP'S HEIGHT ────────────
   All of this used to live in the .strip-read tier rule, which was harmless
   while the row was display:none below its tier -- but the row FADES now, so on
   the way out it lost every one of these declarations and spent 140ms visibly
   reverting. text-align was the one that showed: it fell back to the `center`
   inherited from .phch and the number slid across the strip as it faded. Phil,
   2026-08-26: "when the peak readout fades, it moves to the centre."
   Only the SIZE belongs to the tier, because the size is what the fade animates.

   RIGHT-ALIGNED, IN THE COLUMN TO THE LEFT OF THE FADERS. Phil, correcting the
   first reading of this: "I meant the number should be aligned right, but to the
   left of the faders." Two facts, and they are not the same one. WHERE it sits
   is the left column, which is what separates it from the meter; HOW it sits is
   flush right, so the digits end against the fader they describe and a value
   that gains a character grows away from it rather than drifting across the
   strip. Centred it read as a pair with the centred pan control below. */
.phch-read {
  width:100%; text-align:right;
  font-size:9px;
  color:var(--text-dim); font-variant-numeric:tabular-nums;
  white-space:nowrap;
  /* ── BOXED, SO IT LOOKS LIKE SOMETHING YOU CAN CLICK ──────────────────────
     Phil, 2026-08-26: "perhaps the readout should be boxed so it's more obvious
     it can be clicked?" It has cleared the peak and the clip latch since the
     last change, and nothing said so — a bare number reads as output, not as a
     control. Same field family as the channel name (--bg-inset ground,
     --border-control at rest), so the two clickable things in the strip look
     like the same kind of object.

     box-sizing:border-box and line-height 9, NOT 11. The row's height is stated
     as 11px by the tier rule and that number is load-bearing: it is one end of
     the fade's height transition and it is what the tier thresholds were
     measured against. Taking the border out of the 11 rather than adding to it
     keeps every one of those measurements true — the box appears and NOTHING
     moves. */
  box-sizing:border-box; line-height:9px; padding:0 2px;
  background:var(--bg-inset); border:1px solid var(--border-control);
  border-radius:var(--radius-control);
}
.phch-read:hover, .phmst-read:hover { border-color:var(--border-hi); }
/* ── RED WHEN THIS TRACK HAS CLIPPED ──────────────────────────────────────
   Phil: "also make it red when it has clipped". The lamp says it at the meter;
   the readout says it where the NUMBER is, which is where you look to find out
   how far over it went. --vu-clip is the same token the lamp and #vu's LED use,
   so all three say it in one colour.
   The border goes with it: at 9px the digits alone are a small target for a
   colour change, and the box is the part of this that carries at a glance. */
.phch.clipped .phch-read,
.phmst.clipped .phmst-read { color:var(--vu-clip); border-color:var(--vu-clip); }
#pat-hdr.strip-read .phch-read {
  /* Not compressible, so the meter beside it takes every remaining pixel. */
  flex:0 0 auto;
  height:11px;
}
/* THE FADER IS THE METER WELL'S MIDDLE LANE (Phil, 2026-08-25: "could we also
   try drawing the volume fader in the middle of the channel meter? It would use
   the space more efficiently").

   It is the SAME element at both tiers, and at tier 1 it is the 1px divider
   between the two bars — hidden, unfocusable, and exactly one pixel tall. That
   is why --vu-lane exists rather than a margin on the first bar: one variable
   moves the lane from a hairline to a control, and the bars' calc follows it
   without knowing which tier it is.

   The well is overflow:hidden, so the thumb has to FIT the lane rather than
   overhang it. It does not need to overhang: ::before draws the ceiling mark
   across both bars, which is the cross-lane reading an overhanging thumb would
   have been for.

   flex:0 0 — A CONTROL MUST NOT BE COMPRESSIBLE. With the strip sized by an
   exact height, flex was shrinking the old standalone fader to fit: measured
   6.5px tall at the tier threshold, which clipped the thumb. */
/* --vu-lane is now THE VISIBLE RAIL, 5px (a 3px groove inside its border), and
   the bars butt straight up against it — "with the meters flush against it".
   --vu-grab is the invisible margin either side that keeps the control
   GRABBABLE: a 5px hit target is not one, so the input's box is lane + grab and
   the bars are pulled back over that surplus with a negative margin. The
   input is transparent outside its rail, so what you see is 5px of groove
   between two flush bars, and what you can grab is 11px. */
.phch-fader {
  /* VERTICAL. writing-mode is the modern vertical range (the WebKit-only
     appearance keyword was removed from the platform); direction:rtl makes it
     run bottom-to-top, which is what a fader means. The lane and grab are the
     same tokens as before, applied across the WIDTH now. */
  writing-mode:vertical-lr; direction:rtl;
  --vu-knob: calc(var(--vu-lane, 1px) + var(--vu-grab));
  flex:0 0 calc(var(--vu-lane, 1px) + var(--vu-grab));
  width:calc(var(--vu-lane, 1px) + var(--vu-grab));
  /* NOT height:100%. In a row flex container the default align-items:stretch
     already gives this its height, and `100%` against an indefinite parent
     resolves to the input's own INTRINSIC height instead -- which for a vertical
     range is enormous (measured 620px, the height of the viewport). That is what
     made a collapsed strip 654px tall. Belt and braces with the definite height
     applyStripHeight now writes: either alone fixes the symptom, and the pair
     stops it coming back through a different auto-height ancestor. */
  display:block; height:auto; min-height:0; margin:0; padding:0;
  /* FADES WITH ITS TIER (Phil, 2026-08-26: "make the volume faders ... fade in
     and out like the panning controls"). No height to animate here -- the lane
     is a constant width at every tier, which is what stops the meter moving --
     so this is opacity alone, with visibility carrying the focusability the way
     it does for the rows above. */
  visibility:hidden; pointer-events:none; opacity:0;
  transition:opacity .14s ease, visibility .14s;
  accent-color:var(--accent-fill);
  -webkit-appearance:none; appearance:none; background:transparent;
  /* THE KNOB GOES OVER THE METERS, NOT UNDER (Phil: "they overlap the fader knob
     instead of the other way round"). The second bar is painted after the input
     in DOM order, so without a stacking position it covered the knob. */
  position:relative; z-index:2;
}
/* At tier 1 the fader IS the 1px divider between the bars: no grab margin, no
   hit area, nothing to see. The grab only exists once it is a control. */
#pat-hdr.strip-fader .phch-fader {
  visibility:visible; pointer-events:auto; cursor:pointer; opacity:1;
}
/* THE SAME RAIL THE PAN CONTROL USES (Phil, 2026-08-25: "I wanted the middle
   line for the volume fader to be the same style as the one the pan controls
   use, with the meters flush against it"). A 3px groove inside a 1px border, so
   the two controls on a strip read as the same kind of object.

   WITHOUT the pan rail's centre tick, and that is the one deliberate difference:
   the tick marks a DETENT, and dead centre means something on a pan control and
   nothing on a volume fader — a mark at 32 would invent a landmark that is not
   there. Same treatment, minus the part that carries pan-specific meaning. */
.phch-fader::-webkit-slider-runnable-track {
  width:3px; background:var(--bg-rail);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
}
.phch-fader::-moz-range-track {
  width:3px; background:var(--bg-rail);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
}
/* 12px of thumb inside 16px of box leaves 2px of clearance top and bottom, so
   nothing is cut off at any zoom the appearance system offers.

   THE KNOB IS AN INK OBJECT, NOT A FILL ONE, and getting that backwards is
   invisible in most themes: the base sets `--accent-fill: var(--accent-ink)`,
   so the two are the SAME colour and a knob drawn in one and outlined in the
   other looks fine. Four themes separate them, and there the pair is a SURFACE
   and the FOREGROUND that sits on it:

     magique        fill #3B67A2 (blue)        ink #FFA997 (peach)
     professional   fill #FF8888 (pink)        ink #0000AA (blue)
     nonplusultra   fill #553311 (dark brown)  ink #FFDD88 (light gold)

   A small solid control is a foreground object, so it takes the INK. Reported
   by Phil, 2026-08-25: in Magique the knob came out "blue with a pink outline",
   which is the surface colour filled and the foreground colour used as a rim —
   exactly inside out. The outline is --border-control, the same token every
   other control in the app outlines with, rather than a second accent. */
/* THE KNOB OVERLAPS THE BARS (Phil, 2026-08-25: "I actually wanted the fader
   knob to overlap the meters slightly"). --vu-knob is the lane plus 2px at each
   end, so the knob reaches into both bars by a constant 2px however tall they
   are: at tier 2 that is half of a 4px bar, and at a dragged-out 38px well it is
   a quarter of a 13px one. Tying it to the LANE rather than the well keeps the
   overlap the same at every height, which is what "slightly" has to mean.

   NO overflow CHANGE IS NEEDED, and that was worth checking rather than
   assuming: the well is overflow:hidden, but a 2px reach never leaves it. The
   knob is centred on the lane, the lane is centred in the well, and the bars are
   at least 2px at every tier -- measured at the tier-2 threshold, knob top 2px
   inside a 20px well. */
.phch-fader::-webkit-slider-thumb {
  -webkit-appearance:none; appearance:none;
  width:var(--vu-knob); height:7px; margin-left:calc((var(--vu-knob) - 1px) / -2);
  background:var(--accent-ink); border:1px solid var(--border-control);
  border-radius:var(--radius-control); cursor:pointer; box-sizing:border-box;
}
.phch-fader::-moz-range-thumb {
  width:var(--vu-knob); height:7px; box-sizing:border-box;
  background:var(--accent-ink); border:1px solid var(--border-control);
  border-radius:var(--radius-control); cursor:pointer;
}
.phch-fader:disabled { cursor:not-allowed; opacity:0.35; }
/* ── NO FOCUS BOX AROUND THE METER ──────────────────────────────────────────
   Phil, 2026-08-26: "it also draws a box around the meters, not sure what that's
   for." That was the browser's focus ring. runOp keeps focus ON the fader while
   you drag it (keepFocus), and pressing Shift is a KEY event -- which flips
   Chrome's focus-visible heuristic from "pointer user" to "keyboard user" and
   paints the ring. The fader fills its well, so the ring drew a box around the
   whole meter, and only ever appeared when you reached for fine adjust.

   The ring is not thrown away: it moves to the KNOB, which is the part that
   actually responds to the arrow keys, and stays legible without boxing the
   meter it sits in. */
.phch-fader:focus, .phch-fader:focus-visible,
.phmst-fader:focus, .phmst-fader:focus-visible { outline:none; }
.phch-fader:focus-visible::-webkit-slider-thumb,
.phmst-fader:focus-visible::-webkit-slider-thumb { border-color:var(--accent-hi); }
.phch-fader:focus-visible::-moz-range-thumb,
.phmst-fader:focus-visible::-moz-range-thumb { border-color:var(--accent-hi); }
/* Pan too — same ring, same reason, and it is the same gesture. */
.phch-pan:focus, .phch-pan:focus-visible { outline:none; }
.phch-pan:focus-visible::-webkit-slider-thumb { border-color:var(--accent-hi); }
.phch-pan:focus-visible::-moz-range-thumb { border-color:var(--accent-hi); }
/* ── THE HOVERING VALUE ─────────────────────────────────────────────────────
   position:fixed on <body>, not inside the strip: #pat-hdr is overflow-clipped
   and the strip is ~115px wide, so a tip parented there would be cut off on
   both counts.

   A FIXED WIDTH, and that is load-bearing rather than tidy — centring on the
   thumb otherwise needs offsetWidth, which forces layout inside an input stream
   running at pointer rate. Tabular figures keep the number from shifting
   sideways as it counts, the same reason the readouts have them. */
#fader-tip {
  position:fixed; display:none; z-index:1400; pointer-events:none;
  width:76px; height:17px; box-sizing:border-box;
  /* HARD LEFT AND HARD RIGHT (Phil, 2026-08-26: "the positioning of the numbers
     on the floating readout shift around as they change. can we keep them
     aligned left and right?"). The two halves are separate spans and the box
     holds them apart, so the dB value changing width cannot move the hex -- and
     neither of them moves at all, since both are pinned to an edge of a box
     whose width is fixed. A centred single string moved both every time either
     changed length.
     `display` is set to flex by showFaderTip when it shows the tip; it stays
     `none` here so the box is not laid out before it is ever used. */
  padding:1px 5px; align-items:baseline; justify-content:space-between;
  text-align:left; gap:4px;
  font-size:10px; line-height:13px; font-variant-numeric:tabular-nums;
  /* THE MENU'S OWN TOKENS, not invented ones. The first draft reached for
     --bg-panel/--text-normal/--border-panel; two of those three are defined
     NOWHERE, so the tip would have drawn with no border and whatever colour it
     inherited. Caught by colour-tokens.js, which is exactly what it is for.
     A floating box over the UI is the same object #ctx-menu is, so it takes the
     same three tokens and follows every theme for free. */
  background:var(--bg-menu); color:var(--menu-ink);
  border:1px solid var(--border); border-radius:var(--radius-control);
  box-shadow:0 6px 20px var(--scrim-soft);
}
/* A SINGLE VALUE HAS NOTHING TO SIT OPPOSITE. The pan sliders pass one label
   ("L12"/"C"/"R4"), so it takes the whole box and stays centred rather than
   being flung against the left edge on its own. */
#fader-tip.one { justify-content:center; }
/* The strip grows from the TOP of the name, so the fader lands under the M/S
   row and the meter stays welded to the bottom edge. */
#pat-hdr.strip-fader .phch { justify-content:flex-start; }

/* ── THE STRIP SPLITTER ─────────────────────────────────────────────────────
   Drag the header's bottom edge to grow the strip. Absolutely positioned and
   straddling the existing 1px border, so it costs the header NO height — the
   cell was measured full to the pixel before the meter went in, and there is
   still nothing spare. It overlaps ~3px of the first pattern row, which is
   what every DAW's boundary handle does. */
/* ENTIRELY INSIDE THE HEADER. It used to sit at `bottom:-3px`, straddling the
   border and reaching 3px into the first pattern row — which made it a
   full-width invisible strip over the top of row 0, and Phil found what that
   costs: "I think it's conflicting with area selections on the pattern view
   too". A drag-select begun near the top of row 0 grabbed the splitter instead.
   Pulled fully inside, it can only ever cover the meter, which is
   pointer-events:none and has nothing to conflict with. */
#pat-hdr-splitter {
  position:absolute; left:0; right:0; bottom:0; height:5px;
  cursor:ns-resize; z-index:12; background:transparent;
}
#pat-hdr-splitter:hover, #pat-hdr-splitter.dragging { background:var(--accent-dim); }
/* ── --vu-grab LIVES ON THE HEADER, NOT ON THE FADER ────────────────────────
   A custom property inherits from the PARENT, never from a sibling. Declared on
   .phch-fader it was invisible to .phch-meter > i, whose negative margin then
   resolved to its 0px fallback — so the bars sat against the input's HIT AREA
   instead of against its rail and left a 3px gap either side. Measured exactly
   that: gapAboveRail 3, gapBelowRail 3, on a fix that looked applied.

   On #pat-hdr both the fader and the bars inherit the same number, which is the
   only way the two can agree about where the rail is. */
/* ONE declaration, both tiers, and the arithmetic works out at each. At tier 1
   the lane is 1px: the fader's box is 1 + 6 = 7px, the bars overlap 3px each,
   and what is left between them is 1px of well — exactly the hairline divider it
   has always been, with the fader invisible on top of it. At tier 2 the lane is
   5px and the same sum leaves the 5px rail. A second, tier-specific value would
   also make this the only token in the stylesheet defined twice with different
   values, which colour-tokens.js rightly objects to. */
#pat-hdr { --vu-grab: 6px; }
.phch-meter {
  /* VERTICAL, and in the FLOW. It used to be absolutely positioned on the cell's
     bottom edge, which is what let a horizontal bar cost no height; a vertical
     meter wants the opposite -- all the height it can get -- so it is a flex
     child of the right-hand column and simply fills it.

     A ROW, because rotating the strip's own idiom is what makes it vertical:
     bar, fader lane, bar, read left-to-right instead of top-to-bottom. Same
     three parts, same --vu-lane sizing them, same knob overlapping both bars. */
  /* NO align-self:stretch. It overrode the column's align-items:center and the
     well spanned the whole 74px of the strip's right column -- a big dark box
     with a ~30px meter lost inside it. Hugging its contents is what makes it
     read as a meter. */
  /* flex:0 0 auto -- A CONSTANT WIDTH. .phch-body is a ROW, so `flex` here sizes
     the meter's WIDTH, not its height (the height comes from align-self:stretch).
     At 1 1 auto it shared the row with the left column, so the moment the pan row
     appeared and widened that column the meter NARROWED: measured 27px -> 15px as
     the strip crossed the pan tier. Its width is now whatever its own contents
     need -- two 7px bars, an 11px fader box, less the 6px of grab they overlap --
     and nothing outside it can change that. */
  flex:0 0 auto; min-height:0;
  display:flex; flex-direction:row; justify-content:center;
  background:var(--vu-track); overflow:hidden;
  box-sizing:border-box; border:1px solid var(--vu-track-edge);
  /* POSITIONED, so the clip lamp below has THIS to sit in. It had no `position`
     of its own, and the lamp is absolutely positioned -- so its containing block
     was .phch, the whole cell, and the lamp drew as a red bar down the right-hand
     edge of the channel header. Phil, 2026-08-26. The stale comment on the lamp
     even said the meter was "already position:relative-by-absolute", which had
     been true when the meter WAS absolutely positioned and stopped being true
     the moment it became a flex child. */
  position:relative;
  /* CLICKABLE, so the lamp can be dismissed by clicking it -- the idiom every
     hardware meter uses, and the one #vu already offers for the master. It was
     pointer-events:none, which is why "clicking it or the readouts doesn't reset
     it": the click never landed on anything that was listening.
     Nothing is blocked by this. The fader inside keeps its own hit area and
     stops its clicks propagating, and a click on the well still bubbles to the
     strip, so selecting the track by clicking its header is unchanged. */
  pointer-events:auto;
}
/* THE CLIP LATCH, drawn as a stripe at the right end of the well — the same
   idiom and the same token as the master's .vu-led, because it is the same news
   about a different bus. Latched until the next play-from-start or a click on
   the master meters: a single over is gone in 10 ms, and finding out it
   happened is the whole point.
   ::after rather than an element: one CSS rule instead of 28 more DOM nodes,
   and the meter is already position:relative-by-absolute inside .phch. */
/* ACROSS THE TOP OF THE WELL, in the empty space above the bars (Phil,
   2026-08-26: "it should just be a bar in the empty space above the meters").
   It was a vertical stripe down the right-hand EDGE -- the right shape for the
   horizontal meter this was written for, and the wrong one twice over once the
   meters went vertical: wrong axis, and drawn against the cell rather than the
   well (see position:relative above).
   The top is where a vertical meter has room: the bars fill from the bottom, so
   the space above them is empty exactly when the signal is not near clipping,
   and covered by the bar itself when it is -- which is the moment the lamp
   should be unmissable. Same token as .vu-led, because it is the same news about
   a different bus, and the same hairline of track colour under it so a lit lamp
   stays a discrete object against a bar pinned at full scale. */
/* `.phch.clipped .phch-meter`, not `.phch-meter.clipped`: the latch class moved
   up to the CELL so the readout can turn red on the same news (see .phch-read
   below). The lamp is unchanged — it is still drawn inside the well, which is
   still its containing block. */
.phch.clipped .phch-meter::after {
  content:''; position:absolute; top:0; left:0; right:0; height:3px;
  background:var(--vu-clip); border-bottom:1px solid var(--vu-track);
}
/* ── ALL FOUR OFFER THE POINTER, UNLIKE #vu ────────────────────────────────
   #vu.clipped is conditional, on the reasoning that a permanently clickable
   meter implies an action that usually does nothing. That is right THERE and
   wrong here: this gesture clears the per-track PEAK HOLD as well as the clip
   latch, and the readouts are in "Peak since reset" mode by default -- so there
   is always something to dismiss, whether or not a lamp is lit. Making it
   conditional would also leave the master well with no affordance at all, since
   nothing lights a lamp on it. */
.phch-meter, .phmst-meter, .phch-read, .phmst-read { cursor:pointer; }
/* STEREO (Phil, 2026-08-25): two bars, L above R.
   EXPLICIT 2px HEIGHTS, not flex. `flex:1 1 0` inside 5px with a 1px divider
   splits the remainder as 1.5px + 1.5px — measured in the browser, and 1.5px is
   sub-pixel at 1x, which is exactly the unreadable meter this was supposed to
   avoid. Stated heights leave no arithmetic to get wrong: 2 + 1 + 2 = 5.
   The cost is 2px of header (the mono bar was 3px); .phch's bottom padding
   carries it. Paid deliberately — a stereo meter you cannot read is not worth
   having, and the header's height already varies by more than this with track
   names present. */
/* THE BARS SPLIT WHATEVER THE WELL HAS, minus the 1px divider. Stated with
   calc rather than flex: `flex:1 1 0` inside 5px measured 1.5px per side, which
   is sub-pixel at 1x — the unreadable meter this was meant to avoid, arrived at
   by arithmetic nobody performed. calc says the split out loud. */
/* THE GRADIENT IS FIXED TO THE WELL, AND REVEALED — not scaled with the bar
   (Phil, 2026-08-25: "rather than the gradient scaling with the size of the
   meter, it should only reveal more of the gradient as it grows longer").

   This is a correctness fix, not a cosmetic one. `transform:scaleX(v)` squeezes
   the WHOLE gradient into the bar, so the tip is always the --vu-clip end: a
   track sitting at -30 dB drew a short bar that was RED at its point. The
   colour carried no information at all, which is the opposite of what a
   gradient on a meter is for.

   clip-path rather than width, for the same reason scaleX was chosen over
   width in the first place: it stays on the compositor and costs no layout,
   and 28 strips repainting at 60 Hz is exactly where that matters. */
.phch-meter > i {
  /* Each bar takes a fixed WIDTH now and the full height; the lane between them
     is the fader, exactly as in the horizontal version. Revealed from the
     bottom by the draw loop's inset(). */
  display:block; flex:0 0 7px; height:100%; min-width:0;
  clip-path:inset(100% 0 0 0);
  background:linear-gradient(0deg,var(--vu-floor) 0%,var(--vu-safe) 70%,var(--vu-warn) 88%,var(--vu-clip) 100%);
}
/* A hairline of well between the two, so a track sitting at the same level on
   both sides still reads as two bars rather than one solid block. */
/* The bars overlap the fader's grab margin so they sit flush against its rail —
   horizontally now, since the well is a row. */
.phch-meter > i:first-child { margin-right:calc(var(--vu-grab, 0px) / -2); }
.phch-meter > i:last-child  { margin-left:calc(var(--vu-grab, 0px) / -2); }
/* MONO — off the mixer path, where Paula's panning is fixed hardware LRRL and
   every track is hard left or hard right. One bar spanning the full height,
   and the second is not drawn at all rather than drawn empty. */
.phch-meter.mono > i:first-child { flex:0 0 11px; }
.phch-meter.mono > i:last-child  { display:none; }
/* A track the current block does not have still METERS — mute/solo and audio
   are song-level, so the bar is honest there and reads 0 by itself when the
   track is silent. Only the well recedes with the rest of the dead cell. */
.phch.phch-dead .phch-meter { opacity:0.5; }
/* Ctrl+click-marked track (paste-to-tracks target) — cool-blue top bar + tint,
   matching the range-selection colour. */
.phch-tsel { background:var(--sel-fill-soft); box-shadow:inset 0 2px 0 var(--sel-edge-soft); }
/* A track column the CURRENT block does not have (the grid is laid out at the
   song's width — js/app.js songTrackWidth). Only the label recedes: the M and S
   buttons keep their normal contrast because mute and solo are SONG-level and
   genuinely apply to a track this block happens not to use. Toggled per block
   by a class, never by rebuilding the header. */
.phch.phch-dead { background:rgba(0,0,0,0.28); }
/* ABOVE THE AUDIO BUDGET — a different state from .phch-dead, and deliberately.
   .phch-dead means "the block you are looking at has no such track", which is a
   property of the BLOCK and changes as you move around. This means "no player
   will ever sound this track", which is a property of the SONG: OctaMED stops
   at the mixing-channel budget, so the meter is permanently empty and the fader
   permanently inaudible. Phil read that silence as a bug, which is fair — it
   looked identical to a track that simply had not played yet.
   Hatched rather than merely dimmed, so it does not read as "quiet". */
.phch.phch-nobudget {
  background:repeating-linear-gradient(135deg,
             rgba(0,0,0,0.30) 0 6px, rgba(0,0,0,0.16) 6px 12px);
}
.phch.phch-nobudget > :not(.phch-ms) { opacity:0.40; }
.phch.phch-nobudget .phch-meter { opacity:0.35; }
.phch.phch-nobudget .phch-fader { pointer-events:none; }
.phch.phch-dead > :not(.phch-ms):not(.phch-meter) { opacity:0.35; }
/* `flex:1` was here, and it is the rule that made the M/S row wander -- see the
   .phch-name block above, which now pins it to flex:0 0 auto. Removed rather
   than edited in place so there is ONE declaration of this property: as the
   later rule it silently won, which is how a fix applied above could look
   applied and do nothing. */
.phch-ms { display:flex; gap:3px; margin-top:1px; }
.phch-btn {
  background:var(--bg-rail); color:var(--text-dim); border:1px solid var(--border-control);
  border-radius:var(--radius-control);
  font-family:inherit; font-size:9px; letter-spacing:0.5px;
  padding:1px 5px; cursor:pointer; user-select:none; min-width:18px;
  line-height:1.2;
}
/* THE STRIP'S OWN BUTTONS SHARE THE ROW EQUALLY. `flex:1 1 0` with min-width:0
   overrides the content-derived min-widths above, so three buttons come out the
   same size and together span the name field's width. Scoped to .phch-btns so
   the same .phch-btn class keeps its content width wherever else it is used. */
.phch-btns > .phch-btn {
  flex:1 1 0; min-width:0; padding:1px 2px;
  display:flex; align-items:center; justify-content:center;
}
/* :not(:disabled) since mixer round 2 · U8 — the Mixer window's bus M/S buttons
   share this class and CAN be disabled (their op is a sibling branch), and a
   greyed control that still lights under the pointer is a control that claims
   it can be pressed. No docked strip has ever had a disabled one, which is why
   the guard was not needed until now. */
.phch-btn:hover:not(:disabled) { background:var(--bg-chip-hover); color:var(--text-hi); }
/* THE .medz MARK (signal routing phase 5, U5). A track past trackVolCapacity
   has a working fader whose value the .med cannot hold, so the strip says
   where the number goes. It sits at the BOTTOM of the top group, so it eats
   the column's slack and the readout and pan below it do not move -- pan is a
   comparison control and its row has to line up across the whole header.
   flex:0 0 auto so it never stretches; --accent-ink because the fader works
   and the value is kept, which is a fact about WHERE and not a refusal. */
.phch-medz { flex:0 0 auto; font-size:8px; letter-spacing:.5px; line-height:1.2;
             color:var(--accent-ink); text-align:center; cursor:help; }
/* ── THE CHANNEL ACTIVATION BUTTON (task #11, 2026-08-25) ──────────────────
   Phil: the mute button becomes a channel ACTIVATION button carrying the
   channel number -- "when they're on they're illuminated, when muted, they go
   red or an appropriate theme colour".

   PROVENANCE: this is AMIGA OCTAMED'S numbered channel button, not a DAW's.
   Phil corrected that while it was being built, and it matters -- OctaMED is
   the original this app reimplements, so copying it is the point.

   THE SENSE IS INVERTED FROM WHAT THE CLASS SAYS, deliberately and only here.
   `.on` still means MUTED, because that is what trackMute[] means and what
   refreshTrackButtonStyles sets; what changed is which state looks lit. Renaming
   the class would have touched the toggle, the solo logic and the gates for a
   purely visual decision, so the flip lives in these two rules and nowhere else.

   ILLUMINATED = the channel plays. This is the RESTING state of nearly every
   button on screen, so it is deliberately quiet: an accent ground rather than a
   filled accent, which at 28 channels would be a wall of colour. */
.phch-btn.phch-mute {
  background:var(--accent-ground-soft); border-color:var(--accent-dim);
  color:var(--accent-hi); min-width:20px;
}
/* SILENCED. --mute-* is the existing family, already contrast-fitted per theme,
   so "red or an appropriate theme colour" is a token that already exists rather
   than a hex picked here. */
.phch-btn.on.phch-mute { background:var(--mute-bg); border-color:var(--mute-edge); color:var(--mute-ink); }
.phch-btn.on.phch-solo { background:var(--solo-bg); border-color:var(--solo-edge); color:var(--solo-ink); }

/* Row: extend background to the FULL pattern width (not just the
   visible viewport) so the 4n+1 / 16n+1 stripes and the cur-row
   amber highlight paint across all columns even when horizontally
   scrolled. `width:max-content` makes .pr size to the natural sum-of-
   children width; `min-width:100%` ensures the highlight still fills
   the viewport even when total pattern width is less than viewport.
   Without this, the highlights only painted to viewport width and the
   stripes/cur-row cut off mid-scroll. Phil 2026-05-22. */
/* MPT-fallback flow rows (non-MED formats): normal flow, nth-child stripes. */
#pat-rows { font-family: var(--font-pattern); }
.pr { display:flex; border-bottom:1px solid var(--bg-rail); width:max-content; min-width:100%; }
.pr:not(.prv):nth-child(4n+1)  { background:var(--stripe-beat); }
.pr:not(.prv):nth-child(16n+1) { background:var(--stripe-bar); }
/* Virtualized rows (native MED path, app.js patView): a fixed pool of
   absolutely-positioned rows recycled as the window scrolls. `top` is set
   inline per row; total DOM stays ~120 rows regardless of block size.
   Stripe classes replace nth-child (slot order shuffles on recycle). */
.pr.prv { position:absolute; left:0; top:0; width:var(--pr-w, 2000px);
          height:var(--pr-h, 17px); box-sizing:border-box;
          /* Skip layout/paint for the overscan rows above/below the
             viewport — combined with virtualization, each reflow lays out
             only the ~visible rows, not the whole pool. Explicit height is
             the skipped-size, so scroll geometry stays exact. */
          content-visibility:auto;
          contain-intrinsic-size:var(--pr-w, 2000px) var(--pr-h, 17px); }
.pr.prv.s4  { background:var(--stripe-beat); }
.pr.prv.s16 { background:var(--stripe-bar); }
/* Rows the composer marked by hand (MED's hlmask) — a bar down the row-number
   gutter's left edge, the DOM twin of the canvas path's markBar. `inset`
   box-shadow rather than a border so it costs no layout and cannot shift a
   column. Amber, matching the main controls; the play-row tint it sits over is
   only 11-14% alpha. Unrelated to .s4/.s16, which are arithmetic from
   lines-per-beat. */
.pr.prv.hl .prn { box-shadow: inset 3px 0 0 var(--accent-hi); }
/* The row number is the toggle for its own mark, so it has to look clickable. */
.pr.prv .prn { cursor:pointer; }
/* Canvas grid active: the canvas draws the whole pattern, so the DOM pool rows
   are hidden behind it. display:none removes them from layout+paint entirely,
   so scrolling only moves the canvas layer (no per-row content-visibility work
   under the canvas — the source of the residual scroll jitter, Phil 2026-07-24).
   Scoped to .pr.prv (the pooled rows), NOT the bare .pr geometry probe, so the
   slow-path row measurement still works while canvas is on. */
#pat-rows.cg-on .pr.prv { display:none; }
.pr.cur { background:var(--play-row) !important; }
.pr.cur .prn { color:var(--grid-row-play); }
/* Marked range (Phase 2) — DOM-mode overlay only; in canvas mode the overlay
   canvas draws the rectangle. Positioned in #pat-rows content coords by editor
   js/paintDomSel. z above the pooled rows, click-through. */
#pat-sel { position:absolute; z-index:3; pointer-events:none; box-sizing:border-box;
           background:var(--sel-fill); border:1px solid var(--sel-edge); }

/* Marching ants on the last copied/cut range (Excel/Sheets convention).
   A CSS border cannot animate its dashes, so the outline is four gradient
   strips whose background-position is animated instead. Sits BELOW #pat-sel so
   a live selection still reads as the primary mark. */
#pat-clip { position:absolute; z-index:2; pointer-events:none; box-sizing:border-box;
  background:
    repeating-linear-gradient(90deg, var(--accent-fill) 0 5px, transparent 5px 10px) left top/100% 1px,
    repeating-linear-gradient(90deg, var(--accent-fill) 0 5px, transparent 5px 10px) left bottom/100% 1px,
    repeating-linear-gradient(0deg,  var(--accent-fill) 0 5px, transparent 5px 10px) left top/1px 100%,
    repeating-linear-gradient(0deg,  var(--accent-fill) 0 5px, transparent 5px 10px) right top/1px 100%;
  background-repeat:no-repeat;
  animation:pat-ants 0.6s linear infinite;
}
@keyframes pat-ants {
  to { background-position: 10px top, -10px bottom, left -10px, right 10px; }
}
/* Still legible, just still. */
@media (prefers-reduced-motion: reduce) { #pat-clip { animation:none; } }

.prn { position:sticky; left:0; z-index:4; background:var(--bg-pattern);
       min-width:38px; padding:1px 6px 1px 0; text-align:right; color:var(--grid-row); border-right:1px solid var(--seam-soft); font-size:11px; flex-shrink:0; }
/* Cur-row + stripe tints must reach the frozen gutter too, or it reads as a
   dead column. The gutter bg is opaque (to occlude scrolled cells), so paint
   the tint ONTO it rather than relying on the row bg showing through. */
.pr.prv.s4  .prn { background:var(--stripe-beat-gutter); }
.pr.prv.s16 .prn { background:var(--stripe-bar-gutter); }
.pr.cur     .prn { background:var(--play-row-gutter); }
/* Pattern data cell. Width must match `.phch` above (132px). With fixed
   width, horizontal scrolling kicks in when channel count exceeds the
   viewport — better than stretching cells to oddly different widths and
   misaligning over the header. */
.pc  { flex:0 0 var(--pc-w, 115px); padding:1px 5px; border-right:1px solid var(--btn-press); white-space:nowrap; overflow:hidden; font-size:11px; letter-spacing:0.3px; }
.pc:last-child { border-right:none; }

/* Cell colour coding. Per-digit spans (i1/i2 etc.) carry the editor's
   cursor stops — same palette as the old whole-column spans. */
.nn  { color:var(--grid-note); }
.ni, .i1, .i2 { color:var(--grid-instr); }
.nv  { color:var(--grid-vol); }
.nc, .c1, .c2 { color:var(--grid-cmd); }
.np, .a1, .a2 { color:var(--grid-level1); }
.b1, .b2 { color:var(--grid-level2); }   /* level-2 byte (cmdExt) — dimmer than np so the extension byte is visually distinct from the primary level-1 param */
.ne  { color:var(--text-dim); opacity:.2; }
/* Level-2 column on a block with NO cmdExt storage. Drawn anyway so the
   track columns keep a fixed width from block to block (they used to jump
   as the song moved between blocks), but dimmer than an empty cell and
   never a cursor stop — see stopsFor(). Must beat .ne, which also matches. */
.nx, .nx.ne { color:var(--text-dim); opacity:.08; }

/* ── PATTERN EDITOR (editor.js) ─────────────────────────── */
/* Focus ring: editor keys are captured only while the pane has focus —
   make that state unmistakable (Phil 2026-07-23: "should be more obvious
   when the pattern area is in focus and keyboard is enabled"). Focused =
   solid amber ring + lit bar; unfocused = KEYS OFF hint in the bar. */
/* :focus drives the visuals directly off document.activeElement (no JS
   events involved); the .ed-focus class is kept as belt-and-braces.
   PERF: the focus ring MUST NOT be an inset box-shadow — an inset shadow on
   a SCROLL CONTAINER forces the browser to repaint the whole scrollport on
   every scroll frame (Phil 2026-07-23: "jerky when the pattern is in focus,
   better when it's not"). A border colour change is free: painted once, no
   per-scroll repaint, and no layout shift because the width never changes. */
#pattern-area { outline:none; border-width:2px; }
#pattern-area.ed-focus, #pattern-area:focus { border-color: var(--accent-ink); }
/* #ed-bar moved OUT of #pattern-area (2026-07-27), so the bar's focus styling
   can no longer be a descendant selector off the pane — editor.js mirrors the
   focus state onto the bar itself as .ed-focus. (The old
   `#pattern-area.ed-focus #ed-bar` form silently stopped matching, which left
   "KEYS OFF — CLICK PATTERN" stuck on permanently.) */
#ed-bar.ed-focus { background:var(--accent-ground-deep); border-bottom-color:var(--accent-dim); }
/* THE KEYS HINT LIVES IN THE STATUS BAR NOW, hard right (Phil, 2026-08-12:
   "it quite frequently causes the UI to shift if the window is a certain
   size"). In the edit toolbar it was an item in a WRAPPING flex row, so
   showing and hiding it re-flowed everything around it at the widths where the
   row was close to wrapping.

   FADED, NOT display:none — the space stays reserved whether or not the hint is
   showing, so nothing moves at all. That is the whole point of the change; a
   `display` toggle down here would just have relocated the shift. The status
   bar has room to spare for it.

   AND THE HIDE RULE IS NO LONGER A DESCENDANT OF #ed-bar. It used to be
   `#ed-bar.ed-focus #ed-keys-hint`, which stops matching the moment the element
   moves — the comment above records that exact failure happening once before,
   leaving the hint stuck on permanently. :has() asks the question directly
   instead: is the pattern pane focused, by either the real :focus or the class
   editor.js mirrors. */
/* A TRANSLUCENT WASH, NOT AN OPAQUE TINT. --accent-ground is mixed from the
   PANEL, and this chip does not sit on the panel -- it sits on the status bar,
   which several themes colour differently. Measured against each theme's own
   bar before the change: 8.25:1 on Professional, whose status bar is BLACK and
   whose chip was therefore a bright pink-grey slab, and 1.55:1 on Das
   Nonplusultra, against 1.22-1.31 in the themes where the bar does match the
   panel and the effect was the intended faint tint. Phil, 2026-08-23: '"keys
   off - click pattern" is a different shade from the rest of the UI... in
   several themes.'
   An alpha composites over whatever is actually behind it, so it cannot get
   this wrong again in a theme nobody has written yet -- the same reason the
   play row is an alpha rather than a colour. --accent-a16 already exists and
   is the accent at 16%.

   THE INK COMES FROM THE STATUS BAR TOO, for the same reason. It was
   --accent-hi, and on Professional that is #0000AA on a near-black bar:
   1.56:1, unreadable, and no halo can rescue it because a black shadow
   under a nearly black ink draws nothing. --status-ink is the bar's own
   ink and reads on it by construction in every theme; the accent identity
   is carried by the wash behind, which is what a chip is for. */
#ed-keys-hint { color:var(--status-ink); background:var(--accent-a16); padding:1px 8px;
                border-radius:2px; letter-spacing:1px; white-space:nowrap;
                transition:opacity .12s ease; }
body:has(#pattern-area:focus) #ed-keys-hint,
body:has(#ed-bar.ed-focus)    #ed-keys-hint { opacity:0; }
/* Groups the hint with the existing right-hand readout so #statusbar's
   space-between does not spread the two apart. */
.status-right { display:flex; align-items:center; gap:10px; flex:none; }

/* Unsaved-changes dialog — narrow, three explicit outcomes. */
.unsaved-dialog { max-width: 520px; }
.unsaved-dialog .sd-body p { margin: 0 0 14px; line-height: 1.5; }
.unsaved-btns { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.unsaved-btns .btn { flex: 1 1 auto; min-width: 120px; padding: 7px 10px; }
/* OLDER ABANDONED AUTO-SAVES in the resume prompt: one row each, name and
   time, and the same two buttons as the main offer, smaller. */
.resume-others { display:flex; flex-direction:column; gap:4px; margin-bottom:10px;
  border-top:1px solid var(--border); padding-top:8px; }
.resume-row { display:flex; align-items:center; gap:8px; font-size:11px; }
.resume-row-name { flex:1 1 auto; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.resume-row-when { color:var(--text-dim); flex:0 0 auto; }
.resume-row .btn { flex:0 0 auto; padding:3px 8px; font-size:10px; }
/* "Already open in another tab" on the welcome screen. */
.wc-elsewhere { width:min(560px, 100%); font-size:11px; color:var(--text-dim);
  border:1px solid var(--border); background:var(--bg-inset); padding:6px 10px; }

/* Generic confirm dialog (js/confirm-dialog.js). Borrows the unsaved dialog's
   paragraph rhythm — the counts a structural op reports ARE the content, and
   they only read as separate facts if they are separate lines. Prefixed with
   .cfm-dialog rather than left at .sd-dialog specificity: the shared .fr-btns
   and .set-hint rules below would otherwise win or lose on source order alone,
   which has bitten this file twice. */
.cfm-dialog { max-width: 480px; }
.cfm-dialog .sd-body p { margin: 0 0 12px; line-height: 1.5; }
.cfm-dialog .sd-body p:last-child { margin-bottom: 0; }
/* .fr-btns is right-aligned, so CONFIRM lands in the corner and CANCEL sits
   inboard of it. Same button height as everywhere else — extra padding here
   would make this dialog look heavier than the panels it interrupts. */
.cfm-dialog .cfm-btns { margin-top: 16px; }
.cfm-dialog .cfm-btns .btn { min-width: 92px; padding: 6px 14px; }
.cfm-dialog .gen-warn { margin-top: 10px; }
.cfm-dialog .set-hint { margin-top: 12px; }

/* Cursor: cell outline + inverse-video active stop (MED-style). */
.pc.cur-cell { outline:1px solid var(--cursor-line); outline-offset:-1px; position:relative; }
/* Edit-cursor flash (DOM mode; the canvas grid does its own timed repaint).
   Only the inverse-video stop pulses — the .cur-cell outline stays put, so the
   cursor never disappears. editor.js adds .cur-blink while EDIT is on AND the
   pane has focus. `steps(1)` = a hard on/off, matching the canvas blink. */
@keyframes edCursorBlink { 0%,49% { opacity:1 } 50%,100% { opacity:0 } }
#pat-rows.cur-blink .pc.cur-cell .cs {
  animation: edCursorBlink 0.9s steps(1, end) infinite;
}
.pc .cs { background:var(--accent-fill); color:var(--on-accent) !important; opacity:1; border-radius:1px; }
/* Advance preview arrow: where the cursor goes after entry (editor.js sets
   adv-d / adv-r on the cursor cell when edit mode is on). */
.pc.cur-cell.adv-d::after, .pc.cur-cell.adv-r::after {
  position:absolute; font-size:8px; line-height:1; color:var(--accent-hi);
  pointer-events:none; text-shadow:0 0 2px var(--shadow-cursor); }
.pc.cur-cell.adv-d::after { content:'▼'; bottom:-1px; left:50%; transform:translateX(-50%); }
.pc.cur-cell.adv-r::after { content:'▶'; right:0; top:50%; transform:translateY(-50%); }

/* Editor status bar. Fixed 22px height; #pat-hdr's sticky top offset
   below MUST match so the two bars stack instead of overlapping. */
/* Editor control bar — now a top-region row beside the transport (was a
   sticky strip inside #pattern-area). Wraps instead of clipping; grouped
   into labelled clusters (.edg). Phil 2026-07-27. */
#ed-bar { display:flex; align-items:center; flex-wrap:wrap; gap:var(--d-edbar-gap); padding:var(--d-edbar-pad);
          background:var(--bg-bar); border-bottom:1px solid var(--seam-soft); font-size:10px;
          letter-spacing:1px; color:var(--text-dim); user-select:none;
          flex-shrink:0; }
#ed-bar.hidden { display:none; }
/* One cluster: a labelled inset group, same visual family as .tg */
.edg { display:flex; align-items:center; gap:4px;
       background:var(--bg-inset); border:1px solid var(--border);
       padding:3px 6px; }
.edg-l { font-size:8px; color:var(--ink-off); letter-spacing:1px; margin-right:2px; }
/* NO BOX. .edg paints a background, border and padding for a GROUP OF
   CONTROLS; this one holds a single status dot that is visibility:hidden when
   the module is clean, so it rendered as an empty square — the more so since
   the keys hint moved out of it to the status bar on 2026-08-12. */
/* .edg-file is gone: the dot it held now sits on the SAVE button. */
.ed-flag { padding:1px 6px; border:1px solid var(--border-control);
           border-radius:var(--radius-control);
           cursor:pointer; color:var(--ink-off); }
/* A lit flag IS a selected button, so it takes the lit-button pair -- under
   Professional that is OctaMED's inverse video (black fill, grey ink), and
   it must not borrow --accent-fill, which is that theme's pink CURSOR. */
.ed-flag.on { color:var(--btn-on-ink); background:var(--btn-on-bg); border-color:var(--btn-on-bg); }
/* Hold cell (note tied from an earlier row) — dimmed. Class, not an inline
   style, so single-cell re-renders can turn it on and off. */
.pc.pc-hold { opacity:0.6; }
/* A track column this BLOCK does not have, drawn so the grid stays the song's
   width instead of resizing per block. Reads as "outside the block" rather than
   "an empty cell you could type into": recessed rather than dimmed, because
   .pc-hold already owns dimming and .nx (the inert level-2 digits) already owns
   near-invisible. Nothing inside it — no data-t, so it cannot take the cursor. */
.pc.pc-dead {
  background: repeating-linear-gradient(135deg,
              transparent 0 6px, rgba(0,0,0,0.22) 6px 12px);
  box-shadow: inset 0 0 0 9999px rgba(0,0,0,0.30);
  pointer-events: none;
}
/* ADV-down suppressed: FOLLOW is stepping the cursor for us while playing, so
   the flag is on but inert. Hollow it out instead of lying about it. */
.ed-flag.on.sup { background:transparent; color:var(--accent-ink); border-style:dashed; }
.ed-ind  { color:var(--text-dim); }
/* Bar dropdowns (octave / instrument / page / spacing). */
.ed-sel { color:var(--text-dim); display:inline-flex; align-items:center; gap:3px; }
/* --bg-inset, not a literal #222. This was the last field family OUTSIDE the
   dialogs still carrying its own background, and it is what made the
   text-field-vs-drop-down mismatch app-wide rather than dialog-wide. */
.ed-sel select {
  background:var(--bg-inset); color:var(--accent-hi); border:1px solid var(--border-control);
  border-radius:var(--radius-control); font:inherit; font-size:10px; padding:0 2px; height:16px;
  cursor:pointer; max-width:150px; }
.ed-sel select:hover { border-color:var(--accent-dim); }
/* THE INSTRUMENT DROPDOWN IS THE ONE WITH A NAME IN IT, so it is the one
   that needs room. Phil, 2026-09-06: "instrument name in the pattern editor
   bar needs to be a little wider to not cut off names."

   264px is measured, not guessed: the bar font is 10px Share Tech Mono at
   5.43px per character, so the widest option a module can produce -- "NN "
   plus MED's 40-character name limit -- is 234px of text, and the select's
   own border, padding and arrow measure 26px on top of that (a 40-character
   name measures 259px in the shipping bar). At 1280px the
   four bar groups end at x=1014 with 266px of slack, so the extra 114px
   here still leaves the row unwrapped with room to spare (measured).

   A select is sized by its widest OPTION, so this is a cap and not a width:
   a module of short names keeps a short box, and the bar does not grow for
   songs that do not need it. text-overflow covers the case the cap does
   bite -- a name longer than MED's own limit, arriving from a file we did
   not write -- so it ends in an ellipsis rather than mid-word. */
#ed-inst-sel { max-width:264px; text-overflow:ellipsis; }
.ed-sel select:disabled { opacity:.4; cursor:default; }
/* THE UNSAVED-EDITS DOT, on the button that acts on it.
   Absolutely positioned in the button's top-right corner so it cannot change
   the button's width — SAVE sits in a fixed toolbar row, and a marker that
   reflowed its neighbours every time you typed a note would be worse than no
   marker. `visibility` rather than `display` because js/editor.js toggles that
   property by name. */
#btn-save { position:relative; }
#ed-dirty {
  position:absolute; top:1px; right:2px;
  font-size:9px; line-height:1;
  color:var(--accent-hi); visibility:hidden;
  pointer-events:none;
  text-shadow:0 0 3px var(--shadow-strong);
}
/* A disabled SAVE is greyed, and the dot must not be: it is the reason you
   would want the button, so it stays legible even when the button is not
   currently actionable. */
#btn-save:disabled #ed-dirty { color:var(--accent-hi); opacity:1; }
#ed-bar .btn { font-size:9px; padding:1px 8px; }

/* ── RIGHT PANEL: collapsible module-info + message ──────── */
#modinfo-header { cursor:pointer; display:flex; align-items:center; gap:5px; }
#modinfo-header:hover { color:var(--accent-ink); }
/* .mi-caret STAYS: MODULE INFO no longer uses it, but Settings ▸ Module
   compatibility still does (index.html:1355, and the rotate at #compat-section
   .collapsed below). */
.mi-caret { font-size:8px; display:inline-block; transition:transform .12s; }
/* DEAD RULES REMOVED 2026-08-12: `#modinfo-section.collapsed .mi-caret` and
   `#modinfo-section.collapsed #modinfo-body { display:none }`. Nothing adds
   `.collapsed` to that section any more — the dockable-sections work moved
   MODULE INFO onto .sec-collapsed / .sec-folded like the other three, and its
   header carries .sec-caret. Left alone the second one was a loaded gun:
   display is not animatable, so the day anything re-added that class the fold
   would snap, which is the exact symptom this area has been chasing. */
.mi-sub { font-size:8px; letter-spacing:2px; color:var(--ink-off); padding:3px 7px 0; }
/* Module annotation. max-height + scroll already handled long PROSE, but a
   30,000-char message with no spaces (Win MED allows both a short and a long
   annotation, each up to 30k) is one unbroken token, which overflowed the
   panel sideways instead of wrapping — hence overflow-wrap/word-break. */
#i-msg, #i-long { font-size:11px; color:var(--text-dim); white-space:pre-wrap;
         max-height:80px; overflow-y:auto; overflow-x:hidden;
         overflow-wrap:anywhere; word-break:break-word; }

/* ── WELCOME SCREEN ─────────────────────────────────────── */
#welcome { display:flex; flex-direction:column; align-items:center; gap:16px;
           max-width:660px; padding:22px; color:var(--text-dim); }
.wc-brand { text-align:center; }
/* Logo mark (inline SVG so these variables reach it).
   THE THEMING PASS THIS COMMENT USED TO DEFER HAPPENED (2026-08-21). The mark
   no longer sits outside the app's scheme: every theme block at the end of this
   file sets --logo-ink to its own brand ink, and the theme HUE is derived FROM
   that ink, so chrome and mark cannot drift apart. The values below are the
   amber theme's, which is the scheme the app shipped with.
   window.logoColour() in js/app.js still previews an ink on its own; it predates
   themes and does not change the chrome. */
:root {
  --logo-bg:  #0C111E;   /* near-black rounded tile, from the source artwork */
  --logo-ink: #FF7A00;   /* Workbench orange, the amber theme's mark. The other
                            five are set by their own theme blocks below.   */
  --logo-glow: 0 0 22px rgba(255,122,0,.30);
}
/* Hero scale (Phil 2026-07-28: "at least 2x"). clamp() keeps it a hero on a
   wide window without swamping a narrow one — 64→140px logo, 23→48px wordmark. */
.wc-logo { width:clamp(96px, calc(11vw / var(--ui-scale)), 140px); height:clamp(96px, calc(11vw / var(--ui-scale)), 140px);
           display:block; margin:0 auto; filter:drop-shadow(var(--logo-glow)); }
.wc-name { font-size:clamp(34px, calc(4.4vw / var(--ui-scale)), 48px); letter-spacing:.16em; color:var(--accent-hi);
           margin-top:18px; line-height:1; }
.wc-tag  { font-size:clamp(13px, calc(1.5vw / var(--ui-scale)), 16px); letter-spacing:1.5px; margin-top:12px;
           color:var(--text-hi); }
/* (.wc-tag2 — the explanatory second line — was dropped 2026-07-28: clutter on
   a welcome screen. That copy belongs in the documentation.) */
.wc-drop { display:flex; align-items:center; gap:9px; font-size:12px;
           border:1px dashed var(--border); padding:9px 16px; }
.wc-drop-i { font-size:16px; color:var(--accent-ink); }
.wc-recent { width:min(560px, 100%); border:1px solid var(--border); background:var(--bg-inset); }
.wc-recent-h { background:var(--bg-header); color:var(--header-ink); font-size:9px;
               letter-spacing:2px; padding:3px 8px; border-bottom:1px solid var(--border); }
.wc-recent-empty { padding:8px 10px; font-size:11px; color:var(--text-dim); }
.wc-recent-row { display:flex; justify-content:space-between; gap:12px; width:100%;
                 background:none; border:0; border-bottom:1px solid var(--border-control); color:var(--text);
                 font:inherit; font-size:11px; padding:5px 10px; text-align:left; cursor:pointer; }
.wc-recent-row:hover { background:var(--bg-header); }
/* Columns: the name takes the slack; format/size, source and date are fixed
   widths so they line up down the list. */
.wc-recent-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; }
.wc-recent-meta { color:var(--text-dim); flex:0 0 118px; font-size:10px; letter-spacing:.5px; text-align:left; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.wc-recent-when { color:var(--text-dim); flex:0 0 74px; text-align:right; }
.wc-recent-src { color:var(--text-dim); flex:0 0 46px; font-size:10px; letter-spacing:1px; text-transform:uppercase; text-align:left; }
.wc-recent-foot { padding:6px 10px; text-align:right; }
.wc-cols { display:flex; gap:12px; flex-wrap:wrap; justify-content:center; }
.wc-card { flex:1 1 260px; min-width:230px; background:var(--bg-inset);
           border:1px solid var(--border); }
.wc-h { background:var(--bg-header); color:var(--header-ink); font-size:9px;
        letter-spacing:2px; padding:3px 8px; border-bottom:1px solid var(--border); }
.wc-b { padding:8px 10px; font-size:11px; line-height:1.65; color:var(--text-dim); }
.wc-b b { color:var(--text); font-weight:normal; }
.wc-foot { font-size:10px; opacity:.5; }
/* The welcome box owns its own layout — the dropzone is just its frame. */
#dropzone.show { display:flex; align-items:center; justify-content:center; }

/* ── STATUS BAR ─────────────────────────────────────────── */
#statusbar { height:var(--status-h); background:var(--bg-status); border-top:1px solid var(--seam); padding:0 10px; font-size:11px; color:var(--status-ink); display:flex; align-items:center; justify-content:space-between; flex-shrink:0; }
/* setStatus() interpolates the song name and the loaded filename, both of which
   can be 30,000 chars from a Win MED file. The bar is a fixed-height flex row,
   so an unclamped child distorts it; min-width:0 is required for a flex item
   to be allowed to shrink below its content width. */
#status-msg { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }

/* ── LOADING ────────────────────────────────────────────── */
/* Top of everything: a blocking loader must cover the menus (1000) too. */
#loading { display:none; position:fixed; inset:0; background:rgba(0,0,0,.82); z-index:2000; align-items:center; justify-content:center; flex-direction:column; gap:14px; color:var(--accent-ink); font-size:13px; letter-spacing:2px; }
#loading.show { display:flex; }
.spin { width:28px; height:28px; border:3px solid var(--accent-dim); border-top-color:var(--accent-hi); border-radius:50%; animation:spin .8s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }

/* ── SCROLLBARS ─────────────────────────────────────────── */
::-webkit-scrollbar { width:11px; height:11px; }
::-webkit-scrollbar-track { background:var(--scrollbar-track); }
::-webkit-scrollbar-thumb { background:var(--scrollbar-thumb); border-radius:6px; border:2px solid var(--bg-inset); }
::-webkit-scrollbar-thumb:hover { background:var(--scrollbar-thumb-hover); }
/* The box where the H and V scrollbars meet — unstyled it renders as a stark
   white square. Match the track so it disappears into the chrome. */
::-webkit-scrollbar-corner { background:var(--scrollbar-track); }

/* Right-click context menu (pattern editor ops + their key chords). */
#ctx-menu { position:fixed; z-index:1000; min-width:var(--d-ctx-minw); padding:var(--d-menu-pad);
  background:var(--bg-menu); border:1px solid var(--border); border-radius:5px;
  box-shadow:0 6px 20px var(--scrim-soft); font-size:11px; user-select:none; }
.ctx-item { display:flex; justify-content:space-between; align-items:center; gap:24px;
  padding:var(--d-menuitem-pad); border-radius:3px; cursor:pointer; color:var(--menu-ink); white-space:nowrap; }
.ctx-item:not(.disabled):hover { background:var(--menu-hi-bg); color:var(--menu-hi-ink); }
.ctx-key { color:var(--menu-ink-dim); font-size:10px; letter-spacing:0.3px; }
.ctx-item:not(.disabled):hover .ctx-key { color:var(--menu-hi-key); }
/* Unavailable commands stay visible and greyed, reason in the tooltip — same
   rule as the menu bar. */
.ctx-item.disabled { color:var(--menu-ink-dim); cursor:default; }
.ctx-item.disabled .ctx-key { color:var(--menu-ink-off); }
.ctx-sep { height:1px; background:var(--sep); margin:4px 6px; }
/* THE FLYOUT is the same box as its parent, appended to <body> so it can escape
   the parent's bounds, and one z-index above it. Everything inside is a .ctx-item
   already, so it inherits every row rule above without repeating one of them. */
/* ═══ SAMPLE-OPERATION PARAMETER DIALOG ═══════════════════════════════
   A short form, so it is sized by its content rather than pinned to the wide
   default .sd-dialog width. The label column is fixed so four rows of different
   label lengths still line their controls up. */
.sd-dialog.sop-dialog { width:min(340px, calc(94vw / var(--ui-scale))); }
/* THE NOTE FITS THE WINDOW. Phil, 2026-08-31: "can the explanatory text fit
   the width of the window?" It could not: MEASURED computed margin on
   #sop-note was 2px 0 12px 112px - the shared .fr-help default leaking in, the
   SAME failure this file already documents for #ipr-loopnote a few hundred
   lines down. 112px of indent inside a 253px body left the sentence 141px to
   live in, so it stacked up three lines deep against the left edge of nothing.
   The dialog is a column of label/control pairs, not an indented form, so the
   note belongs at the same left edge as everything else in it. */
.sop-dialog .fr-help { margin-left:0; }
/* THE RESPONSE CURVE inside a sample-operation dialog. Full width, no label
   column: the picture is the label, and a 96px gutter beside a graph is just a
   graph that is 96px too narrow. The height is fixed so the dialog does not
   resize as parameters change - things have a fixed place. */
/* .sop-row.sop-curverow, not .sop-curverow: the bare class lost to .sop-row's
   display:flex below at equal specificity, so the canvas and its caption sat
   SIDE BY SIDE and the caption was ellipsised to whatever width the canvas
   left - Phil, 2026-09-02: "text about the tap count to the right of the graph
   which is mostly truncated". Under the graph is where it was meant to be. */
.sop-row.sop-curverow { display:block; margin:2px 0 10px; }
.sop-curve {
  display:block; width:100%; height:76px;
  border:1px solid var(--border); background:var(--bg-inset);
  border-radius:var(--radius-control);
}
/* Two lines reserved, so a long caption (the echo ladder's) wraps rather
   than being cut, and the dialog keeps one height whatever it says. */
.sop-curvecap {
  font-size:10px; color:var(--text-dim); margin-top:3px;
  white-space:normal; overflow:hidden; min-height:2.5em; max-height:2.5em; line-height:1.25em;
}
.sop-row { display:flex; align-items:center; gap:8px; margin-bottom:6px; }
/* A HIDDEN ROW KEEPS ITS PLACE. The builder hides a field that does not apply
   (the Filter's upper frequency for a low or high pass) with the `hidden`
   attribute; the flex rule above would beat the attribute's display:none, and
   display:none would let the rows below jump up - the dialog resizing as a
   type changes, which is the thing a fixed place forbids. Invisible, in situ. */
.sop-row[hidden] { display:flex; visibility:hidden; }
/* The preview strip under the fields: a play/stop button, a loop box and a
   one-line status, in the dialog's own dim text. */
.sop-preview { display:flex; align-items:center; gap:10px; margin:4px 0 8px; }
.sop-pv-loop { display:inline-flex; align-items:center; gap:4px; font-size:11px; color:var(--text-dim); }
.sop-pv-status { font-size:10px; color:var(--text-dim); }
.sop-lbl { flex:0 0 148px; color:var(--text-dim); font-size:11px; letter-spacing:0.5px; }
.sop-in { flex:0 1 auto; width:84px; }
.sop-in[type="checkbox"] { width:auto; }
select.sop-in { width:auto; max-width:150px; }
/* The unit sits with the value, not with the label — "300 ms" reads as one
   thing, and these are ported numbers where the unit is the whole story. */
.sop-unit { color:var(--text-dim); font-size:10px; letter-spacing:0.5px; }
/* The dialog's text and preset-button rows (Change pitch, Time stretch). */
.sop-text { font-size:10px; color:var(--text-dim); letter-spacing:.3px; }
/* A button row wraps as a whole: the label takes only its width and the
   buttons flow after it, onto a second line when five will not fit 340px. */
.sop-row.sop-btnrow { flex-wrap:wrap; }
.sop-btnrow .sop-lbl { flex:0 0 auto; }
.sop-btnrow .sop-lbl:empty { display:none; }
.sop-btns { display:contents; }
.sop-btns .btn { padding:1px 7px; font-size:10px; min-width:0; }
.sop-say { font-size:10px; color:var(--text-dim); margin-left:6px; }
.sd-dialog.swe-dialog { width:min(430px, calc(94vw / var(--ui-scale))); }
/* 100px, not 72 (2026-09-27). Three dialogs share this column -- WAM Sample
   Effect, CDP Sample Effect and Generate with CDP -- and at 72 their longest
   labels broke in two at the SHIPPED font and size: "Processing rate" wants
   97.2px, "Output tuning" 84.2, "Duration (s)" 77.8. 100 clears all three,
   and matches .voc-dialog's 96px next door. The control beside it is flex:1,
   so it gives up the 28px. */
/* FIRST label only: it is the row's field name and wants the column. A row can
   carry more label+control pairs inline (Rate / Depth / Channels), and giving
   those the column too pushed that row 11px past its width. They keep their
   text width. */
.swe-dialog .fr-row > label:first-child { width:100px; flex:0 0 100px; }
.swe-dialog input[type="range"] { flex:1; }
.swe-dialog select { flex:1; min-width:0; }
.swe-dialog .fr-help { margin-left:0; }
.swe-dialog #swe-plugin-info { margin-bottom:12px; }
.swe-dialog #swe-note { min-height:18px; }
.swe-chain { min-height:32px; max-height:150px; overflow:auto; margin:8px 0 12px; border:1px solid var(--border-control); padding:4px; }
.swe-chain-row { display:flex; align-items:center; gap:6px; padding:4px; border-bottom:1px solid var(--sep); cursor:grab; }
.swe-chain-row:last-child { border-bottom:0; }
.swe-chain-row.bypassed { opacity:.55; }
.swe-chain-name { flex:1; }
.swe-gui { max-height:180px; overflow:auto; margin:8px 0; }
/* The freeform '$DATA' block five generators take (a chord's notes, a click
   track's times, an additive spectrum). Monospace because every one of them is
   whitespace-delimited numbers the user is lining up by eye. */
.cgn-text-row { align-items:flex-start; }
.cgn-text-row textarea { flex:1; min-width:0; resize:vertical; font-family:var(--font-text); font-size:11px; }
/* [hidden] IS NOT ENOUGH ON ITS OWN, for the reason .set-panel[hidden] gives
   further down: the UA's `display:none` for [hidden] loses to any class rule
   that sets a display, and .fr-row sets `display:flex`. Eleven of the sixteen
   generators take no $DATA, and without these two rules every one of them
   showed an empty Data box (measured 2026-09-07). */
.cgn-text-row[hidden] { display:none; }
#cgn-text-help[hidden] { display:none; }
.swe-wave-wrap { position:relative; width:100%; }
.swe-wave { display:block; width:100%; height:90px; background:var(--bg-inset); border:1px solid var(--border-control); margin:8px 0; }
.swe-wave-head { position:absolute; top:1px; bottom:1px; left:0; width:2px; background:var(--accent-hi); box-shadow:0 0 5px var(--accent-hi); pointer-events:none; display:none; }

/* ── Vocoder window ────────────────────────────────────────────────────
   The sample-effect shell above, with a wider label column. .swe-dialog
   names its rows after plugin parameters and 72px is plenty for those;
   this window names them after what they DO ("Modulator", "Tuning"), and
   at 72px the longest of those clipped. Wider dialog for the same reason —
   it carries two source pickers, three pitch rows and a destination.

   ONLY label[for] TAKES THE COLUMN. The Normalise checkbox sits inside its
   own <label> with no `for`, the way every other checkbox row in the app
   does; giving that one a fixed 96px would squash the text against the box. */
.sd-dialog.voc-dialog { width:min(500px, calc(94vw / var(--ui-scale))); }
.voc-dialog .fr-row label[for] { width:96px; flex:0 0 96px; }
.voc-dialog input[type="range"] { flex:1; }
/* max-width:none, and it is not decoration. `.sd-dialog select` caps every
   select at 130px for the stem dialog (0,1,1), which truncated "New
   instrument slot (20)" to "New instrument slv" — a destination you
   cannot read is a destination you have to guess at. Scoped through
   .fr-row (0,2,1) to beat that clamp, the way .set-dialog does; the third
   dialog to walk into this, and the comment at line ~4332 says so too. */
.voc-dialog .fr-row select { flex:1; min-width:0; max-width:none; }
.voc-dialog input[type="text"] { flex:1; min-width:0; }
.voc-dialog .fr-help { margin-left:0; }
/* THE FOURTH DIALOG, and it arrived from another branch already carrying the
   fault: the CDP window grew the same destination dropdown and read "New
   instrument slc" for exactly the same reason. .swe-dialog is shared with the
   WAM effect window, which has the same rows. */
.swe-dialog .fr-row select { flex:1; min-width:0; max-width:none; }
.swe-dialog input[type="text"] { flex:1; min-width:0; }
.voc-dialog #voc-note { min-height:18px; }
/* The read-out beside a slider: fixed width so the row does not jitter as
   the number changes width under the drag. */
.voc-dialog .voc-val { flex:0 0 auto; min-width:38px; text-align:right; }
/* The pitch row is a NARROW select and a wide read-out, not two equal
   halves. A note name is five characters; sharing the row evenly left the
   read-out too narrow and "14079 Hz - 945 ms - mono - -9 semitones"
   wrapped onto a second line, so the carrier row stood taller than the
   modulator row underneath it whenever a pitch was set. */
.voc-dialog .fr-row select#voc-carrier-note,
.voc-dialog .fr-row select#voc-modulator-note { flex:0 0 110px; }
.voc-dialog #voc-car-info, .voc-dialog #voc-mod-info { flex:1; min-width:0; }

#ctx-sub { position:fixed; z-index:1001; min-width:var(--d-ctx-minw); padding:var(--d-menu-pad);
  background:var(--bg-menu); border:1px solid var(--border); border-radius:5px;
  box-shadow:0 6px 20px var(--scrim-soft); font-size:11px; user-select:none; }
/* A parent row stays lit while its child is open, so the trail back is visible. */
.ctx-item.has-sub.sel { background:var(--menu-hi-bg); color:var(--menu-hi-ink); }
.ctx-item.has-sub.sel .ctx-key { color:var(--menu-hi-key); }
/* A HEADING inside a flyout: a label, not a command. Never hovers, never clicks. */
.ctx-item.ctx-head { color:var(--menu-ink-dim); cursor:default; letter-spacing:1px;
  text-transform:uppercase; font-size:9px; padding-top:6px; }
.ctx-item.ctx-head:hover { background:none; color:var(--menu-ink-dim); }

/* ═══ FLOATING PANELS (js/panel.js) ═══════════════════════
   `.floating` turns an .sd-overlay from a modal backdrop into a
   pointer-transparent positioning layer: no dimming, no click-swallowing, so
   the tracker underneath stays live and you can click between the panel and
   the pattern grid exactly as both MEDs let you. The base .sd-overlay rule
   still hides it until .show is added, so the existing open/close code is
   unchanged. Modal is kept for #cfm-overlay, which must block — it carries the
   unsaved-changes warning since 2026-09-12 (#unsaved-overlay was retired). */
/* Base z for a floating panel: above the menu dropdown (150) and the workspace,
   below #loading (200) and below a true modal (.sd-overlay is 300), so the
   unsaved-changes prompt always covers everything. js/panel.js raises the
   OVERLAY (not the dialog) from 160 upward as panels are clicked — the overlay
   is a stacking context, so a z-index on the dialog inside can't reorder it. */
.sd-overlay.floating { background:none; pointer-events:none; z-index:160; }
.sd-overlay.floating.show { display:block; }
.sd-overlay.floating > .sd-dialog {
  pointer-events:auto; position:absolute;
  box-shadow:0 10px 34px var(--scrim); }
/* Title bar doubles as the drag handle. */
.pnl-grab { cursor:move; user-select:none; }
.pnl-grab .sd-titlex { cursor:pointer; }
/* THE TITLE BAR'S OWN BUTTONS sit at the right, in the order every window puts
   them: maximise, then close. `.sub` above takes margin-left:auto, so these
   follow it without needing any spacing of their own. */
.sd-title [data-sop="expand"] { margin-left:6px; }

/* ═══ FIND & REPLACE DIALOG ═══════════════════════════════ */
/* `.sd-dialog.fr-dialog`, not `.fr-dialog` — the base `.sd-dialog` sets
   width:760px and is declared LATER in this file, so a single-class selector
   ties on specificity and loses on source order. This rule had been silently
   doing nothing since it was written; Find & Replace was rendering at 760px. */
.sd-dialog.fr-dialog { width:min(560px, calc(94vw / var(--ui-scale))); }
.fr-row { display:flex; align-items:center; gap:var(--d-frrow-gap); margin-bottom:var(--d-frrow-mb); }
/* [hidden] needs the !important-free help of a real rule, exactly as
   .set-panel does: the display above beats the UA's [hidden]{display:none} on
   specificity, so `el.hidden = true` on a .fr-row would leave the row on
   screen. The CDP window hides its second-sound rows this way. */
.fr-row[hidden], .fr-help[hidden], .set-hint[hidden] { display:none; }
.fr-label { width:104px; flex:0 0 104px; color:var(--text-dim); font-size:10px;
  letter-spacing:1px; text-transform:uppercase; text-align:right; }
/* The pattern widget. Monospace and the same per-nibble split as the grid, so a
   Source pattern reads exactly like the cell it will match. */
.fr-pat { display:flex; align-items:center; background:var(--bg-inset);
  border:1px solid var(--border-control); border-radius:var(--radius-control); padding:4px 7px;
  font-size:13px; letter-spacing:0.5px; outline:none; cursor:default; }
.fr-pat:focus { border-color:var(--accent-dim); }
.fr-f { padding:1px 2px; border-radius:2px; color:var(--text-hi); }
/* Transparent fields are dimmed — at a glance you see what is being ignored. */
.fr-f.any { color:var(--text-dim); }
.fr-f.sel { background:var(--accent-fill); color:var(--on-accent); }
.fr-pat:focus .fr-f.sel { background:var(--accent-fill-hi); }
.fr-gap { width:7px; }
.fr-mini { padding:2px 7px; font-size:9px; letter-spacing:0.5px; }
.fr-swaprow { margin-top:-3px; }
.fr-help { margin:2px 0 12px 112px; color:var(--text-dim); font-size:10px; line-height:1.6; }
.fr-help b { color:var(--text); font-weight:normal; }
.fr-count { color:var(--accent-ink); font-size:10px; letter-spacing:0.5px; }
.fr-count.none { color:var(--text-dim); }
.fr-btns { display:flex; gap:8px; justify-content:flex-end; margin-top:14px; }

/* Menu-bar dropdowns (js/menu.js). Same visual language as #ctx-menu — one
   popup idiom, not two — but a deliberately lower z-index: it must clear the
   panels and the sticky pattern header (10) yet stay UNDER #loading (200) and
   .sd-overlay (300), so a modal is never painted over by a stray menu. */
/* ABOVE the floating panels (160..900), not below them. This started at 150 so
   it would clear the panels, which was backwards: a menu bar dropdown is a
   transient popup the user just summoned, and having it draw BEHIND an open
   dialog makes the bar look broken (Phil 2026-07-29). panel.js renormalises
   its z range so a long session of raises can never climb into this band. */
/* Submenus share every rule with the parent dropdown — one that looked even
   slightly different would read as a different kind of thing. */
#menu-drop, #menu-sub { position:fixed; z-index:1000; min-width:var(--d-drop-minw); padding:var(--d-menu-pad);
  background:var(--bg-menu); border:1px solid var(--border); border-radius:5px;
  box-shadow:0 6px 20px var(--scrim-soft); font-size:11px; user-select:none; }
#menu-sub { z-index:1001; }
/* A section heading inside a dropdown — not an item, so no hover and no
   pointer. Distinct from .disabled, which means "a command you can't run". */
.mdrop-item.mdrop-head { color:var(--menu-ink-dim); font-size:10px;
  letter-spacing:0.6px; text-transform:uppercase; cursor:default;
  padding-top:4px; pointer-events:none; }

/* ── Generator dialogs ────────────────────────────────────────────
   FIXED geometry. The slide dialog rebuilds its parameter rows per command, so
   without this it resized every time you changed the dropdown, and its labels
   ("Divisor of sample length", "Volume slide speed (up)") wrapped to two lines
   in the 104px default column and shifted everything under them (Phil
   2026-08-05). Width is set here rather than left to the help text, which was
   stretching the dialog wide and then leaving most of it empty. */
/* `.sd-dialog.slide-dialog`, not `.slide-dialog` — the base `.sd-dialog` sets
   width:760px and is declared LATER in this file, so a single-class selector
   ties on specificity and loses on source order. (The same trap has `.fr-dialog`
   at the top of this file silently doing nothing.) */
/* 186px wrapped "Volume slide speed (+ up / − down)" onto two lines, which
   shifts every row under it as you change command (Phil 2026-08-06). The dialog
   widens to match rather than the label shrinking — the sign convention is the
   part of that label worth reading. */
.sd-dialog.slide-dialog, .sd-dialog.echo-dialog { width:min(600px, calc(94vw / var(--ui-scale))); }
.sd-dialog.tr-dialog { width:min(600px, calc(94vw / var(--ui-scale))); }
/* Narrower than the rest on purpose: a list window is a column of rows, and
   the widths here are what beat the base .sd-dialog rule — a single-class
   `.lw-dialog` earlier in the file lost to it and came out 760px. */
.sd-dialog.lw-dialog { width:min(340px, calc(94vw / var(--ui-scale))); }
/* The instrument window alone is a TABLE, so it alone is wide. Its eleven
   columns need roughly 660px before the name has anywhere to go; the block and
   sequence windows stay at 340px, where a column of names is exactly right. */
/* 726, not 720 (2026-09-27): the instrument rows are laid out edge to edge and
   at 720 the last detail cell was cut by 2px on EVERY row (measured: 15 rows
   clipped, row content 696 into a 694 box). Six pixels is what the row asks
   for; the window is otherwise unchanged. */
.sd-dialog.lw-dialog.lwi-dialog { width:min(726px, calc(96vw / var(--ui-scale))); }
.tr-dialog .fr-label { width:170px; flex:0 0 170px; }
.tr-dialog .fr-help { margin-left:0; }
/* `.sd-dialog.tr-dialog select`, NOT `.tr-dialog select` — this rule fell into
   the very trap the comment above describes. `.sd-dialog select` (line ~1118)
   caps every select at max-width:130px; both selectors are (0,1,1) so the cap
   won on source order and all three dropdowns sat at 130px inside a 598px
   dialog with ~400px going spare. "Change octave — fold back, keeping the
   pitch class" needs 331px. Measured, Phil 2026-08-06. */
.sd-dialog.tr-dialog select { max-width:none; flex:1 1 auto; min-width:0; }
/* Up above down, as a 2x2 grid: OCTAVE UP over OCTAVE DOWN, SEMITONE UP over
   SEMITONE DOWN (Phil 2026-08-06). Scoped to .tr-dialog — `.fr-btns` is shared
   with Find & Replace, Volume Echo and Generate Slides, which all want the
   original single flex row. The DOM order is up,up,down,down to match. */
.tr-dialog .fr-btns {
  display:grid; grid-template-columns:repeat(2, minmax(0, 1fr));
  gap:8px; justify-content:end; margin-left:auto; width:max-content;
}
.slide-dialog .fr-label, .echo-dialog .fr-label { width:238px; flex:0 0 238px; }
/* Two rows' worth, so a one-field command leaves the dialog the same height as
   a two-field one instead of jumping. */
#slide-fields { min-height:68px; }
/* Full width: the 112px indent made sense beside Find & Replace's pattern
   fields, and here just wasted the space that was forcing the dialog wider. */
.slide-dialog .fr-help, .echo-dialog .fr-help { margin-left:0; }
/* Themed to match `.sd-dialog select` exactly. These were unstyled, so the
   browser default painted them bright white against a dark UI (Phil
   2026-08-05) — the only white in the app. Styled by class, not by dialog, so
   any future number field inherits it instead of repeating the mistake. */
.set-num {
  background:var(--bg-panel); color:var(--text);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
  font-family:var(--font-text); font-size:11px; padding:2px 4px;
}
.set-num:focus-visible { outline:none; border-color:var(--accent-ink); color:var(--accent-hi); }
/* The spinners render as a light block in a dark field; the arrow keys and the
   typed value both still work. */
.set-num::-webkit-inner-spin-button, .set-num::-webkit-outer-spin-button {
  -webkit-appearance:none; margin:0;
}
.set-num[type=number] { -moz-appearance:textfield; appearance:textfield; }
.slide-dialog .set-num, .echo-dialog .set-num { width:74px; }
/* The command dropdown carries the longest strings in the app — "[05] Volume
   Slide + Portamento", "[29] Set Relative Sample Offset". The generic
   `.sd-dialog select` caps every select at 130px, which truncated them while
   the row beside had space going spare (Phil 2026-08-05). Two classes to beat
   it on specificity, same trap as the dialog width above. */
.sd-dialog.slide-dialog select#slide-cmd { max-width:none; flex:1 1 auto; min-width:0; }

/* "Show me this list" — Song ▸ Block List / Instrument List / Playing Sequence.
   Distinct from View's show/hide toggles, which is the whole reason both exist:
   these reveal AND point, so the panel that was already open still responds
   (Phil 2026-08-05: they used to only expand, which View already did better). */
@keyframes panel-flash {
  0%, 100% { box-shadow:inset 0 0 0 0 rgb(var(--accent-rgb) / 0); }
  25%, 75% { box-shadow:inset 0 0 0 2px rgb(var(--accent-rgb) / 0.85); }
}
.panel-flash { animation:panel-flash 1.1s ease-in-out 1; }

/* Shortcut Keys sheet. Three columns: chord, what it does, when it applies. */
.sk-dialog .sd-body { max-height:calc(62vh / var(--ui-scale)); overflow-y:auto; }
#sk-body { display:block; }
.sk-group { margin:12px 0 4px; padding-bottom:3px; font-size:10px; letter-spacing:0.8px;
  text-transform:uppercase; color:var(--accent-ink); border-bottom:1px solid var(--sep); }
.sk-group:first-child { margin-top:0; }
.sk-row { display:flex; align-items:baseline; gap:10px; padding:2px 0; font-size:11px; }
.sk-key { flex:0 0 150px; color:var(--accent-hi); font-family:monospace; }
.sk-what { flex:1 1 auto; }
.sk-when { flex:0 0 auto; color:var(--text-dim); font-size:10px; font-style:italic; }
.mdrop-item { display:flex; align-items:center; gap:10px;
  padding:var(--d-menuitem-pad); border-radius:3px; cursor:pointer;
  color:var(--menu-ink); white-space:nowrap; }
/* Fixed-width gutter so labels line up whether or not a row is ticked. */
.mdrop-chk { width:var(--d-menu-gutter); flex:0 0 var(--d-menu-gutter); color:var(--menu-tick); }
.mdrop-lbl { flex:1 1 auto; }
.mdrop-key { color:var(--menu-ink-dim); font-size:10px; letter-spacing:0.3px;
  padding-left:var(--d-menu-keygap); }
.mdrop-item:not(.disabled):hover,
.mdrop-item.sel { background:var(--menu-hi-bg); color:var(--menu-hi-ink); }
.mdrop-item:not(.disabled):hover .mdrop-key,
.mdrop-item.sel .mdrop-key { color:var(--menu-hi-key); }
/* Unimplemented commands stay VISIBLE and greyed with the reason as a tooltip
   (never hidden) — the menu is the roadmap. Matches #btn-save's behaviour. */
.mdrop-item.disabled { color:var(--menu-ink-dim); cursor:default; }
/* Was #4a453e — 1.78:1 on the dropdown background, essentially invisible. The
   chord on a greyed row is exactly the thing worth reading (it tells you what
   the key WILL be), so it should be dim, not absent. */
.mdrop-item.disabled .mdrop-key { color:var(--menu-ink-off); }
.mdrop-sep { height:1px; background:var(--sep); margin:4px 6px; }
/* The bar title keeps its hover look while its menu is down. */
.menu-item.open { background:var(--menu-hi-bg); color:var(--menu-hi-ink); }

/* ═══ STEM RENDER DIALOG ═══════════════════════════════════ */
/* generic toggled-button highlight (also used by the WAV/FLAC toggle) */
.btn.on { background:linear-gradient(180deg,var(--btn-on-bg-hi) 0%,var(--btn-on-bg) 100%); color:var(--btn-on-ink); border-color:var(--accent-dim); font-weight:bold; }
/* Hovering a SELECTED button must stay amber. `.btn:hover:not(:disabled)`
   scores 3 (class + 2 pseudo-classes) against `.btn.on`'s 2, so hover repainted
   the face grey while .on's amber BORDER survived — a grey button with an
   orange outline (Phil 2026-07-28). It only showed on the preset row because
   `.sd-seg .btn` strips borders off the segmented rows entirely. 4 units here
   wins cleanly. */
.btn.on:hover:not(:disabled) {
  background:linear-gradient(180deg,var(--accent-bright) 0%,var(--accent-fill-hi) 100%);
  color:var(--on-accent); border-color:var(--accent-fill); }

.sd-overlay { display:none; position:fixed; inset:0; z-index:300; background:var(--scrim); align-items:center; justify-content:center; }
.sd-overlay.show { display:flex; }
/* A dialog is a flex COLUMN that never scrolls itself: max-height caps it, and
   the body inside is the scroller. This was overflow:auto, which made the whole
   box the scroll container — so any dialog taller than the viewport (Settings
   is the usual one) scrolled its own title bar and close button out of view
   (Phil 2026-08-06). #sd-overlay had already fixed this for the stem window
   alone, at the foot of this file; these three rules generalise it to all nine.

   Auto height is preserved, which the stem-window comment notes the other
   dialogs rely on: a flex column with no height set is still exactly as tall as
   its content, so short dialogs are completely unchanged. */
.sd-dialog { width:760px; max-width:calc(96vw / var(--ui-scale)); max-height:calc(92vh / var(--ui-scale)); overflow:hidden;
             display:flex; flex-direction:column;
             background:var(--bg-panel); border:1px solid var(--border-hi); box-shadow:0 8px 40px var(--scrim); }
/* Chrome pinned, body scrolls. min-height:0 is load-bearing: a flex item
   defaults to min-height:auto (its content size) and would refuse to shrink,
   handing the scrollbar straight back to the dialog. */
.sd-dialog > .sd-title,
.sd-dialog > .sd-foot,
.sd-dialog > .sd-outline { flex:0 0 auto; }
.sd-dialog > .sd-body,
.sd-dialog > .set-body,
.sd-dialog > .ab-body    { flex:1 1 auto; min-height:0; overflow-y:auto; }
/* A FLOATING WINDOW'S TITLE BAR IS A TITLE BAR. It carries --bg-header, so its
   ink is --header-ink for the same reason the docked panel headers' is: they
   are the same band in two places and must not disagree. Phil, 2026-08-22: "in
   the title bars of floating windows we have darker text on blue. They both
   need to match."
   h1 was --accent-hi and .sub was --text-dim, both of which are chosen to read
   on a PANEL. On a theme whose title bar is a different colour that is simply
   the wrong surface -- Das Nonplusultra would have put navy on dark brown. In
   the dark themes this moves h1 one rung down the accent ladder (OKLab dE 9.2)
   and .sub onto the header ink at 70%; that is the cost of the two bands
   agreeing, and it is the thing that was asked for. */
/* ROOM FOR A BIGGER TITLE. A dialog's width is declared per dialog for the
   type it was drawn with; raise the font-size axis and the title bar can want
   more than that. --dlg-min is 'auto' at 100% -- so every dialog is exactly as
   wide as it always was, to the pixel -- and 'min-content' above it, which lets
   a dialog take the width its own bar needs (the title cannot wrap, so it
   counts). Measured with a proportional local font at 135%: Playing Sequence 340 -> 352px,
   Custom Theme 344 -> 396, and neither title wraps nor subtitle truncates.
   js/fonts.js publishes it from the TITLE role, since that is the type that
   decides a title bar. */
.sd-dialog { min-width: var(--dlg-min, auto); }
.sd-title { background:var(--bg-header); border-bottom:1px solid var(--seam); padding:var(--d-dlgtitle-pad); display:flex; align-items:center; gap:10px; color:var(--header-ink); overflow:hidden; }
/* THE TITLE NEVER WRAPS, THE SUBTITLE GIVES WAY. Both are flex items, and by
   default both shrink — so a title that grew (Font size above 100%, or simply a
   wider font) lost the race against a long .sub and broke onto a second line,
   which is what a title bar must never do. Measured with a proportional local font at 135%: of
   29 dialogs, two ran out of room — CUSTOM THEME by 23px against a 150px
   subtitle, PLAYING SEQUENCE by 8px — and both wrapped. The h1 now refuses to
   shrink and the .sub takes the loss with an ellipsis: it is a count or a hint,
   and losing its tail reads as intended where a two-line title reads as broken.
   Nothing changes at 100%, where nothing was short. */
.sd-title h1 { font-family:var(--font-title); font-weight:600; font-size:15px; color:inherit; letter-spacing:2px; text-transform:uppercase; white-space:nowrap; flex:0 0 auto; }
.sd-title .sub { color:inherit; opacity:.72; font-size:10px; letter-spacing:1px; margin-left:auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* A .sub that is a real link — Settings' "Settings reference ↗" out to
   docs/settings.md. Inherits the dim sub styling above; only needs its
   underline suppressed and a hover state to read as clickable. */
.sd-title a.sub { text-decoration:none; }
/* A "Help ↗" link after a dialog's own subtitle: that .sub already takes the
   auto margin that pushes it right, so this one just follows it. */
.sd-title .sub + a.sub.sd-help { margin-left:12px; }
.sd-title a.sub:hover, .sd-title a.sub:focus-visible { color:var(--accent-hi); text-decoration:underline; }
/* A VIEW control living in a window's title bar, beside the close glyph. The
   .sub between the heading and here is flex:1, so both sit hard right. Sized to
   read as chrome rather than as one of the body's action buttons. */
.lw-titlebtn { margin-left:auto; flex:none; font-size:12px; line-height:1; padding:1px 7px; }
.lw-titlebtn.on { color:var(--accent-hi); border-color:var(--accent-dim); }
/* THE CLOSE GLYPH IS A TITLE-BAR GLYPH. It took --text-dim -- a PANEL ink --
   while every other glyph on that bar was moved to --header-ink back in
   4c37f8a; this one was missed. It shows wherever a theme gives the title bar
   a colour of its own: on Magique the bar is blue with white glyphs and the X
   was dark grey. Phil, 2026-08-23: 'the X to close a window is not being drawn
   in the same ink as other window glyphs'. .82 rather than flat, to match the
   .85 the panel-header buttons already carry. */
/* AND IT IS DRAWN AS A GADGET, not as a bare glyph. Phil, 2026-08-23: "can we
   draw a box round the X close button the same as the other window gadgets?"
   Same border token, same radius and the same .85/1 opacity pair .ph-btn uses,
   so the close box and the height toggle beside it are one row of controls
   rather than a control and a character. 13px rather than 16 so the glyph sits
   inside its box at the same weight as its neighbours. */
.sd-titlex { color:var(--header-ink); opacity:.85; cursor:pointer;
             font-size:13px; line-height:1; padding:1px 6px;
             border:1px solid var(--border-control); border-radius:var(--radius-control); }
.sd-titlex:hover { opacity:1; }
.sd-titlex:hover { color:var(--accent-hi); }
/* The close X sits hard right in every dialog. Normally `.sub` carries the
   auto-margin that pushes it there — but a dialog with no subtitle (About) left
   the X tucked against its title instead (Phil 2026-08-05). This targets only
   that case, so dialogs WITH a subtitle keep the existing layout exactly. */
.sd-title h1 + .sd-titlex { margin-left:auto; }
.sd-body { padding:var(--d-dlgbody-pad); }
/* ── Settings window ──────────────────────────────────────────────── */
.set-dialog { width:500px; }
.set-body { padding:var(--d-set-pad); display:flex; flex-direction:column; gap:var(--d-set-gap); }
.set-section { display:flex; flex-direction:column; gap:var(--d-set-rowgap); }
/* A HEADING BELONGS TO THE GROUP BELOW IT. Measured 2026-08-22, Phil: "large
   area below each heading... then very little space between that and the next
   heading." Both sit in .set-section's uniform gap, so a heading got exactly
   the same space above it as any row -- and 0px where it began a new section.
   Below the heading there is also its own 4px padding and the rule, so the pair
   read as bottom-heavy: the heading floated away from its own rows and sat on
   top of the previous group's last hint.
   Space ABOVE a heading now exceeds the space below it, which is what binds it
   to what it introduces. :not(:first-child) so the first heading in a panel does
   not push the whole panel down, and the adjacent-section rule covers the case
   where the heading opens a section.
   (This shipped once and was lost: an uncommitted copy was discarded by a
   `git checkout --` while recovering an unrelated mistake, and 26bf4e8's message
   claimed it anyway. Re-applied here, on the density token.) */
.set-section > .set-sechead:not(:first-child) { margin-top: var(--d-sechead-gap); }
.set-section + .set-section > .set-sechead:first-child { margin-top: var(--d-sechead-gap); }
.set-sechead { font-family:var(--font-title); font-weight:600; font-size:11px; letter-spacing:2px; text-transform:uppercase; color:var(--accent-hi); border-bottom:1px solid var(--border); padding-bottom:4px; }
.set-row { display:flex; align-items:center; gap:12px; }
.set-label { flex:0 0 124px; width:124px; font-size:11px; color:var(--text-dim); letter-spacing:1px; cursor:help; }
.set-row .tg { flex:0 0 auto; }
/* A row that belongs to the control ABOVE it: indented past the label
   column so its content starts exactly where the selects do. 124px is
   .set-label's width and 12px is .set-row's gap -- read from those two
   rules, not guessed, so the alignment survives either changing. */
.set-row.set-subrow { padding-left: calc(124px + 12px); }
.set-hint { font-size:9px; color:var(--text-dim); opacity:.75; letter-spacing:.5px; line-height:1.4; }
/* A SLIDER IN A SETTINGS ROW (stereo separation, 2026-09-28): the select
   column's own 316px, so it lines up with the selects above and below, split
   between the track and its readout. The track's look is .sd-dialog's. */
.set-range { display:flex; align-items:center; gap:10px; width:316px; max-width:100%; min-width:0; }
.set-range input[type="range"] { flex:1 1 auto; min-width:60px; }
.set-range-val { flex:0 0 auto; font-size:11px; color:var(--text); letter-spacing:.5px;
  white-space:nowrap; font-variant-numeric:tabular-nums; }
/* Fonts the user loaded (js/fonts.js): one row each, the font's own name and a
   remove button, indented to the controls' column like .set-subrow. */
.font-user-list { display:flex; flex-direction:column; gap:2px; padding-left:calc(124px + 12px); }
.font-user-list[hidden] { display:none; }
.font-user-row { display:flex; align-items:center; gap:8px; font-size:11px; color:var(--text); }
.font-user-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.btn.font-user-x { flex:0 0 auto; min-width:0; padding:1px 6px; font-size:10px; }
.set-hint.font-status-bad { color:var(--alert); opacity:1; }
/* The font lists are grouped (Amiga pixel fonts / Installed / Loaded). Like
   .set-sel option below, this only reaches the popup where the platform draws
   it from CSS (Chrome on Windows); elsewhere the OS draws its own. */
.set-sel optgroup { background:var(--bg-panel); color:var(--text-dim); }
/* Settings-row action button (the compat "Pin to module" control). Sized to sit
   beside a .set-sel without pushing the row taller than its neighbours. */
.set-btn, .set-pin { flex:0 0 auto; font-size:10px; padding:3px 8px; letter-spacing:.5px; }
/* The per-option pin: a .btn so its edge and radius come from the control
   tokens (control-uniformity.js forbids anything else), sized down to sit in
   a select row without stretching it. The glyph is the pin emoji at reduced
   size; .amber = pinned, disabled = no module loaded. */
/* THE PIN IS DRAWN, NOT TYPED. It was U+1F4CC, the pushpin emoji, which every
   platform paints in its own fixed red -- Phil, 2026-08-22: "doesn't fit any of
   the current styles". An inline SVG on currentColor takes the theme instead,
   in all twelve. The two states are a shape difference, not just a colour one,
   because "it was unclear when the button is enabled or not": OUTLINE = not
   pinned, SOLID = pinned, and the tail stays stroked in both so the silhouette
   stays a pin. */
.pinbtn { flex:0 0 auto; line-height:0; padding:3px; }
.pinbtn svg { display:block; width:12px; height:12px;
  fill:none; stroke:currentColor; stroke-width:1.3;
  stroke-linejoin:round; stroke-linecap:round; }
.pinbtn.amber svg .pin-head { fill:currentColor; }
/* Disabled means "no module loaded", which is a different thing from "not
   pinned" -- so it dims the whole control rather than changing the shape. */
.pinbtn:disabled { opacity:.35; }

/* Tempo Operations (Tools). Mirrors the Amiga window: a 2x2 Conversion block,
   a modifier checkbox under it, then the two command buttons. Scoped to
   .tempo-dialog — .fr-btns is shared with four other dialogs that want a plain
   flex row. */
.sd-dialog.tempo-dialog { width:min(460px, calc(94vw / var(--ui-scale))); }
.tempo-dialog .set-sechead { margin:14px 0 8px; }
.tempo-dialog .fr-help { margin:0 0 4px; }
.tempo-grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:8px; }
/* Two-line conversion button: MED's label, then what THIS button will actually
   do to THIS module. The outcome line is the reason the panel needs no standing
   warning list. */
.btn.tconv { display:flex; flex-direction:column; align-items:center; gap:2px;
             padding:6px 8px; line-height:1.25; text-align:center; }
.btn.tconv > b { font-weight:inherit; }
.btn.tconv > i { font-style:normal; font-size:9px; letter-spacing:.3px;
                 color:var(--text-dim); max-width:100%; overflow:hidden;
                 text-overflow:ellipsis; white-space:nowrap; }
.btn.tconv:disabled > i { color:var(--text-dim); }
/* An unreachable conversion still runs — MED's does — so it stays enabled and
   is marked instead of greyed. Amber, matching the warning band, so "this will
   not keep your speed" reads the same wherever it appears. */
.btn.tconv.warn { border-color:var(--accent-dim); }
.btn.tconv.warn > i { color:var(--accent-ink); }
.btn.tconv > i:empty { display:none; }
.tempo-dialog .tempo-btns { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr));
                            gap:8px; justify-content:stretch; margin-top:0; }
.tempo-lpb { margin-top:10px; margin-bottom:0; }
.tempo-lpb label { font-size:10px; color:var(--text-dim); letter-spacing:.5px; min-width:64px; }
/* `.sd-dialog.tempo-dialog select`, NOT `.tempo-lpb select` — the SECOND time
   this trap has been walked into (see .tr-dialog above). `.sd-dialog select`
   caps every select at max-width:130px and is (0,1,1); so is `.tempo-lpb
   select`, so the cap won on source order and "4 lines per beat (suggested —
   BPM 155)" was cut off at 130px with ~230px of the row going spare. The
   .sd-dialog. prefix makes it (0,2,1) and settles it on specificity rather
   than on where the rule happens to sit in the file. Phil 2026-08-07. */
.sd-dialog.tempo-dialog .tempo-lpb select { max-width:none; flex:1 1 auto; min-width:0; }
.fr-check { display:flex; align-items:center; gap:7px; margin-top:10px;
            font-size:10px; color:var(--text-dim); letter-spacing:.3px; cursor:pointer; }
.fr-check input { accent-color:var(--accent-fill); }
/* A CHECKBOX THAT CANNOT APPLY, dimmed rather than hidden — a control that
   vanishes leaves you wondering whether you imagined it, and its title carries
   the reason. Same treatment .phch-btn:disabled gets, from the same token. */
.fr-check-off { opacity:.4; cursor:not-allowed; }
/* A disabled conversion is the normal resting state — most modules can only do
   one or two of the four — so it must read as "not applicable here" rather than
   broken. The title attribute carries the reason, same as a greyed menu row. */
.tempo-grid .btn:disabled, .tempo-btns .btn:disabled { opacity:.38; cursor:default; }

/* Generator notes vs generator WARNINGS. #slide-warn carries `.set-hint`, whose
   9px / --text-dim / 0.75-opacity is right for an explanatory note and wrong for
   something the user must not miss — and both used to be concatenated into one
   text node, so the ⚠ was indistinguishable from the ℹ beside it. Each is now
   its own row; the warning opts out of the parent's size and fade entirely.
   Not scoped to #slide-warn any more: Tempo Operations needs the same "you must
   not miss this" band for a conversion that can't hit the old speed. */
#slide-warn > .gen-note + .gen-note,
.gen-warn { margin-top:6px; }
#slide-warn > .gen-note { display:block; }
.gen-warn {
  display:block; opacity:1; font-size:10.5px; line-height:1.45;
  color:var(--accent-ink); background:rgb(var(--accent-rgb) / 0.10);
  border-left:2px solid var(--accent-ink); padding:5px 7px;
}
.gen-warn:empty { display:none; }
.set-note   { font-size:10px; color:var(--text-dim); opacity:.7; letter-spacing:.5px; }
.compat-grp { font-size:9px; letter-spacing:1.5px; text-transform:uppercase; color:var(--text-dim);
              opacity:.8; margin:6px 0 2px; }
.compat-grp:first-child { margin-top:0; }
.compat-row { display:flex; gap:12px; font-size:10px; line-height:1.6; letter-spacing:.4px; }
.compat-row > span:first-child { flex:0 0 148px; color:var(--text-dim); }
.compat-row > span:last-child  { color:var(--text-hi); }
.compat-auto { font-style:normal; font-size:8px; letter-spacing:1px; text-transform:uppercase;
               color:var(--accent-hi); opacity:.65; margin-left:6px; }
/* Settings dropdowns (2026-07-19 redesign: consistent selects, button rows retired) */
.set-sel {
  background: linear-gradient(180deg, var(--btn-hi) 0%, var(--btn-lo) 100%);
  color: var(--text-hi);
  border: 1px solid var(--border-control);
  border-bottom-color: var(--btn-press);
  border-right-color:  var(--btn-press);
  font-family: var(--font-text);
  font-size: 11px;
  padding: 4px 6px;
  cursor: pointer;
  outline: none;
}
/* FIXED widths (not min-): a <select> otherwise grows to its longest option,
   which overflowed the dialog into a horizontal scrollbar. 316 = the selpair
   total (210 kernel + 6 gap + 100 taps) so all rows right-align. Scoped as
   `.set-dialog select.*` (0,2,1) to beat the generic `.sd-dialog select`
   130px max-width clamp (0,1,1) that ships for the stem dialog. */
.set-dialog select.set-sel { width: 316px; max-width: none; }
.set-dialog select.set-sel-narrow { width: 100px; }

/* ── SETTINGS TABS ──────────────────────────────────────────────────────── */
/* The tab strip sits under the title and above the scrolling body, so the
   dialog's height stops depending on which section you are reading. */
/* STICKY, for a strip that lives INSIDE a scrolling body (the instrument
   editor's). Phil, 2026-08-22: "if the window scrolls you can lose the tabs at
   the top". flex:0 0 auto only stops it being squeezed -- it does not stop it
   scrolling away. The background is required: without it the panel's rows show
   through the strip as they pass under it. */
.set-tabs { display:flex; gap:2px; flex:0 0 auto; padding:0 14px;
  border-bottom:1px solid var(--border); margin-bottom:10px;
  position:sticky; top:0; z-index:2; background:var(--bg-panel); }
/* THE SETTINGS STRIP IS NOT IN THE SCROLL CONTAINER. Sticky was not enough
   there: it sticks at the body's PADDING edge, so the rows scrolled through the
   padding band above the strip and the 10px margin gap below it -- Phil,
   2026-09-02: "when the settings dialog scrolls, the content still appears
   behind the tabs". The strip now sits between the title bar and .set-body, a
   flex sibling that never scrolls; it carries the body's padding on top and
   sides, and the body's own top padding is the gap beneath it. */
.set-dialog > .set-tabs { position:static; margin:0; padding:var(--d-set-pad); padding-bottom:0; }
/* A STATE tab, not a momentary button: flat, and it fills with the accent when
   it is the one you are on -- the same distinction .ed-flag draws and that
   stuff/regression/control-uniformity.js polices. Hence --border-control and
   --radius-control rather than any local values. */
.set-tab {
  background:var(--bg-panel); color:var(--text-dim);
  border:1px solid var(--border-control); border-bottom:none;
  border-radius:var(--radius-control) var(--radius-control) 0 0;
  font-family:var(--font-text); font-size:11px; letter-spacing:1px;
  padding:4px 14px; cursor:pointer; position:relative; top:1px;
}
.set-tab:hover:not(.on) { color:var(--text); border-color:var(--border-hi); }
/* The selected tab joins the panel below it: its bottom edge is the panel's
   background, which is what makes a tab strip read as tabs rather than as a
   row of buttons. */
.set-tab.on { background:var(--bg-mid); color:var(--accent-hi);
  border-color:var(--border-hi); box-shadow:inset 0 2px 0 var(--accent-ink); }
.set-tab:focus-visible { outline:none; border-color:var(--accent-dim); color:var(--accent-hi); }
/* [hidden] needs the !important-free help of a real rule: .set-panel would
   otherwise win on specificity if it ever gains a display. */
.set-panel[hidden] { display:none; }

/* ONE HEIGHT FOR EVERY TAB, which is the entire point of having tabs. Without
   this the dialog was 422px on Playback, 643 on Audio & output and 432 on
   Interface -- it jumped as you switched, so no control had a fixed place and
   the tallest tab was still nearly the 662px that made the dialog undraggable
   past 125% in the first place.

   THE HEIGHT IS ONE VALUE PER DENSITY, and it is the TALLEST TAB'S -- that is
   what makes the tabs work: the window does not resize under you when you move
   between them, so every control keeps its place.

   The constant went stale the moment the density system moved beneath it. It
   read 704, measured when Normal was the roomier spacing; after Compact became
   Normal the tallest tab needs 619, and under the new Compact 560. Phil,
   2026-08-24: "the settings window has a lot of unused space in it now." It
   was 85px of dead space on every tab at Normal and 144px at Compact, on top
   of whatever a short tab leaves by design.

   Measured at scale 1 in a viewport tall enough not to cap it -- the earlier
   attempt read 593 for every tab in both densities, which was `max-height:
   92vh` binding rather than any content, in a 644px-tall window. A number
   taken from a capped measurement is not a measurement.

     Normal    Playback 374   Interface 592   Audio 619
     Compact   Playback 339   Interface 536   Audio 560

   620 and 562, a pixel over each, so a sub-pixel rounding cannot introduce a
   scrollbar. The viewport cap stays: it is what keeps the window usable when
   the screen (or the UI scale) cannot afford the full height. */
.set-dialog { height: min(var(--d-set-dialog-h), calc(88vh / var(--ui-scale))); }
/* The body takes the slack and scrolls; the tab strip and title stay put. */
.set-dialog > .set-body { flex:1 1 auto; min-height:0; overflow-y:auto; }

/* THE ROW THAT OVERFLOWED. A .set-row is label(124) + gap(12) + select(316),
   which is 452 of the ~712px of usable width -- fine until a row also carries
   an action button, where 'Pin to module' pushed past the edge and put a
   horizontal scrollbar under the whole dialog (Phil 2026-08-21). Let the row
   wrap instead of overflowing: the button drops under the select on a narrow
   dialog and sits beside it when there is room. min-width:0 is what actually
   permits the shrink -- a flex item's default min-width:auto refuses to go
   below its content and is the usual reason a flex row overflows silently. */
.set-row { flex-wrap:wrap; }
/* A COMPAT ROW DOES NOT WRAP. 124px label + 12px gap + a 316px select already
   reaches 452 of the 500px dialog, so adding the pin pushed it onto its own
   line -- Phil, 2026-08-22: "should be on the same line, not below". The select
   gives up the width instead: it is the only thing in the row that can. */
.set-row.set-row-pin { flex-wrap:nowrap; }
.set-row.set-row-pin > select.set-sel { flex:1 1 auto; width:auto; min-width:0; }
.set-dialog .set-row > select.set-sel { min-width:0; max-width:100%; }
/* THE INTERFACE-SCALE FIELD HAS NO RULE OF ITS OWN, and that is the fix.
   It used to carry a full copy of the field styling here, and the comment
   said it was "styled to match `.sd-dialog select` exactly" -- a DROPDOWN.
   That is why it stood out: `.sd-dialog select` grounds on --bg-panel, while
   every number field in the app grounds on the sunken --bg-inset, so the one
   box in Settings looked raised where Song Properties' looked recessed.
   Phil, 2026-08-23: "the UI scale box doesn't have the same styling as
   spinners elsewhere in the app, like song tempo for example."

   Deleting the override is the whole change: `.sd-dialog input[type="number"]`
   already supplies ground, ink, edge, radius, font, size, alignment and width,
   and .set-dialog IS an .sd-dialog. One source of truth for the control kind,
   which is what stuff/regression/control-uniformity.js is asking for anyway.
   It was the last number field in Settings once the compat rows moved to Song
   Properties, so nothing else was relying on it. */
.set-dialog .selpair > select.set-sel:first-child { width: 210px; }
/* A BORDER HOVER, NOT A BACKGROUND ONE. This was a `background` shorthand at
   (0,3,0) — it beat the shared field rule and repainted the control light
   grey on hover, taking the drawn drop-down arrow with it. */
.set-sel:hover:not(:disabled) { border-color: var(--border-hi); }
.set-sel:focus { border-color: var(--accent-dim); }
.set-sel:disabled { opacity: .35; cursor: default; }
/* The open list of a native <select>, and a claim CORRECTED 2026-08-22. This
   said "Chrome and Firefox honour background and colour on <option>". They do
   not, and Phil tested four browsers to find out: "Firefox doesn't style the
   text in dropdowns and none of the browsers on MacOS did, only chrome on
   windows." The honest matrix:

     Chrome / Windows   styles <option>          -- the only one that does
     Chrome / macOS     OS-drawn popup, ignored
     Safari / macOS     OS-drawn popup, ignored
     Firefox / any      OS-drawn popup, ignored

   The rules below are therefore a PROGRESSIVE ENHANCEMENT, not a guarantee:
   where they land, the checked row takes the same pair a lit button does, and
   under Professional that is the inverse video asked for; everywhere else the
   popup is the platform's own and the theme stops at the closed control. The
   hover row inside the popup is OS-drawn even on Chrome/Windows.

   The only real fix is to stop using a native popup -- either
   appearance:base-select (Chrome 135+, absent from Safari and Firefox, so it
   would help exactly the one browser that already works) or a custom listbox,
   which means owning keyboard navigation, typeahead and accessibility for 23
   controls. Neither is worth it yet. What matters is that nobody reads the old
   comment and believes the popup is themed when it is not. */
/* Colour inputs (the Custom theme editor). The baseline dialog-controls.js
   demands for every control kind in a .sd-overlay: background, border, font
   and a :focus rule, all from the control tokens. */
.sd-dialog input[type="color"] {
  background: var(--btn-face); border: 1px solid var(--border-control);
  border-radius: var(--radius-control); font: inherit;
  width: 46px; height: 24px; padding: 1px; cursor: pointer;
}
.sd-dialog input[type="color"]:focus { outline: none; border-color: var(--accent-dim); }
/* THE CUSTOM THEME EDITOR. It had four rules and looked it -- Phil,
   2026-08-22: "The colour picker is completely unstyled." A row is now
   [swatch] [name] [hex], which is the shape every colour editor uses, and the
   two groups say which pens are surfaces and which are ink, because that is
   the distinction that decides what a pen does.

   The HEX FIELD is not decoration: input[type=color] opens the OS picker, and
   that dialog is drawn by the platform and cannot be themed -- the same limit
   the <select> popup has. Typing a value is the way round it, and the way to
   paste an exact colour from somewhere else. */
.ct-dialog { width: 344px; }
/* The Start-from row. max-width:none for the same reason the moved compat
   dropdowns needed it -- `.sd-dialog select` caps every select at 130px. */
.ct-from { display:flex; align-items:center; gap:9px; margin-bottom:12px; }
.ct-from label { flex:0 0 auto; font-size:11px; color:var(--text-dim); letter-spacing:1px; }
.ct-from > select.set-sel { max-width:none; flex:1 1 auto; width:auto; min-width:0; }
.ct-body .set-sechead:first-child { margin-top: 0; }
.ct-grid { display: flex; flex-direction: column; gap: 4px; }
.ct-row { display: flex; align-items: center; gap: 9px; }
.ct-row label { flex: 1 1 auto; font-size: 11px; color: var(--text-dim);
                letter-spacing: 1px; cursor: pointer; min-width: 0; }
/* The swatch is the biggest thing in the row because it is the thing being
   chosen; the tokens still supply its edge, per control-uniformity.js. */
.ct-row > input[type="color"] { flex: 0 0 auto; width: 40px; height: 20px; }
/* WRITTEN `input[type="text"].ct-hex` ON PURPOSE. `.sd-dialog input[type=text]`
   is (0,2,1) and sets `flex:1 1 auto`; a plain `.ct-hex` is (0,1,0) and loses,
   so the width declared here was dead and the field stretched to 126px for
   seven characters. Measured, not guessed. Everything else still comes from
   that shared baseline -- this only reclaims the sizing. */
.sd-dialog input[type="text"].ct-hex {
  flex: 0 0 76px; width: 76px; text-align: center; text-transform: uppercase;
  font-family: var(--font-text); font-size: 11px; padding: 3px 4px;
  background: var(--bg-inset); color: var(--text);
  border: 1px solid var(--border-control); border-radius: var(--radius-control);
}
.sd-dialog input[type="text"].ct-hex:hover:not(:disabled) { border-color: var(--border-hi); }
.sd-dialog input[type="text"].ct-hex:focus { outline: none;
  border-color: var(--accent-dim); color: var(--accent-hi); }
/* A hex that is not six digits yet is not an error to shout about -- you are
   part-way through typing one. It just declines to take effect. */
.sd-dialog input[type="text"].ct-hex.bad { border-color: var(--alert); }
.ct-foot { display: flex; align-items: center; gap: 8px; margin-top: 14px; }
.ct-spacer { flex: 1 1 auto; }
/* Undo and redo are glyph buttons, sized to the pin beside them so the left
   cluster reads as one group of icon controls. */
.btn.ct-undo { font-size: 13px; line-height: 1; padding: 2px 7px; }

select option { background: var(--bg-float); color: var(--text); }
select option:checked { background: var(--btn-on-bg); color: var(--btn-on-ink); }
.set-sel option { background: var(--bg-panel); color: var(--text); }
.set-sel option:disabled { color: var(--ink-off); }
.selpair { display: flex; gap: 6px; align-items: center; }
/* BLEP cat easter egg — brief Toasty-style pop-in from the bottom-right
   corner when the user picks the BLEP kernel (10% chance, see maybeBlepCat).
   Overshoot ease on the way in; the same transition retreats it. */
.blep-cat {
  position: fixed; right: 0; bottom: 0; width: 130px; height: auto;
  z-index: 9999; pointer-events: none; user-select: none;
  transform: translate(115%, 115%) rotate(8deg);
  transition: transform 0.56s cubic-bezier(.2, 1.4, .4, 1);
}
.blep-cat.in { transform: translate(4%, 10%) rotate(0deg); }
.sd-ctl-row { display:flex; align-items:center; gap:8px; margin-bottom:10px; flex-wrap:wrap; }
.sd-lbl { color:var(--text-dim); font-size:11px; letter-spacing:1px; }
.sd-hint { color:var(--text-dim); font-size:10px; }
.sd-sm { padding:3px 7px; font-size:11px; min-width:0; }
.sd-seg { display:inline-flex; border:1px solid var(--border-control); background:var(--bg-inset); }
.sd-seg .btn { border:none; border-right:1px solid var(--border); min-width:0; }
.sd-seg .btn:last-child { border-right:none; }
.sd-seg .btn:disabled { opacity:.35; cursor:default; }

.sd-selbar { background:var(--selbar-bg); border:1px solid var(--selbar-edge); padding:5px 8px; margin-bottom:8px; display:none; align-items:center; gap:8px; font-size:11px; }
.sd-selbar.show { display:flex; }
.sd-selbar .cnt { color:var(--selbar-ink); }

/* ── A FRAMED LIST ────────────────────────────────────────────────────
   A sunken well with a titled bar on top, the shape the stem window has always
   used for its two panes. Phil, 2026-08-24: "it would make it clearer which
   parts are containers for lists, currently everything is just sitting on the
   same plane and it doesn't scan well." So it stops being the stem window's
   private shape and becomes the app's.

   `.pane` and `.sd-pane` SHARE these declarations rather than one copying the
   other — the drift between two spellings of one thing is what this file spent
   2026-08-23 undoing. They differ only in sizing, below, because the stem
   window pins its panes and the dialogs flex theirs. */
.sd-panes { display:flex; gap:var(--d-panes-gap); }
.pane, .sd-pane { background:var(--bg-inset); border:1px solid var(--border);
                  display:flex; flex-direction:column; min-width:0; }
.sd-pane { flex:1; min-height:300px; }
.pane    { flex:1 1 auto; min-height:0; }
.pane-head, .sd-pane-head { background:var(--bg-mid); border-bottom:1px solid var(--border);
                            padding:5px 8px; display:flex; align-items:center; gap:8px;
                            flex:0 0 auto; }
/* The dialogs' heads carry a plain label where the stem window carries a
   .ph-title span, so the type treatment lives on the head itself. */
.pane-head { color:var(--text-dim); font-size:10px; letter-spacing:1px;
             text-transform:uppercase; }
.pane-head select { margin-left:4px; }
.pane-list { flex:1 1 auto; min-height:0; overflow-y:auto; }
.sd-pane-head .ph-title { color:var(--text-hi); letter-spacing:2px; font-size:11px; }
.sd-pane-head .ph-meta { color:var(--text-dim); font-size:10px; margin-left:auto; }
.sd-pane-list { flex:1; overflow-y:auto; max-height:300px; }

/* Source and stem rows MUST be the same height or the two lists drift apart by
   a pixel a row — with the same number of items the sources list ended up
   visibly shorter (Phil 2026-07-28). They were 30px vs 31px because each was
   sized by a different tallest child: a 21px <select> here, a 20px <input>
   there. Pin both instead of letting the content decide. */
.sd-src-row { display:flex; align-items:center; gap:8px; height:30px; padding:0 8px; cursor:pointer; border-bottom:1px solid var(--seam-row); }
.sd-src-row:hover { background:var(--row-hover-cool); }
/* THE HOUSE SELECTION IDIOM. This list was the one that never got the fix
   Phil asked for on the instrument list (see the note at .inr.sel): a blue
   fill and outline where every other list draws an inset amber bar. A FILL
   means "live" everywhere else in the app, so this row read as playing.
   .lead matches js/app.js's 3px brighter bar for the shift-range anchor. */
.sd-src-row.sel { box-shadow:inset 2px 0 0 var(--accent-ink); }
.sd-src-row.sel .sd-src-name { color:var(--accent-hi); font-weight:600; }
.sd-src-row.sel.lead { box-shadow:inset 3px 0 0 var(--accent-hi); }
/* THE CHIP FILLS ARE DATA, NOT CHROME, and stay fixed across every theme on
   purpose: they are what says which stem is which, and a stem that changed
   colour with the theme would lose the only identity it has. Their OUTLINE is
   chrome, and was the last literal in this dialog (#0008 -- a black hairline
   that did its job on the light tributes and was invisible on the dark ones).
   It takes --border now, like every other edge in the app. */
.sd-src-dot { width:10px; height:10px; border-radius:50%; flex-shrink:0; border:1px solid var(--border); background:var(--bevel-hi); }
.sd-src-idx { color:var(--text-dim); width:22px; text-align:right; }
.sd-src-name { flex:1; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.sd-src-grp { color:var(--text-dim); font-size:10px; }

.sd-stem-row { display:flex; align-items:center; gap:8px; height:30px; padding:0 8px; border-bottom:1px solid var(--seam-row); }
.sd-stem-sw { width:12px; height:12px; flex-shrink:0; border:1px solid var(--border); cursor:pointer; }
.sd-stem-name { flex:1; background:transparent; border:1px solid transparent; color:var(--text-hi); font-family:var(--font-text); font-size:12px; padding:2px 4px; }
.sd-stem-name:hover { border-color:var(--border-control); }
.sd-stem-name:focus { border-color:var(--accent-dim); background:var(--bg-panel); outline:none; }
.sd-stem-count { color:var(--text-dim); font-size:10px; }
.sd-x { color:var(--text-dim); cursor:pointer; padding:0 4px; }
.sd-x:hover { color:var(--alert-hi); }
.sd-addstem { padding:6px 8px; color:var(--green); cursor:pointer; font-size:11px; letter-spacing:1px; }
.sd-addstem:hover { color:var(--ok-hi); background:var(--ok-ground); }
/* Fixed line-height + min-height: the ✓ in "Σ stems = full mix ✓" has a taller
   line box than the plain warning text, which nudged everything below it down
   by a pixel as the message swapped (Phil 2026-07-28). */
.sd-suminc { padding:6px 8px; border-top:1px solid var(--border); font-size:11px;
  letter-spacing:1px; line-height:15px; min-height:15px;
  /* ...and nowrap: with no ellipsis this line WRAPPED to two lines the moment
     the omitted-source count hit two digits ("… 64 source(s) omitted" is 43px
     vs 28px), which is a 15px jolt on a very common state, not a 1px one. */
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.sd-suminc.ok { color:var(--green); }
.sd-suminc.warn { color:var(--accent-hi); }

.sd-foot { display:flex; align-items:center; gap:var(--d-dlgfoot-gap); padding:var(--d-dlgfoot-pad); border-top:1px solid var(--border); background:var(--bg-mid); }
/* min-width:0 lets a flex item shrink below its content width — without it the
   93-char per-instrument note forces the footer to two or three lines once
   max-width:96vw pulls the dialog under ~790px. */
.sd-warnmsg { color:var(--accent-ink); font-size:10px; flex:1 1 auto; min-width:0;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.sd-spacer { margin-left:auto; }
/* Settings/stem summary line above the footer buttons. Variable-length text
   lives HERE so the buttons beside it can stay a fixed size. Pinned line box:
   empty vs filled was a 6px→17px jump. */
.sd-outline { padding:6px 12px 0; color:var(--text-dim); font-size:10px;
  letter-spacing:0.5px; text-align:right; line-height:11px; min-height:17px;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }

/* ── Stem window: PINNED SIZE ──────────────────────────────────────────
   The window is a fixed-height flex column and ONLY the two list panes flex,
   so content changes reflow inside it instead of resizing it. Before this the
   dialog was auto-height: it grew 565→646px as the stem list filled, and a
   64-instrument module hit max-height:92vh and turned the WHOLE dialog into a
   scroller — dragging the title bar and the RENDER button out of view. The two
   hard-coded 300s also fought each other (.sd-pane a 300px floor, .sd-pane-list
   a 300px ceiling), leaving ~70px of dead space under the sources list.

   Scoped to #sd-overlay because .sd-dialog is shared with Find & Replace, the
   unsaved-changes prompt and Settings, which all want auto height.
   (Since 2026-08-06 the base .sd-dialog rule is itself a non-scrolling flex
   column, so the title-bar half of this is no longer unique to the stem window.
   What still IS unique is everything below: the per-open pinned height, and
   handing the scroll down to .sd-pane-list rather than to the body.)
   min-height:0 is load-bearing everywhere it appears: flex items default to
   min-height:auto (content size), which is exactly what pushed the window
   taller. flex:0 0 auto on the head/add/sum rows stops them being squashed
   instead of the list.

   The HEIGHT itself is not set here — js/stem-dialog.js measures it once per
   open, from the most rows THIS module could ever need, and writes it inline.
   A flat 70vh was stable but wasted most of the screen on a 4-track song
   (Phil 2026-07-28); the requirement is only that it not move WHILE you work,
   which a per-open measurement satisfies just as well. max-height stays as the
   backstop for a big module or a short viewport. */
#sd-overlay > .sd-dialog {
  max-height:calc(92vh / var(--ui-scale));
  display:flex; flex-direction:column;
  overflow:hidden;                 /* beats .sd-dialog's overflow:auto */ }
#sd-overlay .sd-title,
#sd-overlay .sd-outline,
#sd-overlay .sd-foot        { flex:0 0 auto; }
#sd-overlay .sd-body        { flex:1 1 auto; min-height:0;
                              display:flex; flex-direction:column; }
#sd-overlay .sd-body > .sd-ctl-row,
#sd-overlay .sd-body > .sd-selbar { flex:0 0 auto; }
#sd-overlay .sd-panes       { flex:1 1 auto; min-height:0; }
#sd-overlay .sd-pane        { min-height:0; }        /* drop the 300px floor */
#sd-overlay .sd-pane-head,
#sd-overlay .sd-addstem,
#sd-overlay .sd-suminc      { flex:0 0 auto; }
#sd-overlay .sd-pane-list   { flex:1 1 auto; min-height:0; max-height:none;
                              overflow-y:auto; }     /* drop the 300px ceiling */
/* Fixed width: the label is now the constant word "RENDER", and a min-width
   stops a disabled/again state from resizing it and squeezing Cancel. */
.sd-render-btn { font-size:13px; padding:6px 16px; min-width:104px;
  color:var(--green); border-color:var(--green-dim); }
.sd-render-btn:hover:not(:disabled) { color:var(--ok-hi); }
.sd-dialog select { background:var(--bg-panel); color:var(--text); border:1px solid var(--border-control); border-radius:var(--radius-control); font-family:var(--font-text); font-size:11px; padding:2px 4px; max-width:130px; }
/* Found by stuff/regression/dialog-controls.js on its first run: the text and
   number boxes had a focus style and the SELECTS did not, so tabbing onto any
   of the 33 dropdowns showed the browser's own ring in the middle of an
   amber-on-black dialog. Matched to the other two. */
.sd-dialog select:focus { outline:none; border-color:var(--accent-dim); color:var(--accent-hi); }
.sd-src-play { color:var(--green); cursor:pointer; font-size:11px; padding:0 4px; flex-shrink:0; }
.sd-src-play:hover { color:var(--ok-hi); }
.sd-progress { display:none; align-items:center; gap:8px; }
.sd-progress.show { display:flex; }
.sd-progress-track { width:170px; height:8px; background:var(--bg-inset); border:1px solid var(--border); overflow:hidden; }
/* No width transition: the bar is updated ~20×/s from JS; a CSS transition
   re-animating that fast promotes a compositor layer that tears (black gaps in
   the fill). Frequent JS updates are already smooth without it. */
.sd-progress-fill { display:block; height:100%; width:0; background:linear-gradient(180deg,var(--accent-fill-hi) 0%,var(--accent-fill) 100%); }
.sd-progress-label { color:var(--text); font-size:11px; }

/* ── About window ──────────────────────────────────────────────────────
   Fixed width, content-height. Sized so the whole thing reads in one go
   without scrolling — the moment an About box needs a scrollbar people
   stop reading it, which is why the licence and the full notices are
   links to the shipped files rather than inline copies. */
.ab-dialog { width: 560px; }
.ab-body   { padding: 16px 20px 14px; }
.ab-head   { text-align: center; margin-bottom: 10px; }
/* The welcome screen's brand block at dialog scale. Same mark, same wordmark
   proportions (.16em tracking, --accent-hi), same tagline — a second visual
   identity for the same product in the same app would just look like an
   oversight. Sized in px rather than the welcome's vw clamps: this sits in a
   fixed-width dialog, so viewport-relative type would drift against it. */
.ab-logo   { width:52px; height:52px; display:block; margin:0 auto 9px;
             filter:drop-shadow(var(--logo-glow)); }
/* Share Tech Mono at weight 400, matching .wc-name. NOT Oswald 600, which is
   what this was: the wordmark is the same wordmark in both places, and the
   face has no bold, so 600 would have been synthesised anyway. */
.ab-name   { font-family:var(--font-text); font-weight:400; font-size:26px;
             letter-spacing:.16em; color:var(--accent-hi); line-height:1;
             text-shadow:0 0 14px rgb(var(--accent-hi-rgb) / .35); }
.ab-tag    { color:var(--text-hi); font-size:11px; letter-spacing:1.2px;
             margin-top:8px; }
.ab-ver    { color:var(--text-dim); font-size:10px; letter-spacing:1px; margin-top:6px; }
.ab-p      { color:var(--text); font-size:11px; line-height:1.55; margin:0 0 8px; }
.ab-p strong { color:var(--text-hi); }
.ab-sec    { color:var(--accent-ink); font-size:10px; letter-spacing:2px;
             text-transform:uppercase; margin:13px 0 6px;
             border-bottom:1px solid var(--border); padding-bottom:4px; }
.ab-list   { margin:0; padding-left:16px; color:var(--text-dim);
             font-size:10.5px; line-height:1.5; }
.ab-list li { margin-bottom:3px; }
.ab-list strong { color:var(--text); font-weight:normal; }
/* Credited works link out to where we got them. Underline is dotted so the
   list still reads as prose rather than a wall of links; `a strong` inherits
   so the hover colour wins over the .ab-list strong rule above. */
.ab-list a        { color:var(--text); text-decoration:none;
                    border-bottom:1px dotted var(--border); }
.ab-list a strong { color:inherit; }
.ab-list a:hover, .ab-list a:focus-visible
                  { color:var(--accent-ink); border-bottom-color:var(--accent-ink); }
.ab-lic    { color:var(--text-dim); font-size:10.5px; line-height:1.5;
             margin:13px 0 0; padding-top:10px; border-top:1px solid var(--border); }
.ab-lic strong { color:var(--accent-ink); }
/* Anchors styled as buttons: .btn assumes a <button>, so restore the bits an
   <a> doesn't inherit. */
.ab-link   { text-decoration:none; display:inline-flex; align-items:center;
             font-size:11px; }

/* ── Song tabs (multi-song modules) ─────────────────────────────────────
   Only rendered when a file actually holds more than one song, which is
   rare — so this is deliberately quiet chrome that costs nothing on the
   ordinary single-song path. Styled off the existing editor-bar tokens so
   it reads as part of the same strip rather than a new UI language. */
#song-tabs {
  display: flex; align-items: center; gap: 4px;
  padding: 4px 8px;
  background: var(--bg-panel);
  border-bottom: 1px solid var(--border);
  overflow-x: auto;                 /* many songs must scroll, never wrap the row */
  white-space: nowrap;
}
#song-tabs .st-lbl {
  font-size: 10px; letter-spacing: .12em; opacity: .55;
  margin-right: 4px; flex: 0 0 auto; cursor: help;
}
#song-tabs .st-tab {
  flex: 0 0 auto;
  font: inherit; font-size: 12px;
  padding: 3px 10px;
  background: transparent;
  color: var(--text-dim);
  border: 1px solid var(--border-control);
  border-radius: var(--radius-control) var(--radius-control) 0 0;
  cursor: pointer;
  max-width: 220px; overflow: hidden; text-overflow: ellipsis;
}
#song-tabs .st-tab:hover { background: var(--bg-hover); color: var(--text-hi); }
#song-tabs .st-tab.active {
  /* The ACTIVE tab merges into the panel below it — same background, no bottom
     edge — and takes the amber the rest of the app uses for "this one". */
  background: var(--bg-panel);
  color: var(--accent-hi);
  border-color: var(--accent-dim);
  border-bottom-color: transparent;
}

/* ── Song Selector dialog (Song > Select) ─────────────────────────────── */
#sg-overlay {
  position: fixed; inset: 0; z-index: 1500;
  background: var(--scrim-modal);
  display: flex; align-items: center; justify-content: center;
}
#sg-overlay .sg-dialog {
  background: var(--bg-panel);
  border: 1px solid var(--border-hi);
  border-radius: 4px;
  min-width: 420px; max-width: calc(92vw / var(--ui-scale)); max-height: calc(80vh / var(--ui-scale));
  display: flex; flex-direction: column;
  box-shadow: 0 8px 32px var(--scrim);
  /* max-height alone does NOT clip an oversized child — without this, a long
     song name in .sg-foot painted straight through the dialog border and
     covered the screen (measured: a 30,000-char name made .sg-foot 6271px
     tall, pushing the Close button to y=6398 with body{overflow:hidden}, so
     there was no way to reach it). The sibling .sd-dialog already had this. */
  overflow: hidden;
}
#sg-overlay .sg-title {
  padding: 8px 12px; font-size: 13px; letter-spacing: .04em;
  border-bottom: 1px solid var(--border);
}
#sg-overlay .sg-list { overflow-y: auto; flex: 1 1 auto; padding: 4px 0; }
#sg-overlay .sg-row {
  display: flex; align-items: baseline; gap: 8px;
  padding: 4px 12px; cursor: pointer; font-size: 12px;
}
#sg-overlay .sg-row:hover { background: var(--bg-hover); }
#sg-overlay .sg-row.current { background:var(--accent-wash); }
#sg-overlay .sg-mark { width: 10px; color: var(--accent-ink); }
#sg-overlay .sg-num  { opacity: .55; font-variant-numeric: tabular-nums; }
#sg-overlay .sg-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#sg-overlay .sg-meta { opacity: .45; font-size: 11px; flex: 0 0 auto; }
/* Per-song Export. Dim until the row is hovered so the list still reads as a
   list of songs rather than a column of buttons, but never display:none —
   keyboard users tab to it, and a control that only exists on hover can't be
   reached that way. */
#sg-overlay .sg-exp {
  flex: 0 0 auto; font-size: 10px; padding: 1px 7px; line-height: 1.6;
  opacity: .35; transition: opacity .12s;
}
#sg-overlay .sg-row:hover .sg-exp,
#sg-overlay .sg-row.current .sg-exp { opacity: .85; }
#sg-overlay .sg-exp:hover, #sg-overlay .sg-exp:focus-visible { opacity: 1; }

/* File-save notices (js/file-save.js) — built from .sd-overlay/.sd-dialog so
   they match the unsaved-changes dialog. Narrower than the settings dialog:
   this is prose, and 760px of it is a wall. */
.fs-notice .fs-dialog { width: 560px; }
.fs-notice .fs-msg p { margin: 0 0 12px; line-height: 1.55; }
.fs-notice .fs-msg p:last-child { margin-bottom: 16px; }
.fs-notice .fs-btns { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.fs-notice .fs-btns .btn { flex: 1 1 auto; min-width: 140px; padding: 7px 10px; }
/* The footer shows the CURRENT song's name, and Win MED allows 30,000-char
   names. The .sg-row list was always clamped (.sg-name below); the footer was
   not, which is what filled the screen. Same three-property clamp the song-tab
   strip uses, plus flex so the label stays put and only the name ellipsises. */
#sg-overlay .sg-foot {
  padding: 6px 12px; border-top: 1px solid var(--border); font-size: 12px;
  display: flex; gap: 6px; align-items: baseline; min-width: 0;
}
#sg-overlay .sg-foot-lbl { opacity: .55; flex: 0 0 auto; }
#sg-overlay .sg-foot-name {
  flex: 1 1 auto; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#sg-overlay .sg-note {
  padding: 0 12px 8px; font-size: 11px; opacity: .5; line-height: 1.4;
}
#sg-overlay .sg-btns {
  padding: 8px 12px; border-top: 1px solid var(--border);
  display: flex; gap: 8px; justify-content: flex-end;
}

/* Expand / Shrink BLOCK and SONG. Same shape — one Factor field, two buttons —
   so the selectors carry both dialogs rather than the Song one inheriting
   nothing and silently falling back to the generic .sd-dialog rules. That is
   not hypothetical: `.sd-dialog select { max-width:130px }` beats a same-
   specificity rule on source order, which is exactly how the LPB dropdown got
   its text clipped once already, and the two-column button grid below is
   likewise scoped to a class the Song dialog does not have. */
/* Expand / Shrink — one dialog for both scopes since 2026-08-12, so one set of
   rules where there were two identical ones under .xsb-/.xss- prefixes. */
.sd-dialog.xs-dialog { width:min(440px, calc(94vw / var(--ui-scale))); }
.xs-dialog .fr-row label { font-size:10px; color:var(--text-dim); letter-spacing:.5px; min-width:56px; }
.sd-dialog.xs-dialog .fr-row select { max-width:none; flex:0 0 90px; }
.xs-dialog .fr-help { margin:0 0 10px; }
.xs-dialog .xs-btns { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr));
                        gap:8px; justify-content:stretch; margin:0 0 4px; }
.xs-dialog .set-hint { margin-top:8px; }

/* The Song dialog's one extra control. The checkbox row is a LABEL wrapping its
   own input, so the whole phrase is the hit target — and it overrides the
   56px label min-width above, which exists to align the Factor row's caption
   and would otherwise indent this sentence to nothing. */
.xs-dialog .xs-lpbrow { margin-top:2px; margin-bottom:2px; }
/* The row is now a <label class="sop-check xs-lpbrow"> so it borrows the Song
   Options checkbox styling; only the disabled state needs saying, and it says a
   lot here — in block scope this control is deliberately visible-but-dead. */
.xs-dialog .xs-lpbrow { font-size:10px; letter-spacing:.5px; }
.xs-dialog .xs-lpbrow:has(input:disabled) { opacity:.45; cursor:default; }
/* The lines-per-beat note carries the reason the control is dead in block
   scope, so it reads as information rather than the dimmed footnote .fr-help
   is elsewhere. */
.xs-dialog #xs-lpbnote { color:var(--text); opacity:.9; margin:0 0 10px 22px; }

/* Block Properties (Alt+9). Third of the block windows, so it joins the shared
   selectors above rather than inheriting the generic .sd-dialog rules — the
   `.sd-dialog select { max-width:130px }` trap that clipped the LPB dropdown
   applies here too, and this dialog has a select of its own. */
/* THE PROPERTIES-SWEEP DIALOGS SHARE BLOCK PROPERTIES' SHAPE, from one rule
   rather than four copies. Phil, 2026-08-11: "The song tempo window is very
   wide compared to the controls it contains. The info text seems to be padding
   it out a lot."

   Two causes, both here. Without a width the dialog sizes to its CONTENT, so a
   sentence of explanation set the window's width — the controls were never the
   widest thing in it. And `.fr-help` carries `margin-left:112px` for the
   find-and-replace layout it was written for, which .bpr-dialog already
   overrode and the new dialogs inherited, pushing the text right and making it
   wider still.

   Listing every dialog together is deliberate, and Song Options proved the point
   by arriving after the rule was written and inheriting nothing — its folded
   group's intro text sat indented to 112px until it was added here. Writing this
   per dialog is
   what left the new ones inheriting nothing, which is the same failure
   stuff/regression/dialog-controls.js exists to catch for CONTROLS. */
.sd-dialog.bpr-dialog,
.sd-dialog.spr-dialog,
.sd-dialog.stm-dialog,
.sd-dialog.blh-dialog { width:min(440px, calc(94vw / var(--ui-scale))); }
.sd-dialog.ann-dialog { width:min(560px, calc(94vw / var(--ui-scale))); }   /* wider: it holds a paragraph */

/* SONG PROPERTIES IS WIDER THAN ITS SIBLINGS since 2026-08-22: it took the
   Playback-compatibility rows over from Settings, and those are a 124px label
   plus a dropdown whose longest option is "SoundStudio mix - Windows". At the
   shared 440px the dropdown measured 130px and could not show it. 480 plus a
   shorter label column gives it ~198px, which fits every option. */
.sd-dialog.spr-dialog { width:min(480px, calc(94vw / var(--ui-scale))); }
.spr-dialog .set-label { flex-basis:104px; width:104px; }
/* The compat dropdowns take the room the label gave up rather than a fixed
   width -- .set-dialog's 316px is scoped to Settings and never applied here. */
/* max-width:none IS THE LOAD-BEARING PART. `.sd-dialog select` caps every
   dropdown at 130px, and setting flex/width without lifting that cap leaves
   the select at 130 with 177px of empty row beside it -- measured. Four other
   rules in this file carry the same trio for the same reason. */
.spr-dialog .set-row > select.set-sel { max-width:none; flex:1 1 auto; width:auto; min-width:0; }
/* ── SONG PROPERTIES AS THREE TABS (2026-09-28, plan stage 5) ──────────────
   The strip sits inside the body like Instrument Properties' and sticks there;
   what follows is only what the three tabs need beyond the shared rules. */
.spr-dialog .sd-body > .spr-tabs { margin:0 0 12px; padding:0; }
/* ONE HEIGHT FOR EVERY TAB, the point of tabs (the Settings note above has the
   whole argument): the window must not resize as you move between them.
   MEASURED 2026-09-28 at Normal density, scale 1, on a 26-track mix module -
   the tallest case, since the mixing group shows only in mix mode: Song 369,
   Playback 293 (380 with Quirks open), File 242. 382 is the tallest plus two,
   so a sub-pixel rounding cannot put a scrollbar on it. */
.spr-dialog .sd-body { min-height:382px; }
/* A ROW THAT DOES NOT APPLY IS NOT SHOWN (plan rule 2), and `hidden` has to
   win: .set-row, .fr-row and .fr-check all set a display of their own, which
   beats the attribute's display:none - measured, the Player row showed on a mix
   module. !important because the author rules it has to beat are more
   specific than any one selector here could be. */
.spr-dialog [hidden] { display:none !important; }
/* THE CHANNEL MODE LINE: the mode in words, and a button only where there is a
   step to take. The text takes the room; the button keeps its natural width. */
.spr-dialog .spr-mode .spr-mode-text { flex:1 1 auto; min-width:0; font-size:11px; color:var(--text-hi); }
.spr-dialog .spr-mode .btn { flex:0 0 auto; padding:2px 8px; font-size:10px; }
/* A PITCH THE MODULE CANNOT CHOOSE is a line of text where the select would be. */
.spr-dialog .spr-fixed { flex:1 1 auto; font-size:11px; color:var(--text); }
/* A MODULE'S OWN CHOICE (plan rule 4): the accent the pins used, on the whole
   row, so one glance shows what has been set for this module. */
.spr-dialog .set-row.spr-chosen > .set-label { color:var(--accent-ink); }
.spr-dialog .set-row > select.set-sel.spr-chosen { color:var(--accent-ink); border-color:var(--accent-dim); }
.spr-dialog #spr-plugtemponote, .spr-dialog #spr-amigaline { margin:2px 0 0; }
.spr-dialog #spr-amigaline:empty, .spr-dialog #spr-plugtemponote:empty { display:none; }
.spr-dialog .spr-quirks .set-section { margin-top:4px; }

/* ── Instrument Properties ─────────────────────────────────────────────
   THIS DIALOG NEVER GOT ITS LAYOUT WRITTEN. `.ipr-dialog`, `.ipr-tabs`,
   `.ipr-lbl` and `.ipr-num` matched ZERO rules in this stylesheet — verified by
   walking document.styleSheets in the running app — so it was the one
   properties dialog left out of the shared rule below, with no label column and
   no cancel for the 112px `.fr-help` indent that rule exists to undo.

   Phil, 2026-08-29: "The layout in general is just really poor, everything
   listed vertically instead of using the horizontal space."

   MEASURED before this: at a 734px row width, 79-84% of every row was empty —
   Volume 601px unused, Transpose 582px, Finetune 588px. Every field was a fixed
   62px box on its own line.

   Windows MED's own dialog (RT_DIALOG id 114) is five full-width group boxes,
   each laid out internally in TWO TO FOUR COLUMNS. That is what the grid below
   does: field rows flow two-up until the dialog is too narrow for it, so the
   horizontal space carries pairs of fields instead of whitespace. */
.ipr-dialog .set-section .ipr-grid {
  display:grid; grid-template-columns:repeat(auto-fit, minmax(280px, 1fr));
  gap:0 18px; align-items:start;
}
/* GROUPS SET THEIR OWN WIDTH, and the selector matches the one above rather
   than being shorter than it — `.ipr-dialog .ipr-g3` is (0,2,0) against that
   rule's (0,3,0) and would lose regardless of order.
   A group is a semantic unit, so it gets a column count that fits it: three for
   the amplitude trio, two for the tuning pair. Each grid is its own element, so
   a narrow window collapses a group to one column without ever mixing it with
   the next one — which is exactly what went wrong when all five fields shared a
   single flowing grid and Decay was pushed two lines from Hold. */
/* PACK LEFT, DO NOT STRETCH. `1fr` columns split the row evenly whatever the
   fields need: measured at a 760px dialog, each amplitude cell was 233px wide
   around 174px of content — 59px of air per field, three times a line.
   Phil, 2026-08-29: "all the parameters are still spaced too far apart."
   Each of these groups is a SINGLE row, so there is no cross-row alignment to
   preserve and a flex line is the honest layout: every field takes what it
   needs, the gap between them is the gap you set, and the leftover sits at the
   end instead of being shared out as padding. It still wraps when the dialog is
   too narrow. */
.ipr-dialog .set-section .ipr-g3,
.ipr-dialog .set-section .ipr-g2,
.ipr-dialog .set-panel .ipr-g4,
.ipr-dialog .set-section .ipr-g4 {
  display:flex; flex-wrap:wrap; gap:4px 16px; align-items:center;
}
/* A field keeps its own natural width inside that line. */
.ipr-dialog .ipr-grid > .fr-row,
.ipr-dialog .ipr-grid > .fr-check { flex:0 0 auto; margin-bottom:0; }
/* AND NO LABEL FLOOR INSIDE A PACKED GROUP. The 82px min-width buys vertical
   alignment between STACKED rows; these fields sit side by side and are already
   different widths, so it was only padding — "Hold" is four characters and was
   reserving room for thirteen. Measured: 200px per field around 174px of
   content before, ~150px after. The floor stays on any row still stacked. */
.ipr-dialog .ipr-grid > .fr-row > label { min-width:0; }

/* The rows and the checkboxes in this group must sit on the same baseline: a
   .fr-row carries a bottom margin and a .fr-check does not, so centring the
   cells left the two number boxes 15px above the two ticks. */
.ipr-dialog .ipr-g4 > .fr-row, .ipr-dialog .ipr-g4 > .fr-check { margin-bottom:0; }

/* THE IDENTITY LINE: name takes the slack, the slider takes what is left, and
   the inline labels do not claim the 96px column the stacked rows use. */
.ipr-idrow { flex-wrap:nowrap; }
/* THE NAME FIELD HOLDS 40 CHARACTERS, so it is sized in CHARACTERS.
   Phil, 2026-08-30: "There is a large gap between name and its field. either
   make the field bigger and/or bring them closer together, if it's already big
   enough for the max field length (I don't think it is)." It was not: the input
   is maxlength=40 and the floor was 90px, so a full name could not be read back
   without scrolling it. freebuff raised the floor to 180px, which is better and
   still short of 40 characters; `ch` says what the number MEANS and follows the
   font instead of guessing at it. The flex-basis grows it further when the row
   has room, which is what closes the gap. */
.ipr-idrow #ipr-name { flex:1 1 40%; min-width:28ch; }
/* INLINE LABELS HUG THEIR OWN CONTROL. The dialog-wide 82px label column exists
   so that STACKED rows line their fields up down the page; on this one-line row
   of label/control pairs it only pushes each label away from the thing it
   names. BOTH labels on the row carry this now - the Name one did not, which is
   what made it read as part of the slot navigator to its left. */
/* SPECIFICITY, MEASURED. `.ipr-idrow .ipr-inlbl` is (0,2,0) and the column rule
   `.ipr-dialog .fr-row label` is (0,2,1) - so the column WON and this rule did
   nothing: both labels on the row still had an 82px box with their text at the
   left of it, which is the gap Phil was pointing at. Qualifying with the dialog
   makes it (0,3,0) and it wins. Caught by measuring the rendered box rather
   than by reading the rule, which looked right. */
/* AND `flex:0 0 auto`, or min-width:0 is a licence to CLIP. Dropping the 82px
   floor also dropped the only thing stopping the flex line from shrinking these
   labels below their own text: the row rendered "Instrume" and "Nam". nowrap
   stops the text WRAPPING, not the box SHRINKING. The label takes its content
   width and gives none of it back; the name field and the slider are what
   absorb the row. */
.ipr-dialog .fr-row .ipr-inlbl { flex:0 0 auto; min-width:0; white-space:nowrap; margin-left:6px; }
.ipr-idrow #ipr-slot { flex:0 0 46px; }
.ipr-idrow input[type="range"] { flex:1 1 22%; min-width:60px; }

/* THE BASIS IS THE INTENDED WIDTH, so nothing may raise the floor above it.
   Phil, 2026-08-30: "The finetune box seems to be really wide for some reason?"
   MEASURED: 138px against the 52px every other field gets. A flex item's
   `min-width: auto` is its automatic MINIMUM CONTENT SIZE, and for
   `<input type="number">` the browser derives that from the `max` attribute —
   so removing min/max from #ipr-fine (which had to go, or the spinner clamped
   the finetune CARRY dead at ±8) took its automatic minimum from 4 characters
   to the unbounded default, and that minimum then beat the 52px basis.
   Every one of these fields states the width it wants; this says the automatic
   minimum does not get a vote. */
.ipr-dialog .fr-row input[type="number"],
.ipr-dialog .fr-row input[type="text"] { min-width:0; }
.ipr-idrow .ipr-inchk { flex:0 0 auto; margin:0 0 0 8px; white-space:nowrap; }

/* WHAT IS LEFT OF THE FACTS LINE: the empty-state message, and nothing else.
   The type/length/format facts were removed 2026-08-31 (see index.html beside
   #ipr-noinstr). COLLAPSES WHEN EMPTY, or the head would carry a blank line and
   its margin for every instrument that exists, purely to serve the case where
   none does - and the dividers below would then measure from a gap. */
.ipr-dialog #ipr-noinstr { margin:0; color:var(--text-dim); font-size:11px; }
.ipr-dialog #ipr-noinstr:empty { display:none; }

/* WHERE THE HEAD DIVIDES.
   Phil, 2026-08-31: "Can we put some spacing or dividing lines between the top
   two rows (instrument selector, name, disabled + type, length format) and the
   volume & pitch parameters, and also a little more space between that and the
   instrument source tabs."

   Three groups answering three different questions - WHICH instrument, HOW it
   sounds, and WHAT makes the sound - and they ran together as one stack of
   rows. A rule between the first two and a wider gap before the tab strip is
   the whole change; nothing moves sideways and nothing is hidden, so every
   control keeps the place it had (feedback_ui_fixed_places).

   ONLY THE DIRECT CHILDREN OF THE BODY. The tab panels below hold .set-sections
   of their own, and a descendant selector would draw a rule inside each one. */
.ipr-dialog .sd-body > .set-section {
  border-top:1px solid var(--border);
  padding-top:10px;
  margin-top:4px;
}
/* THE TAB STRIP GETS AIR, NOT A SECOND RULE. It already carries its own bottom
   border, and two horizontal lines a few rows apart read as a box drawn round
   the parameters rather than as a separation between them. */
.ipr-dialog .sd-body > .ipr-tabs { margin-top:14px; }
/* The label column the shared rule gives every other properties dialog, so the
   fields line up down the page instead of starting wherever the label ends. */
/* TIGHTER THAN THE SHARED 96px. Phil, 2026-08-29: "The horizontal spacing of
   pretty much everything could be tightened a lot. The window is currently too
   narrow to fit everything without scrolling."
   The widest label here is "Default pitch" at 11px Share Tech Mono — about
   77px — so 96px was paying for a column nothing filled, three times per line.
   82px fits the longest one and gives ~42px back on a three-up row. The gap
   between a label and its control drops with it: these are tight pairs, and 8px
   between every one of them reads as loose at three per line. */
/* NOWRAP, or the saving becomes a cost: at 82px "Default pitch" wrapped onto a
   second line and made its whole row two lines tall, which is worse than the
   14px the tighter column saved. min-width is a floor for ALIGNMENT; the label
   still takes what it needs. */
.ipr-dialog .fr-row label { min-width:82px; white-space:nowrap; }

/* ── THE SENDS ROW (signal routing phase 4) ──
   A send whose source is an INSTRUMENT: every voice playing this slot, summed
   into a bus, whatever track it is on. It sits in the head section because it
   is a property of the CONTAINER, and it is the SHORT version of the matrix -
   destination, level, mute, remove - because the full one is edited in the
   Effects dialog from the bus's end.

   WRAPPING, not scrolling. The row is as wide as the dialog and a slot rarely
   has more than two or three sends; a fixed-height scroller here would be a
   second list control in a head made of single-line rows. `align-items:start`
   so a wrapped second line does not stretch the label with it. */
.ipr-sends-row { align-items:flex-start; flex-wrap:wrap; }
.ipr-sends { display:flex; flex-wrap:wrap; gap:4px 8px; flex:1 1 auto; min-width:0;
  align-items:center; }
.ipr-send { display:inline-flex; align-items:center; gap:3px; }
/* The destination reads as a link to the dialog it opens, not as a push
   button: it is a place, and the buttons beside it are actions. */
.ipr-send-dst { max-width:120px; overflow:hidden; text-overflow:ellipsis;
  white-space:nowrap; font-size:10px; padding:1px 4px; }
/* The three-class form, for the reason .fx-send-level records: .ipr-dialog
   .fr-row input[type="number"] is (0,3,1) and pins every number box at 52px,
   which is wider than a send level needs beside three buttons. */
.ipr-dialog .fr-row .ipr-send .ipr-send-level { flex:0 0 44px; padding:1px 3px; }
.ipr-send .btn { flex:0 0 auto; padding:1px 4px; min-width:18px; font-size:10px; }
/* A LIVE TOGGLE READS AS FILLED - the app's rule, and the same channel
   .fx-send .btn.on uses, so M means the same thing in both dialogs. */
.ipr-send .btn.on { color:var(--accent-ink); background:var(--accent-wash);
  border-color:var(--accent-ink); }
/* SILENT IS NOT ABSENT: a muted send, or one at the -60 dB floor, is still in
   the file and still in this list - it is just not in the engine's table. */
.ipr-send.silent { opacity:.5; }
.ipr-send.dropped .ipr-send-dst { color:var(--alert-hi); }
.ipr-send-drop { color:var(--alert-hi); font-size:10px; }
.ipr-dialog .fr-row #ipr-sends-add { flex:0 0 auto; max-width:45%; font-size:10px; }
.ipr-dialog .fr-row { gap:5px; margin-bottom:6px; }
.ipr-dialog .ipr-grid { gap:0 10px; }
/* The number boxes were a fixed 62px for a value that is at most four
   characters plus a sign. */
/* THE NUMERIC FIELDS, whatever their input type. #ipr-vol and #ipr-slot are
   type="text" because they show HEX and a number input cannot — but they are
   the same kind of control as the rest of the row and take the same width, so
   the rule is keyed on the field rather than on the type attribute. */
.ipr-dialog .fr-row input[type="number"],
.ipr-dialog .fr-row input.ipr-numfield { flex:0 0 52px; }
/* Readouts are commentary, not controls — they take what is left and never
   push a control off the line. */
/* A READOUT IS NOT A CONTROL, but it still has to be legible: min-width:0 with
   overflow:hidden crushed "~+63.2 cents" to 22px of nothing. It takes the slack
   the row has left and shrinks no further than its own text. */
.ipr-dialog .ipr-ro { flex:0 1 auto; min-width:0; white-space:nowrap;
  overflow:hidden; text-overflow:ellipsis; color:var(--text-dim); font-size:10px; }
/* A READOUT RESERVES ITS WORST CASE, so the fields beside it do not move when
   its text changes.
   Phil, 2026-08-29: "The parameters shift about a bit when the finetune cents
   appears and disappears." They did: the cents span goes from "0¢" to
   "~+63.2¢" and the ms spans from empty to "241 ms", and everything to their
   right slid along with them.
   THE NUMBER IS MEASURED, not guessed: the widest reachable text is finetune
   -8, "~-106.2¢", which renders at 44px in 10px Share Tech Mono against a 5.4px
   `ch` — 8.15ch. 8ch was tried and left that one case 4px wide, which moved
   Default pitch. 9ch clears it with headroom, and `ch` rather than px so it
   still holds when the appearance system changes the font size. The widest ms
   is "12.75 s" at 38px, comfortably inside it. Text sits left so it grows into
   reserved space rather than pushing anything. */
.ipr-dialog .ipr-grid .ipr-ro { min-width:9ch; text-align:left; }
/* The slot navigator's "of N" is the one readout that may be squeezed — the
   slider beside it is worth more width than the count. */
.ipr-idrow .ipr-ro { flex:0 0 auto; }
/* …and the indent that rule exists to cancel. Measured computed margin on
   #ipr-loopnote was `2px 0 12px 112px`, which is the .fr-help default leaking
   in — the exact failure the shared rule's own comment describes. */
.ipr-dialog .fr-help { margin-left:0; }

/* THE SLOT NAVIGATOR. The slider takes the slack so it is worth dragging;
   Windows MED gives its scrollbar 44% of the dialog for the same reason. */
/* THE KEY BOX IS A KEY BOX, not a name field. `.sd-dialog input[type="text"]`
   is flex:1 1 auto so it can carry a 40-character instrument name; the default
   pitch is four characters and was inheriting the whole row. */
.ipr-dialog #ipr-defpitch { flex:0 0 54px; text-align:center; cursor:pointer; }
/* WHILE THE BUTTON IS HELD the box is listening for a note, and it has to look
   different or the gesture is invisible — you would be holding the mouse on a
   text field with no sign it was going to do anything. */
.ipr-dialog #ipr-defpitch.ipr-listening {
  outline:1px solid var(--sel-edge); background:var(--bg-hover);
}
.ipr-dialog #ipr-defhz    { flex:0 0 62px; }


.spr-dialog .fr-row, .stm-dialog .fr-row,
.blh-dialog .fr-row, .ann-dialog .fr-row {
  display:flex; align-items:center; gap:8px; margin:0 0 8px;
}
.spr-dialog .fr-row label, .stm-dialog .fr-row label,
.blh-dialog .fr-row label, .ann-dialog .fr-row label {
  font-size:10px; color:var(--text-dim); letter-spacing:.5px; min-width:96px;
}
/* Left-aligned with the labels and allowed to WRAP, rather than indented to
   112px and stretching the dialog to hold one line. */
.spr-dialog .fr-help, .stm-dialog .fr-help,
.blh-dialog .fr-help, .ann-dialog .fr-help,
/* The options section inside Song Properties. The sop- prefix names the
   SECTION now, not a window — the dialog merged away on 2026-08-11. */
.spr-dialog .sop-row .fr-help { margin:0 0 10px; }
.spr-dialog .set-hint, .stm-dialog .set-hint,
.blh-dialog .set-hint, .ann-dialog .set-hint { flex:0 0 76px; }
/* ^ THE TRAILING COMMA THAT USED TO BE HERE joined these four .set-hint
   selectors to the number-box rule below, so they have been taking its
   flex:0 0 76px all along. That is PRE-EXISTING and is deliberately preserved
   verbatim rather than "fixed" — the width above is exactly what they resolved
   to before, and whether a hint should be a fixed 76px box is a separate
   question from the tempo field. Stated explicitly now only so that changing
   the number-box width cannot silently drag them along, which is precisely
   what happened on the first attempt at the split below. */

/* Number boxes stay narrow — a tempo is three digits, not a text field —
   EXCEPT the one that has to hold a fractional BPM. Three dialogs shared this
   rule; only Song Tempo needs the width, so each now states its own.

   A COMMENT DOES NOT BREAK A SELECTOR LIST. The first attempt at this split
   left `.spr-dialog ...,` with its trailing comma above the new comment, so
   Song Properties stayed joined to the widened rule and silently went to 88px —
   the exact collateral the split was for. Separate rules, no trailing commas.

   THE WIDTH IS MEASURED. In the open dialog at 11px Share Tech Mono: 5.94px per
   character, 5px of padding each side. The old shared 76px gave 63px of content
   = TEN characters. The widest value the tempo box can hold is eleven — three
   integer digits, a point, and the seven decimals js/parser.js quantizes to;
   the worst real case is 183.4600345 (Paula SPD 1 at 16 lines/beat). 666 of the
   1187 reachable exact conversions (56%) are eleven characters, so at 76px the
   majority of them scrolled their leading digits out of view (Phil, 2026-08-26).
   88px gives 75px of content = twelve characters, one spare, and the row has
   ~169px of slack so nothing else moves. All three boxes in the Song Tempo
   dialog grow together: they sit in a label-aligned column and a lone wide one
   would leave a ragged edge. */
.stm-dialog .fr-row input[type="number"] { flex:0 0 88px; }
/* Transpose (-128..127) and Mixing channels (0..64): four characters at most. */
.spr-dialog .fr-row input[type="number"] { flex:0 0 76px; }
/* Mark every / First row: plain integers to 3200, four characters. */
.blh-dialog .fr-row input[type="number"] { flex:0 0 76px; }
/* The annotation's long box is a column, so its label sits above the text. */
.ann-dialog .ann-row { align-items:flex-start; }
.ann-dialog .ann-longlabel { padding-top:4px; }
.ann-dialog .ann-buttons { justify-content:flex-start; }
/* Song Options: checkbox rows, and the folded Amiga group.
   The fold is a <details>, so the browser owns the open/closed state and
   there is no third place for it to disagree with. */

.sop-row { margin:0 0 10px; }
.sop-head { display:flex; align-items:center; gap:8px; }
.sop-check { display:flex; align-items:center; gap:8px; font-size:11px; color:var(--text-hi);
             cursor:pointer; }
.sop-check input[disabled] + span { color:var(--text-dim); cursor:default; }
.sop-check:has(input[disabled]) { cursor:default; }
.sop-name { font-size:11px; color:var(--text-hi); }
/* STATE CHIP, not a greyed checkbox. A disabled tick-box reads as a control
   that is broken; this reads as a readout, which is what it is. Fixed width so
   the labels beside ON and OFF line up down the column. */
.sop-chip { flex:none; min-width:34px; text-align:center; padding:1px 5px;
            font-size:9px; letter-spacing:.5px; border:1px solid var(--border-control);
            color:var(--text-dim); background:var(--bg-inset); border-radius:2px; }
.sop-chip.on    { color:var(--accent-ink); border-color:var(--accent-dim); }
/* Set in the file, but something downstream means it is not doing anything. */
.sop-chip.muted { color:var(--text-dim); font-style:italic; min-width:0; }
.sop-note { margin:2px 0 0 22px; }
.sop-link { color:var(--accent-ink); text-decoration:underline; cursor:pointer; }
.sop-link:hover { color:var(--accent-hi); }
.sop-fold { margin:14px 0 0; border-top:1px solid var(--border-control); padding-top:10px; }
.sop-fold > summary { font-size:10px; color:var(--text-dim); letter-spacing:.5px;
                      cursor:pointer; padding:2px 0; }
.sop-fold > summary:hover { color:var(--accent-ink); }
.sop-fold[open] > summary { color:var(--accent-ink); margin-bottom:6px; }
.bpr-dialog .fr-row { display:flex; align-items:center; gap:8px; margin:0 0 8px; }
.bpr-dialog .fr-row label { font-size:10px; color:var(--text-dim); letter-spacing:.5px; min-width:70px; }
.sd-dialog.bpr-dialog .fr-row select { max-width:none; flex:0 0 70px; }
.bpr-dialog .fr-help { margin:0 0 10px; }
.bpr-dialog .set-hint { margin-top:10px; }
/* EVERY DIALOG TEXT BOX, from one rule. This was `.bpr-dialog #bpr-name` — one
   id — and the list windows' two name boxes duly arrived looking like browser
   defaults next to it (Phil 2026-08-09). That is the same mistake the note on
   the number boxes below already records, so the fix is the same shape: style
   the KIND of control, not the instance.
   Monospace via font-family:inherit, because names line up in a list the user
   reads as a column. */
/* Checkboxes too — the accent is the only thing that stops them rendering in
   the browser's own blue. Both existing rules were dialog-scoped (.fr-check and
   .xs-dialog), which is the per-dialog pattern that keeps producing unstyled
   controls; they can stay as overrides, but the baseline is here. */
/* EVERY CHECKBOX IN EVERY DIALOG, DRAWN RATHER THAN TINTED.
   Phil, 2026-08-29: "the instrument number slider and various check boxes are
   unstyled". They were not literally unstyled — they carried accent-color, which
   only TINTS the operating system's own widget. In an app where every other
   control is drawn by us, a native checkbox reads as unstyled no matter what
   colour it is, and it was measuring 9x9 next to 11px text.
   Edge and radius come from the shared tokens, per control-uniformity.js. */
.sd-dialog input[type="checkbox"] {
  appearance:none; -webkit-appearance:none; -moz-appearance:none;
  width:13px; height:13px; margin:0; flex:0 0 auto; position:relative;
  background:var(--bg-inset); cursor:pointer;
  border:1px solid var(--border-control); border-radius:var(--radius-control);
}
.sd-dialog input[type="checkbox"]:hover:not(:disabled) { border-color:var(--border-hi); }
.sd-dialog input[type="checkbox"]:checked {
  background:var(--accent-fill); border-color:var(--accent-dim);
}
/* The tick is a rotated corner rather than a glyph, so it never depends on a
   font being present and stays crisp at 13px. */
.sd-dialog input[type="checkbox"]:checked::after {
  content:''; position:absolute; left:4px; top:1px; width:3px; height:7px;
  border:solid var(--on-accent); border-width:0 2px 2px 0;
  transform:rotate(42deg);
}
.sd-dialog input[type="checkbox"]:focus-visible {
  outline:none; border-color:var(--sel-edge); box-shadow:0 0 0 1px var(--sel-edge);
}
.sd-dialog input[type="checkbox"]:disabled { opacity:.4; cursor:not-allowed; }

/* THE FIRST RADIO IN A DIALOG (Convert to Mix Mode's pitch, plan stage 3),
   drawn the checkbox's way - the same edge, fill and focus, round - so the two
   read as one family rather than as a browser default beside ours. */
.sd-dialog input[type="radio"] {
  appearance:none; -webkit-appearance:none; -moz-appearance:none;
  width:13px; height:13px; margin:0; flex:0 0 auto; position:relative;
  background:var(--bg-inset); cursor:pointer;
  border:1px solid var(--border-control); border-radius:var(--radius-round);
}
.sd-dialog input[type="radio"]:hover:not(:disabled) { border-color:var(--border-hi); }
.sd-dialog input[type="radio"]:checked { border-color:var(--accent-dim); }
.sd-dialog input[type="radio"]:checked::after {
  content:''; position:absolute; left:2px; top:2px; width:7px; height:7px;
  border-radius:var(--radius-round); background:var(--accent-fill);
}
.sd-dialog input[type="radio"]:focus-visible {
  outline:none; border-color:var(--sel-edge); box-shadow:0 0 0 1px var(--sel-edge);
}
.sd-dialog input[type="radio"]:disabled { opacity:.4; cursor:not-allowed; }

/* EVERY RANGE INPUT IN EVERY DIALOG — the baseline for the dry/wet faders
   (the first range to reach a dialog: the wam-gui controls window). Written
   the day the first one arrived rather than the day after, per the comment
   below: the accent-colour idiom matches the shipped plugin GUIs
   (the plugins/mediante-<name>/gui.js files use the same trick on their
   sliders; written with <name> rather than a glob because a star-slash ENDS
   THE COMMENT — see comment-terminators.js). */
/* EVERY SLIDER IN EVERY DIALOG. Same story as the checkbox above: a native
   range with an accent colour is still the OS's slider. The instrument-number
   slider is the first one users actually drag, so it is drawn to match — a
   thin inset track with a raised thumb, the same shape language as the rest of
   the controls.

   The vendor pseudo-elements are written as SEPARATE rules on purpose. An
   invalid selector invalidates the WHOLE selector list, so grouping the WebKit
   and Gecko names would silently drop the rule in both engines. */
.sd-dialog input[type="range"] {
  appearance:none; -webkit-appearance:none; -moz-appearance:none;
  height:14px; margin:0; padding:0; background:transparent; cursor:pointer;
}
.sd-dialog input[type="range"]::-webkit-slider-runnable-track {
  height:4px; background:var(--bg-inset);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
}
.sd-dialog input[type="range"]::-moz-range-track {
  height:4px; background:var(--bg-inset);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
}
.sd-dialog input[type="range"]::-webkit-slider-thumb {
  appearance:none; -webkit-appearance:none;
  width:9px; height:14px; background:var(--accent-ink);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
  /* Centre the thumb on the track: (track 4px + 2px border - thumb 14px) / 2. */
  margin-top:-6px;
}
.sd-dialog input[type="range"]::-moz-range-thumb {
  width:9px; height:14px; box-sizing:border-box; background:var(--accent-ink);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
}
.sd-dialog input[type="range"]:hover::-webkit-slider-thumb { background:var(--accent-hi); }
.sd-dialog input[type="range"]:hover::-moz-range-thumb     { background:var(--accent-hi); }
.sd-dialog input[type="range"]:focus-visible::-webkit-slider-thumb { border-color:var(--sel-edge); }
.sd-dialog input[type="range"]:focus-visible::-moz-range-thumb     { border-color:var(--sel-edge); }
.sd-dialog input[type="range"]:disabled { opacity:.4; cursor:not-allowed; }

.sd-dialog input[type="text"] {
  flex:1 1 auto; min-width:0; font-family:inherit; font-size:11px;
  background:var(--bg-inset); color:var(--text-hi);
  border:1px solid var(--border-control); border-radius:var(--radius-control); padding:4px 6px;
}
.sd-dialog input[type="text"]:focus {
  outline:none; border-color:var(--accent-dim); color:var(--accent-hi);
}
/* EVERY SEARCH FIELD IN EVERY DIALOG — same baseline as the text input above;
   the picker's filter toolbar (wam-plugins-search) is the first type="search"
   to reach a dialog. Search boxes get a cancel ✕ from the browser in most
   engines; WebKit also shows one. Both stay visible over the dark field. */
/* EVERY PASSWORD FIELD IN EVERY DIALOG. The two API-key rows in Settings are
   the first type="password" anywhere in the app, and without this they would
   render as a browser default -- a white box in a dark dialog, which is the
   exact failure stuff/regression/dialog-controls.js exists to catch. Identical
   to the text baseline above on purpose: a key field is a text field whose
   characters happen to be dotted out, and nobody should be able to tell which
   of the two they are looking at. */
.sd-dialog input[type="password"] {
  flex:1 1 auto; min-width:0; font-family:inherit; font-size:11px;
  background:var(--bg-inset); color:var(--text-hi);
  border:1px solid var(--border-control); border-radius:var(--radius-control); padding:4px 6px;
  letter-spacing:2px;
}
.sd-dialog input[type="password"]:focus {
  outline:none; border-color:var(--accent-dim); color:var(--accent-hi);
}
.sd-dialog input[type="search"] {
  flex:1 1 auto; min-width:0; font-family:inherit; font-size:11px;
  background:var(--bg-inset); color:var(--text-hi);
  border:1px solid var(--border-control); border-radius:var(--radius-control); padding:4px 6px;
}
.sd-dialog input[type="search"]:focus {
  outline:none; border-color:var(--accent-dim); color:var(--accent-hi);
}
/* EVERY TEXTAREA IN EVERY DIALOG, written the day the first one arrived rather
   than the day after. Module Annotation (task #39) is the first textarea
   anywhere in the app — before it index.html contained none and this file had
   no rule for one — so it is the fourth control type to reach a dialog and the
   first that did NOT land looking like a browser default. It deliberately
   matches the text input above: an annotation box is a text field that happens
   to be several lines tall, and nobody should be able to tell which of the two
   they are looking at.
   `resize:vertical` only — the width belongs to the dialog's layout and a
   horizontal drag would break it, while the height is genuinely the reader's
   business when the text can be one line or a page. */
.sd-dialog textarea {
  flex:1 1 auto; min-width:0; font-family:inherit; font-size:11px; line-height:1.45;
  background:var(--bg-inset); color:var(--text-hi);
  border:1px solid var(--border-control); border-radius:var(--radius-control); padding:4px 6px;
  resize:vertical;
}
.sd-dialog textarea:focus {
  outline:none; border-color:var(--accent-dim); color:var(--accent-hi);
}
/* EVERY NUMBER BOX IN EVERY DIALOG. This was `.bpr-dialog input[type=number]`,
   and the sequence window's "Plays block" box duly arrived unstyled next to it
   (Phil 2026-08-09) — the THIRD time a new control has landed looking like a
   browser default, after the number boxes themselves and the list-window name
   fields. The pattern was per-dialog rules, which means every new dialog starts
   from nothing. The base now belongs to .sd-dialog and a dialog only overrides
   what is genuinely its own (a width, an alignment).
   stuff/regression/dialog-controls.js fails if a control TYPE ever appears in a
   dialog with no .sd-dialog-level rule behind it. */
.sd-dialog input[type="number"] {
  flex:0 0 62px; font-family:inherit; font-size:11px; text-align:right;
  background:var(--bg-inset); color:var(--text-hi);
  border:1px solid var(--border-control); border-radius:var(--radius-control); padding:4px 6px;
}
.sd-dialog input[type="number"]:focus { outline:none; border-color:var(--accent-dim); }
/* THE NATIVE SPINNERS ARE GONE. At 11px in a dark box they render at the
   browser's own scale — taller than the field, light-on-light, and nothing about
   them can be themed portably. MED has spin buttons here (updown 1068 and 1070
   beside the Lines and Command Pages boxes), so they are replaced rather than
   simply dropped: the .bpr-spin pair below is ours, sized to the row. */
.sd-dialog input[type="number"]::-webkit-outer-spin-button,
.sd-dialog input[type="number"]::-webkit-inner-spin-button {
  -webkit-appearance:none; appearance:none; margin:0;
}
.sd-dialog input[type="number"] { -moz-appearance:textfield; appearance:textfield; }
/* A HELD ARROW MUST NOT BECOME A TEXT SELECTION. A press that drifts even one
   pixel can start a native selection or drag in Chrome and Safari, and that
   fires pointercancel at the button -- which is one of the ways a hold-to-repeat
   dies on real input while testing clean under synthetic events. touch-action
   also stops a touch press being claimed as a scroll gesture. */
.bpr-spin button { user-select:none; -webkit-user-select:none; touch-action:none; }
.bpr-spin { display:flex; flex-direction:column; gap:1px; flex:0 0 auto; }
.bpr-spin button {
  width:16px; height:11px; padding:0; line-height:1; font-size:7px;
  display:flex; align-items:center; justify-content:center;
  background:var(--btn-face); color:var(--text-dim);
  border:1px solid var(--border-control); border-radius:var(--radius-control); cursor:pointer;
}
.bpr-spin button:hover  { background:var(--btn-hi); color:var(--accent-hi); }
.bpr-spin button:active { background:var(--btn-press); }
/* The read-only line: what this block IS, as opposed to what you can change. */
.bpr-dialog .bpr-info {
  margin-top:10px; padding-top:8px; border-top:1px solid var(--border);
  font-size:9px; color:var(--text-dim); letter-spacing:.5px; line-height:1.5;
}

/* Song Tempo: the derived MUSICAL bpm, which disagrees with the tempo box at
   any ticks-per-line other than 6. Highlighted only when they disagree — at
   the conventional 6 it is a confirmation, not a warning. */
.stm-musical { margin-top: 4px; }
.stm-musical.stm-mismatch { color: var(--accent-hi); }

/* Song Tempo: the Edit button through to Tempo Operations, which is the only
   route to that dialog now that it has left the menu bar. */
.stm-ops { display:flex; align-items:center; gap:8px; margin-top:8px; }
.stm-ops .fr-help { margin:0; flex:1 1 auto; min-width:0; }

/* MODAL INTERRUPTS SIT ABOVE THE FLOATING PANELS. js/panel.js raises a
   focused panel with an INLINE z-index that climbs from Z_BASE 160 toward
   Z_CEIL 900, so a dialog relying on .sd-overlay's 300 disappears behind
   panels as soon as a few have been opened — Phil, 2026-08-11: the unsaved-
   changes warning "doesn't come to the front". This must outrank every panel
   AND the menus (1000/1001), while staying under #loading (2000).
   ONE SELECTOR SINCE 2026-09-12: #unsaved-overlay was retired and its warning
   is one of #cfm-overlay's callers now (mixer round 2, C3), so the dialog Phil
   was talking about is literally this one. */
#cfm-overlay { z-index:1500; }

/* A spin pair whose field is disabled. Matches the input's own disabled look
   rather than hiding, so the control still reads as present-but-inert. */
.bpr-spin.bpr-off { opacity:.4; pointer-events:none; }

/* Divider between the editable song fields and the flags section that the
   Song Options merge brought in. */
.spr-optsep { border-top:1px solid var(--border-control); margin:14px 0 12px; }

/* ── Dockable panel sections ─────────────────────────────────────────────
   NOTHING HERE USES display:none, and that is the whole point. The first cut
   did, and display is not an animatable property — so collapsing snapped, and
   undocking animated in one direction only (SEQUENCE had an explicit flex to
   move; BLOCKS just filled the space its vanished sibling left, instantly).
   Phil, 2026-08-12: "docked sequence height animates smoothly when blocks is
   undocked, but not the other way round. Clicking the title bar to
   collapse/expand doesn't animate."

   Collapsing to zero size animates and keeps the element in flow, so both
   directions move the same way. pointer-events:none because a zero-height box
   with overflow:hidden is invisible but still hit-testable at its edge. */
/* THE FOLD IS FLEX, NOT max-height.                          (2026-08-12)
   Every folding element is a child of a flex COLUMN (#left-panel,
   #right-panel, #modinfo-section — all measured), so the property that decides
   its height is flex, and max-height only ever CLAMPED that height.

   max-height cost four failed attempts because it needs a MEASUREMENT: to open,
   something has to know the height to animate towards. Measuring means briefly
   unfolding, and the answer is wrong whenever the parent column has not settled
   — most obviously when the sibling sections are themselves mid-fold. Measured:
   expanding SEQUENCE read its natural height as 24px (the .med-list min-height
   floor) instead of ~300px, pinned max-height there, and the section stayed
   shut while reporting itself open.

   flex-grow and flex-basis need no measurement at all. They are a plain number
   and a length, both explicit at both ends, and the parent redistributes the
   space. No inline styles, no hand-back timer, nothing to leave behind.

   TRAP 1 STILL APPLIES, which is why the open state below declares real
   values: a flex child left at the `flex-basis: auto` initial cannot
   interpolate to 0. That is what snapped MODULE INFO shut in an earlier
   attempt. Every folding element now carries an explicit basis. */
/* NO `overflow: hidden` HERE — it silently un-scrolled the lists.
   `.med-list { overflow-y: auto }` (line ~365) and this rule have the SAME
   specificity, so being further down the file this one won and every docked
   list lost its scrollbar: measured #song-list at 303px tall holding 792px of
   content, clipped with no way to reach the rest. Clipping is only needed
   WHILE FOLDED, to hide the content as the box collapses, so it belongs on the
   folded state rather than on every section for all time. */
/* #modinfo-section IS IN THIS LIST BY NAME, and it has to be. Every other
   participant is reached by an attribute — [data-dock] for the sections that
   can undock, [data-dockpart="body"] for the bodies that fold — and MODULE
   INFO's section has neither: it cannot undock (there is no MODULE INFO
   window) and it is a section, not a body. So it was the ONE element
   reflow() animates that had no transition at all, measured
   transition-duration: 0s.
   The symptom was precise (Phil, 2026-08-12): "the module info panel seems to
   stop animating when it reaches the bottom of the instruments list."
   Collapsing INSTRUMENTS moves two grows at once — instruments 1 -> 0 and
   module info 0 -> 1 — and with module info's jumping instantly to 1 while
   instruments was still at 1, the two split the free space evenly on frame
   one. MODULE INFO leapt to half the column, met the instruments list, and
   everything after that was just its neighbour shrinking. */
[data-dock], [data-dockpart="body"], .sec-head, #modinfo-section {
  transition: flex-basis .18s ease, flex-grow .18s ease,
              min-height .18s ease, opacity .14s ease, padding .18s ease;
}
/* The header still clips: .sec-name ellipsises inside it. */
.sec-head { overflow: hidden; }
/* !important, because js/layout.js reflow() writes an inline `flex` onto
   #song-list when it is the last section standing. Without this the fold would
   lose to that inline value exactly when one section is left. */
.sec-folded, [data-dock].undocked {
  /* Clip HERE, not on the open state: a collapsing box has to hide its content
     as it shrinks, but an open list has to be able to scroll it. */
  overflow: hidden !important;
  flex-grow: 0 !important;
  flex-basis: 0 !important;
  min-height: 0 !important;
  padding-top: 0 !important; padding-bottom: 0 !important;
  border-width: 0 !important;
  opacity: 0;
  pointer-events: none;
}
/* INSTRUMENTS is the one section with a wrapper of its own, so it is the one
   that needs a floor: collapsed it must still show its title bar, undocked it
   must vanish outright (the fold rule's `min-height: 0 !important` wins there).
   THIS EXISTS SO js/layout.js NEVER HAS TO CONTENT-SIZE THE SECTION. Switching
   its flex-basis to `auto` was the old way to keep the header, and auto cannot
   interpolate — which is exactly why collapsing snapped while undocking, which
   leaves the basis at 0%, animated correctly.

   NOT `min-content`. That was the first attempt and it is UNBOUNDED: the value
   follows the list's content, so a sample-heavy module inflated the floor and
   pushed MODULE INFO off the bottom of the panel — measured with 63 rows, the
   section claimed 1246px compact and 2548px with the rows expanded, putting
   MODULE INFO 1687px past the panel's edge (Phil, 2026-08-12: "toggling the
   height of the list can push the module info panel off the screen"). Zeroing
   the list's own min-height does NOT bound it either; measured identical.

   A LENGTH IS THE ONLY THING THAT IS BOUNDED, and the variable keeps it from
   being a magic number: the header is pinned to the same value it floors the
   section at, so the two cannot drift and a collapsed section is exactly its
   title bar with nothing left over. */
:root { --sec-head-h: 20px; }
.sec-head { min-height: var(--sec-head-h); box-sizing: border-box; }
[data-dock="instr"] { min-height: var(--sec-head-h); }

/* The splitter has no content to shrink, so it is the one thing still hidden
   outright — a 1px line fading is worse than it simply not being there. */
.splitter-h.undocked { display: none !important; }

/* ── Section headers ─────────────────────────────────────────────────────
   The caret and the buttons were "quite small and hard to see" (Phil,
   2026-08-12) — the caret is the affordance that says a header is clickable at
   all, so it earns real contrast rather than the .55 the panel chrome uses. */
.sec-head { display: flex; align-items: center; gap: 5px; cursor: pointer; user-select: none; }
/* THE NAME YIELDS, THE BUTTONS NEVER DO. min-width:0 is the load-bearing part:
   a flex item's automatic minimum size is its CONTENT, so `flex: 1 1 auto`
   alone refuses to shrink below the full section name — and since .sec-head
   carries overflow:hidden (the fold rule above), the buttons were pushed out of
   the header and clipped on a narrow panel rather than the text truncating.
   Phil, 2026-08-12: "the undock buttons should always be visible no matter the
   size of the panel." */
.sec-head .sec-name { flex: 1 1 auto; min-width: 0;
                      overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sec-head .ph-btn, .sec-head .sec-undock, .sec-head .sec-caret { flex: none; }
.sec-head .sec-caret {
  font-size: 13px; line-height: 1; color: var(--header-ink);
  transition: transform .18s ease, color .15s ease;
}
.sec-head.sec-collapsed .sec-caret { transform: rotate(-90deg); }
.sec-head:hover .sec-caret { color: var(--header-ink); }
.sec-head:hover .sec-name { color: var(--header-ink); }
/* Panel-header buttons: the undock glyph and the instrument height toggle.
   Legible at rest, not merely on hover.
   --header-ink, NOT --text: these sit ON the title bar, so they belong to the
   bar's ink and not to the body text. Under a theme whose title bar is a
   different colour from its panels they were the only dark things on a blue
   band -- Phil, 2026-08-22: "make the glyphs white ... but keep the black box
   outline." The outline is --border-control and is deliberately untouched.
   Hover is the SAME colour and differs by opacity alone, which is the mechanism
   already here; giving hover its own accent put a black glyph on blue again. */
.sec-head .ph-btn, .panel-header .ph-btn, .sd-title .ph-btn {
  font-size: 12px; color: var(--header-ink); opacity: .85;
  transition: opacity .15s ease, color .15s ease;
}
.sec-head .ph-btn:hover, .panel-header .ph-btn:hover,
.sd-title .ph-btn:hover { opacity: 1; color: var(--header-ink); }
.panel-header .ph-btn.on, .sd-title .ph-btn.on { color: var(--header-ink); }
/* .sd-title JOINED THAT LIST 2026-08-23. A panel's header gadgets took
   --header-ink when DOCKED and fell back to the bare .ph-btn rule's
   --text-dim once the same panel was floated, so the instrument list's
   height toggle changed colour depending on where the window was. Phil:
   "The instrument list window height toggle doesn't have the same colour
   as it does in the docked panel." It is the same control on the same kind
   of bar; the only thing that differed was which selector had been
   remembered. */

/* ── Smooth height / width transitions ───────────────────────────────────
   Phil asked for the discrete jumps to move the way a divider drag does.
   flex-basis and flex-grow are what the layout actually changes. */
/* NO id-level transition rule here. There was one — `#song-list, #block-list,
   #i-list { transition: flex-basis, flex-grow }` — and being id-specificity it
   REPLACED the generic list above rather than adding to it, so max-height was
   left untransitioned on exactly the three elements that needed it. Collapsing
   then clamped the box to zero instantly while flex-basis animated underneath,
   invisibly. All three already carry data-dockpart="body", so the generic rule
   covers them; a second, narrower list could only ever disagree with it. */
#left-panel, #right-panel { transition: width .18s ease; }

/* NOT DURING A DRAG. A width transition on a splitter drag makes the panel lag
   the pointer by the whole duration, which reads as broken rather than smooth —
   the one case where animating is worse than not. */
body.lay-dragging #left-panel,
body.lay-dragging #right-panel,
body.lay-dragging #song-list,
body.lay-dragging #block-list,
/* The mixer strip's height, for the same reason: dragging the header's bottom
   edge with a .18s transition on it lags the pointer by the whole duration. */
body.lay-dragging .phch,
body.lay-dragging [data-dockpart="body"] { transition: none !important; }

/* The instrument list's compact <-> expanded row height. The rows are REUSED
   across a view change (instrument-waveforms.js inject() adds canvases to the
   existing .inr elements rather than rebuilding them), so a transition here has
   something to animate. */
/* min-height: 0 written OUT, not left to the `auto` initial — see the note
   above. Without it the compact -> expanded change snapped, which is exactly
   what Phil reported ("seems to snap very quickly"). */
.med-list[data-list="instr"] .inr { min-height: 0; transition: min-height .18s ease, padding-top .18s ease;
              transition-delay: var(--row-delay, 0ms); }

/* STAGGERED, so the change rolls across the list instead of every row moving
   at once (Phil, 2026-08-12: "could we stagger the effect so that the scaling
   rolls down the list"), at a 25ms-per-two-rows step at his request.

   THE DELAY IS A CUSTOM PROPERTY so the row's HEIGHT and its waveform's
   OPACITY ride the same ladder. transition-delay does not inherit; a custom
   property does. Setting the delay twice — once per property — would let the
   two drift, and a fade that completed uniformly while the heights rolled down
   staggered would read worse than the pop it replaced.

   THE VALUES NOW COME FROM JS, not from a `:nth-child(n+K)` stack here.
   nth-child can only count from the start of the list, so the wave always
   began at row 1 — and on a long list scrolled down, every row you could see
   sat at the 200ms cap, so clicking the toggle did nothing visible until the
   delay ran out. The origin has to follow the scroll position, and CSS cannot
   see scrollTop. js/instrument-waveforms.js stamps --row-delay per row in
   setView; see delayLadder() there for the rule. This declaration is the
   fallback for a row that has not been stamped yet. */
.med-list[data-list="instr"] .inr { --row-delay: 0ms; }

/* MODULE INFO body: needs a scroll of its own now that the splitter can
   make it small, and a min-height so the splitter has somewhere to stop. */
/* MODULE INFO's BODY carries the height, not its section.      (2026-08-12)
   This one declaration fixes two reported bugs at once, because both were the
   same missing thing — a definite flex-basis:

   1. "the splitter between instruments and module info doesn't work properly.
      Module info height doesn't scale." The splitter was resizing #i-list,
      which is INSIDE .panel-section[data-dock="instr"] — and that section keeps
      its own `flex:1`, so shrinking the list just left a hole inside a section
      that still claimed the whole column. Nothing could make MODULE INFO grow.
   2. its fold animated opacity only. flex-basis was `auto`, and auto cannot
      interpolate to 0 — the same trap that has bitten this area four times.

   WHY THE BODY AND NOT THE SECTION. Pinning the SECTION would bring back the
   "gap at the bottom when module info is collapsed" bug: a fixed-height section
   keeps its box when its body folds away. Sizing the body instead leaves the
   section content-sized (flex: 0 1 auto), so it tracks the body down to just
   the header — measured 867 = 845 + 0 + 22 with nothing left over — while the
   instruments section absorbs the difference through its own flex:1.

   160px rather than the content height, because the body scrolls: this is the
   same bargain #song-list and #block-list already make, a definite height with
   overflow inside it. The splitter overwrites it and js/layout.js persists it
   as modinfoFlex. min-height:0 so it can be dragged smaller than its content. */
#modinfo-body { overflow:auto; flex:0 0 160px; min-height:0; }
#modinfo-section { display:flex; flex-direction:column; min-height:0; }

/* The confirm dialog's sideways action (js/confirm-dialog.js altLabel/onAlt).
   Pushed to the LEFT of the row so it is nowhere near where a reflexive click
   on CANCEL or CONFIRM lands — it is a third choice, not a third answer, and
   the two answers keep the positions muscle memory expects. `hidden` needs the
   !important because .btn sets display. */
.cfm-btns .cfm-alt { margin-right: auto; }
.cfm-btns .cfm-alt[hidden] { display: none !important; }


/* ── EFFECTS DIALOG ──────────────────────────────────────────────────────
   Two columns, MED's own layout: the group list on the left, the selected
   group's parameters on the right. */
/* TWO COLUMNS keep the navigation task together: a 220px rail for groups and
   tracks, then the selected group's detail column. The dialog remains at its
   established desktop minimum; the detail column is the only elastic part. */
/* THE DETAIL COLUMN MATCHES THE PICKER, exactly (Phil 2026-08-24): both show
   the same plugin chain, so the chain should not change width when you move
   between them. 698 = the picker's 440 + 220px nav + 14px gap + 24px, the last
   being body padding this dialog has and the picker does not -- MEASURED, not
   derived, because deriving it gave 674 and left the detail column 24px short.
   Verified: detail column 438, picker content 438. Change one and change the
   other.

   `.sd-dialog.fx-dialog` for the same specificity reason as .wam-dialog. */
.sd-dialog.fx-dialog { width: min(698px, calc(94vw / var(--ui-scale)));
                       min-width: 618px; }

/* ── Effects window: the bottom holds still, the middle takes the slack ──
   Every list here used to be a fixed height (fx-list 190, fx-tracks 170,
   fx-stack-list 150) and the detail column was plain source order, so the
   window could not grow and its lower half MOVED: choosing Plugin as the
   effect type swapped two echo rows for the plugin panel, which is a
   different height, and Stereo separation, Filter and the curve slid up and
   down after it (Phil 2026-08-23).

   So: a floor and a cap on the dialog, a flex chain from the dialog to each
   list, and .fx-params-bottom pinned last. Short content sits at the floor
   with the bottom genuinely at the bottom; long content grows the window to
   the cap and then the LISTS scroll, never the dialog — that is the failure
   the stem window documents at the foot of this file, where a tall dialog
   scrolled its own title bar out of view.

   min-height:0 is load-bearing at every level. A flex item defaults to
   min-height:auto (content size) and would refuse to shrink, handing the
   scrollbar back up the chain. The 120px floors on the lists are separate and
   deliberate: a list may shrink below its content, but not to nothing. */
#fx-overlay > .sd-dialog {
  min-height: min(520px, calc(88vh / var(--ui-scale)));
  max-height: calc(88vh / var(--ui-scale)); }
/* Beats `.sd-dialog > .sd-body` (0,2,0) on specificity, not source order — the
   trap `.fr-dialog` and `.tr-dialog` both fell into further up this file. */
#fx-overlay > .sd-dialog > .sd-body {
  overflow: hidden; display: flex; flex-direction: column; }

.fx-cols { display: grid; grid-template-columns: 220px minmax(0, 1fr);
            gap: 14px; align-items: stretch;
            flex: 1 1 auto; min-height: 0; }
/* gap:0 plus the splitter's own 2px margins — 4px between the two lists, the
   same as the picker. It was 14px, which the splitter then doubled to 28
   because a gap applies on BOTH sides of it (Phil 2026-08-24: "quite a lot of
   padding on the left column"). */
.fx-nav-col { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 0; }
/* THE GROUPS LIST TAKES A PIXEL HEIGHT, the track list takes the slack, and a
   splitter sets the boundary (Phil 2026-08-24). They used to size to their own
   content and share what was left, which on a 3-group / 25-track module gave
   the groups list its floor and the tracks list everything else — fine until
   you wanted it otherwise, and there was no way to say so.

   Floors count the WHOLE column, the lesson the picker's two lists taught in
   turn: groups is head 22 + border 2 + two rows 40 + gap 6 + strip 22 = 92,
   tracks is head 22 + border 2 + three rows 60 = 84, and both carry 14px on
   top for the sub-pixel rounding through four nested flex boxes. */
.fx-list-col, .fx-track-col { width: 100%; min-width: 0;
            display: flex; flex-direction: column; gap: 6px; }
.fx-list-col  { flex: 0 0 auto; height: 190px; min-height: 120px; }
.fx-track-col { flex: 1 1 auto; min-height: 98px; }
#fx-nav-splitter { flex: 0 0 auto; margin: 2px 0; }
.fx-list-col > .fr-lbl, .fx-list-col > .fr-btns,
.fx-track-col > .fr-lbl, .fx-track-col > .fr-help { flex: 0 0 auto; }
/* A SCROLLABLE LIST NEVER CARRIES A min-height; ITS COLUMN DOES. Four lists
   here got this wrong and all four failed the same way: the column is what a
   splitter or a flex chain sizes, and a list that refuses to shrink with it
   spills past its own frame with NO scrollbar -- rows drawn outside the
   border. A floor on the list cannot work, because the list is not the thing
   being sized. The floors live on .fx-list-col, .fx-track-col,
   .fx-plugin-panel and the two .wam-*-col rules, and each counts its column's
   head, toolbar and button strip rather than just the list. */
.fx-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.fx-list-btns { justify-content: flex-start; gap: 6px; }
.fx-detail-col { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.fx-params { min-width: 0; min-height: 0; flex: 1 1 auto;
             display: flex; flex-direction: column; }
.fx-params > .fr-row { flex: 0 0 auto; }
.fx-params-mid { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.fx-params-mid > .fr-row { flex: 0 0 auto; }
.fx-params-bottom { flex: 0 0 auto; }
/* The group's colour, the same one the stem export and the channel header use
   — derived from the group index, see MEDEditor.fxGroupColor.

   A CRISP OUTLINE, the stem window's (.sd-src-dot, .sd-stem-sw). This was a
   soft --shadow-deep glow, which the tracks list below it then had to CANCEL,
   so the same colour dot was drawn two ways in one dialog and a third way in
   the channel headers (Phil 2026-08-24). One rule now, and it is the one the
   stem window already established.

   box-sizing is border-box globally (:395), so the ring eats into the 8px
   rather than growing it: the dot stays exactly the size it was and no list
   reflows. */
.fx-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; margin-right: 2px;
          border: 1px solid var(--border); }
.fx-row { align-items: center; }
/* Echo parameters remain in their fixed places when Effect type is No echo,
   but the entire row dims and its controls are disabled by fx-dialog.js. */
.fx-echo-off { opacity: .42; }
.fr-unit { color:var(--text-dim); font-size: 10px; margin-left: 6px; }
/* Filter status readout — runtime state, so it reads as text rather than as a
   field. `.on` only when a filter is actually running. */
.fx-filter { color: var(--text-dim); font-size: 11px; flex: 1 1 auto; min-width: 0;
             overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fx-filter.on { color: var(--accent-ink); }
/* The WAM/VST chain is a PANEL, not a one-line field. The heading owns the
   actions; the list below gets the full detail-column width and remains a
   dedicated scroll target for WO-17's row drag implementation. */
/* The chain list's column, so this is where its floor lives: head 22 +
   border 2 + two rows 40 + gap 6 + strip 22 = 92, plus 14 for the rounding
   through the nested flex boxes. */
.fx-plugin-panel { min-width: 0; min-height: 106px; margin-top: 14px;
                   flex: 1 1 auto; display: flex; flex-direction: column; }
/* `.fr-row` and the panel display rules are author styles, so the browser's
   user-agent `[hidden] { display:none }` rule cannot be trusted here. These
   controls are genuinely absent outside their effect type, not merely dimmed. */
.fx-dialog #fx-stack-row[hidden],
.fx-dialog #fx-rate-row[hidden],
.fx-dialog #fx-depth-row[hidden] { display: none !important; }
.fx-stack-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
                 flex: 0 0 auto; }
.fx-stack-head > label { flex: 1 1 auto; color: var(--text-dim); font-size: 10px;
                         letter-spacing: 1px; text-transform: uppercase; }
.fx-stack-actions { display: flex; align-items: center; gap: 6px; }
.fx-stack-actions .btn { flex: 0 0 auto; }
.fx-stack-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
/* A LIST, NOT A PARAGRAPH. white-space:pre-line and overflow-wrap:anywhere
   were what a newline-joined text dump needed; the chain renders real .inr
   rows now (js/fx-dialog.js) and inherits the house row grammar, so all this
   has left to do is set the type size. */
.fx-stack { display: block; width: 100%; font-size: 11px; }
/* THE PER-ROW CONTROLS BUTTON, in both windows. An inline SVG rather than a
   glyph because there is no reliable monochrome fader character: U+1F39A and
   U+1F39B both fall back here to something 6.66px wide against a 6.39px tofu,
   which does not read as a fader at button size (measured 2026-08-24). It is
   built once, in js/wam-plugins.js fadersIcon — the pin icon in index.html is
   inlined seven times and that is the trap being avoided.

   currentColor throughout, so it inherits .btn's ink and themes for free. The
   tracks are dimmed relative to the caps: three equal lines read as a barcode,
   three lines with caps read as faders. */
.ic-faders { width: 11px; height: 11px; display: block; }
.ic-faders .fd-track { stroke: currentColor; stroke-width: 1.3; stroke-linecap: round;
                       fill: none; opacity: .5; }
.ic-faders .fd-cap   { fill: currentColor; }
/* The button is an icon, so it sheds the text button's horizontal padding. */
.fx-ctrl { padding: 1px 4px; display: inline-flex; align-items: center; }

/* A bypassed entry is STILL IN THE CHAIN and still in signal-flow order, so it
   stays readable rather than being greyed past legibility. The chip says
   "bypassed" in words; the strike is the second signal, not the only one. */
.fx-stack-bypassed .inname { text-decoration: line-through; opacity: .6; }
.fx-stack-bypassed .insize { opacity: .45; }
.fx-stack-empty { padding: 7px 8px; color: var(--text-dim); font-size: 10px;
                  line-height: 1.4; }

/* The selected group's tracks live in the left navigation rail beneath the
   group list. Keep their own scroll region so a long channel list cannot push
   the detail controls out of the dialog. */
.fx-track-col { width: 100%; min-width: 0; display: flex; flex-direction: column; gap: 6px; }

/* The plugin picker (Song ▸ Plugins…). Same two-column shape as the Effects
   dialog: the registry's available plugins on the left, the group's chain on
   the right, each a scrollable list with the house control strip beneath. */
/* NARROW, because the two lists are stacked now and nothing needs the width
   (Phil 2026-08-24). Measured at the real font: the three filter selects want
   ~377 layout px of content between them and the widest row -- name, vendor
   and category chip -- wants ~328. 440 clears both with room, against the 760
   every .sd-dialog gets by default.

   `.sd-dialog.wam-dialog`, not `.wam-dialog`: the base rule sets width:760px
   and is declared LATER in this file, so a single-class selector ties on
   specificity and loses on source order. Two rules further up document the
   same trap being walked into. */
.sd-dialog.wam-dialog { width: min(440px, calc(94vw / var(--ui-scale)));
                        min-width: 360px; }
/* Available-list rows carry the plugin name and its author credit on ONE line.
   They were stacked, which halved how many plugins fit and is most of why the
   list felt cramped (Phil 2026-08-23) — with ~43 registered, two lines each is
   the difference between seeing a category and scrolling through one.

   `flex-direction: row` is SPELLED OUT rather than left to the default,
   because the value being removed is `column` and the failure is silent: an
   override that sets display:flex without naming the direction puts the row
   back at 35px and nothing else looks wrong.

   The VENDOR gives way first (`flex: 0 100 auto`) and the name never does —
   the name is what you are looking for. No line-height and no align-items
   here, deliberately: the shared `.inr > .inname { line-height: 15px }` at
   (0,2,0) already puts both children in one 15px box, which is what makes a
   row WITH a vendor exactly as tall as one without. `align-items: baseline`
   would measure a 9px credit against that box and re-create the inequality.
   (The `line-height: 1.3` that used to sit here was dead: (0,1,0) against
   that same (0,2,0) rule, and it computed to 15px anyway.) */
.wam-namewrap { display: flex; flex-direction: row; min-width: 0; }
.wam-name { flex: 0 1 auto; min-width: 0;
            overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wam-vendor { flex: 0 100 auto; min-width: 0; font-size: 9px; color: var(--text-dim);
              overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The separator rides on the vendor, so a row without one cannot leave a
   dangling bullet — the same bullet .insize uses, so one separator means one
   thing throughout the app. */
.wam-vendor::before { content: '\00B7'; margin: 0 4px 0 1px; opacity: .7; }
.wam-vendor:empty { display: none; }
/* THE LICENCE MARK (signal routing phase 5, U6). Two shipped built-ins are
   ports of unlicensed sources and are not for commercial use — CREDITS.md
   "Shapee and Ether — ported ⚠". It rides INSIDE .wam-name, so it survives the
   ellipsis the way the name itself does and takes no column of its own;
   --alert-hi because it is the same register as a refusal, and cursor:help
   because the whole content is in the tooltip. */
.wam-licence { color: var(--alert-hi); margin-left: 4px; cursor: help; }
/* A plugin row is still listed so the user can see what is available, but it
   is not an insertion target until the selected group explicitly uses the
   Plugin effect type. Keep that state visible instead of leaving a row that
   looks clickable but silently does nothing. */
.wam-row-disabled { opacity: .45; cursor: not-allowed; }
.wam-row-disabled:hover { background: transparent; }
/* THE TWO LISTS ARE STACKED, not side by side (Phil 2026-08-24). A chain of
   three plugins does not need half a window, and the registry of 43 does. The
   splitter between them is the same .splitter-h the left rail uses; the chain
   gets a pixel height it keeps, the list above takes whatever is left. */
.wam-chain-col { flex: 0 0 auto; height: 150px; min-height: 114px; }
/* THE FLOORS LIVE ON THE COLUMNS, NOT ON THE LISTS INSIDE THEM. This is the
   whole of it: a column is sized by the splitter, and a list that refuses to
   shrink with it spills past its own frame with no scrollbar -- rows visibly
   outside the border. Both lists did it in turn (Phil 2026-08-24): the chain
   at its default height, then the available list once the splitter was
   dragged. A floor on the list cannot work, because the list is not the thing
   being sized.

   AND THE FLOORS COUNT THE WHOLE COLUMN, not just the list. Measured in layout
   px: the chain column is head 30 + border 2 + two rows 40 + gap 6 + strip 22
   = 100. The available column is head 22 + FILTERBAR 45 + border 2 + three
   rows 60 + gap 6 + strip 22 = 157. Both carry 14px on top of the arithmetic,
   because the arithmetic alone left one row fewer than it says at each stop --
   sub-pixel rounding through four nested flex boxes, not a mistake in the sum.
   Floors that counted only the LIST left the available list one row tall at the
   splitter's top stop and the chain list spilling its frame by 10px at the
   bottom one. */
#wam-plugins-splitter { flex: 0 0 auto; margin: 2px 0; }

/* ── The picker grows with its content ───────────────────────────────
   .wam-list was a flat 180px, so the window could not use a tall screen no
   matter how many plugins were registered (Phil 2026-08-23). Same idiom as the
   Effects window sixty lines up and the stem window at the foot of this file:
   a floor and a cap on the dialog, a flex chain down to each list, and the
   LISTS scroll rather than the dialog. A dialog that becomes its own scroller
   drags its title bar and buttons off screen, which is the failure the stem
   comment records.

   The floor is conditional on the columns being visible: when the module has
   no effect path the dialog shows one line of explanation, and holding 470px
   under a single sentence would be absurd.

   The .sd-body override MUST be id-scoped. `.sd-dialog > .sd-body` is (0,2,0)
   and beats any single-class .wam-* rule, so a half-applied override reads as
   done in a diff while the body quietly stays the scroller. */
#wam-plugins-overlay > .sd-dialog { max-height: calc(88vh / var(--ui-scale)); }
#wam-plugins-overlay > .sd-dialog:has(#wam-plugins-cols:not([hidden])) {
  min-height: min(470px, calc(88vh / var(--ui-scale))); }
#wam-plugins-overlay > .sd-dialog > .sd-body {
  overflow: hidden; display: flex; flex-direction: column; }
#wam-plugins-overlay > .sd-dialog > .sd-body > .fr-help { flex: 0 0 auto; }
/* .wam-cols has no author [hidden] rule to fall back on — `display:flex` is an
   author declaration and beats the UA's `[hidden] { display:none }`. At (1,1,0)
   this needs no !important, unlike the .fx-dialog trio further up. */
#wam-plugins-cols[hidden] { display: none; }
.wam-cols { display: flex; flex-direction: column; gap: 0; align-items: stretch;
            flex: 1 1 auto; min-height: 0; }
/* The list column takes the slack; the chain column is sized by the splitter
   (its height is set above and overwritten inline by the drag). The shared
   rule carries NO min-height: it came after the floor and silently cancelled
   it, which is how the available list ended up able to shrink to nothing while
   the list inside it would not follow. */
.wam-list-col, .wam-chain-col { display: flex; flex-direction: column;
  gap: 6px; min-width: 0; }
.wam-list-col { flex: 1 1 auto; min-height: 184px; }
.wam-list-col > .fr-lbl, .wam-list-col > .fr-btns,
.wam-chain-col > .fr-lbl, .wam-chain-col > .fr-btns { flex: 0 0 auto; }
.wam-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
/* The picker's filter/sort toolbar (show/hide by category or vendor, search,
   sort). Two compact rows above the list; the search takes the slack while
   the selects and the count stay fixed. */
.wam-filterbar { display: flex; flex-direction: column; gap: 4px; flex: 0 0 auto; }
.wam-filter-row { display: flex; align-items: center; gap: 5px; }
/* The search field's baseline lives with the other input types (the
   .sd-dialog input[type="search"] rule above — dialog-controls.js requires
   every dialog control kind to have one); this rule is layout only. */
/* The library's search box. Same shape as the WAM browser's — it grows to
   take the slack in the bar so the two selects beside it keep their size, which
   is the rule the WAM filter row settled on for the same reason. */
.lib-bar .lib-search { flex: 1 1 auto; min-width: 0; padding: 2px 4px; }
.lib-bar .lib-search::placeholder { color: var(--text-dim); opacity: .7; }

/* ── Library tabs and online catalogue ────────────────────────────────────
   Online is a second source, not a second kind of local row. Keep its consent
   and provider explanation above the results, and keep the results/detail
   columns bounded so a catalogue response cannot turn the whole dialog into an
   unbounded page. The online controller writes all provider text with
   textContent; these rules are layout and emphasis only. */
/* The strips are the app's .set-tabs; inside this body they are not sticky
   and carry no side padding. */
.lib-dialog .set-tabs.lib-tabs { position:static; padding:0; margin:0 0 10px; }
.lib-dialog .set-tabs.lib-online-tabs { position:static; padding:0; margin:8px 0 0; }
.lib-online-consent { margin-bottom:8px; padding:6px 8px; background:var(--bg-inset);
  border:1px solid var(--border-control); color:var(--text-dim); font-size:10px; line-height:1.4; }
.lib-online-consent-note { margin-bottom:5px; }
.lib-online-consent label { display:flex; align-items:flex-start; gap:6px; min-width:0; color:var(--text); }
.lib-online-consent input { flex:0 0 auto; margin-top:1px; }
/* A download's progress: the bar fills when the provider says how much is
   coming; a throbber runs when it does not. */
.lib-online-progress { height:4px; margin:2px 0 6px; overflow:hidden;
  background:var(--bg-inset); border:1px solid var(--border-control); }
.lib-online-progress-bar { height:100%; width:0; background:var(--accent-ink); transition:width .15s linear; }
.lib-online-progress.indeterminate .lib-online-progress-bar { width:30%; animation:lib-throb 1s linear infinite; }
@keyframes lib-throb { from { transform:translateX(-100%); } to { transform:translateX(400%); } }
.lib-online-bar { flex-wrap:wrap; }
.lib-online-bar #lib-online-provider { flex:0 1 180px; max-width:none; min-width:120px; }
/* The online box is the same control in the same flex row, so it takes the
   same rule: min-width:auto/120px is what let an input's intrinsic width lay
   the bar out wider than the dialog (2026-09-12). */
.lib-searchbar #lib-online-search { flex:1 1 auto; min-width:0; }
.lib-online-browse { margin-top:-2px; }
.lib-online-browse .set-sel { flex:1 1 160px; min-width:120px; max-width:none; }
.lib-online-filters { margin-top:-2px; }
.lib-online-filters .set-sel { flex:1 1 130px; min-width:100px; max-width:none; }
/* The sample rows reuse the online grid, with the licence badge in the
   format column and the source tag in the size column's place. */
.lib-lic-badge { font-size:9px; padding:1px 4px; border-radius:2px;
  background:var(--bg-inset); color:var(--text-dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.lib-lic-badge.cc0, .lib-lic-badge.pdm { color:var(--accent-hi); }
.lib-online-item-play { flex:0 0 auto; width:24px; font-size:11px; }
.lib-online-item-source { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text-dim); font-size:10px; text-align:right; }
.lib-online-audio { width:100%; height:28px; margin-top:4px; }
.lib-online-detail-note { color:var(--text-dim); font-size:11px; }
.lib-online-detail-note.warn { color:var(--accent-hi); }
/* F3/F4: a Freesound sound's waveform picture in its bar, and the sign-in
   line at the end of the provider note. */
.lib-online-wave { flex:0 0 auto; height:22px; max-width:140px; object-fit:contain; opacity:.85; }
.lib-online-account { display:inline-flex; align-items:center; gap:6px; margin-left:auto; flex:0 0 auto; white-space:nowrap; }
.lib-online-account[hidden] { display:none; }
/* Folders (scene.org's listings) and what an archive holds. */
/* A folder's name runs across the title, creator and size columns (it has no
   creator or size), one line and ellipsised - see renderFolder. */
.lib-online-folder .lib-online-item-title { grid-column:2 / -1; font-weight:bold; }
.lib-online-pathbar { display:flex; align-items:center; gap:6px; padding:2px 4px; font-size:11px; color:var(--text-dim); }
/* THE PATH BAR STAYS AT THE TOP of the list while it scrolls (Phil,
   2026-09-27): "keep the top row with the level up button and folder path
   pinned". Aminet's mods/ holds 125 folders and a Modland format hundreds of
   artists, so ↑ Up used to scroll away with them. Sticky inside
   .lib-online-results (the scroller), on the list's own ground so the rows
   passing under it do not show through, and ruled off from them. */
.lib-online-results > .lib-online-pathbar { position:sticky; top:0; z-index:1;
  background:var(--bg-inset); border-bottom:1px solid var(--border-control); }
.lib-online-pathbar > span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.lib-online-detail:has(.lib-online-archive) { flex-wrap:wrap; }
.lib-online-archive { flex:1 1 100%; display:flex; flex-direction:column; gap:3px; max-height:42vh; overflow:auto; }
.lib-online-entry { display:flex; align-items:center; gap:6px; min-width:0; }
.lib-online-entry > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.lib-online-nfo { margin:2px 0 0; padding:4px 6px; font:11px/1.15 monospace; white-space:pre; overflow:auto; max-height:30vh;
  background:var(--bg-inset); color:var(--text-dim); }
/* DOS text sets solid: block and box characters were drawn to touch, and the
   1.15 leading above left a stripe between every row of the art. */
.lib-online-nfo.cp437, .lib-online-nfo.ansi { line-height:1; }
/* ANSI ART (js/ansi-text.js) is drawn on its own black whatever the theme:
   its sixteen colours were chosen against black, and on a light theme half of
   them vanish. These are the VGA text-mode values, in ANSI order. */
.lib-online-nfo.ansi { background:#000; color:#aaa; font-size:12px; }
.lib-online-nfo .ans-f0 { color:#000; }    .lib-online-nfo .ans-b0 { background:#000; }
.lib-online-nfo .ans-f1 { color:#a00; }    .lib-online-nfo .ans-b1 { background:#a00; }
.lib-online-nfo .ans-f2 { color:#0a0; }    .lib-online-nfo .ans-b2 { background:#0a0; }
.lib-online-nfo .ans-f3 { color:#a50; }    .lib-online-nfo .ans-b3 { background:#a50; }
.lib-online-nfo .ans-f4 { color:#00a; }    .lib-online-nfo .ans-b4 { background:#00a; }
.lib-online-nfo .ans-f5 { color:#a0a; }    .lib-online-nfo .ans-b5 { background:#a0a; }
.lib-online-nfo .ans-f6 { color:#0aa; }    .lib-online-nfo .ans-b6 { background:#0aa; }
.lib-online-nfo .ans-f7 { color:#aaa; }    .lib-online-nfo .ans-b7 { background:#aaa; }
.lib-online-nfo .ans-f8 { color:#555; }    .lib-online-nfo .ans-b8 { background:#555; }
.lib-online-nfo .ans-f9 { color:#f55; }    .lib-online-nfo .ans-b9 { background:#f55; }
.lib-online-nfo .ans-f10 { color:#5f5; }   .lib-online-nfo .ans-b10 { background:#5f5; }
.lib-online-nfo .ans-f11 { color:#ff5; }   .lib-online-nfo .ans-b11 { background:#ff5; }
.lib-online-nfo .ans-f12 { color:#55f; }   .lib-online-nfo .ans-b12 { background:#55f; }
.lib-online-nfo .ans-f13 { color:#f5f; }   .lib-online-nfo .ans-b13 { background:#f5f; }
.lib-online-nfo .ans-f14 { color:#5ff; }   .lib-online-nfo .ans-b14 { background:#5ff; }
.lib-online-nfo .ans-f15 { color:#fff; }   .lib-online-nfo .ans-b15 { background:#fff; }
/* An Amiga readme's styles, in the theme's own ink. */
.lib-online-nfo .ans-bold { font-weight:bold; color:var(--text); }
.lib-online-nfo .ans-rev { background:var(--text-dim); color:var(--bg-inset); }
.lib-online-nfo .ans-faint { opacity:.6; }
.lib-online-nfo .ans-it { font-style:italic; }
.lib-online-nfo .ans-ul { text-decoration:underline; }
.lib-online-detail-attribution { display:block; color:var(--text-dim); font-size:10px;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; flex:1 1 auto; }
.lib-online-provider-note { display:flex; align-items:center; gap:10px; min-height:18px;
  margin:-2px 0 6px; color:var(--text-dim); font-size:10px; line-height:1.35; }
.lib-online-provider-note span { flex:1 1 auto; min-width:0; }
.lib-online-provider-note a { flex:0 0 auto; color:var(--accent-ink); }
.lib-online-provider-note a:hover, .lib-online-provider-note a:focus-visible { color:var(--accent-hi); }
.lib-online-results { height:400px; overflow-y:auto; overflow-x:hidden;
  background:var(--bg-inset); border:1px solid var(--border-control);
  border-radius:0 var(--radius-control) var(--radius-control) var(--radius-control); }
.lib-online-empty { padding:12px; color:var(--text-dim); font-size:11px; }
.lib-online-more { display:block; margin:6px 0 0 auto; }
/* display:block above would beat the UA [hidden] rule, and the button showed
   with no results to page (seen 2026-09-05). */
.lib-online-more[hidden] { display:none; }
.lib-online-item { display:flex; align-items:stretch; gap:4px; border-bottom:1px solid var(--border-control); }
.lib-online-item:last-child { border-bottom:0; }
.lib-online-item:hover { background:var(--bg-hover); }
/* The pick, drawn as the app draws it (see .lib-row.sel above). */
.lib-online-item.sel { box-shadow:inset 2px 0 0 var(--accent-ink); }
.lib-online-item.sel .lib-online-item-title { color:var(--accent-hi); font-weight:600; }
/* One line per result: format tag, title, artist, size - the local list's
   columns - so the 400px list shows about twenty rows rather than nine. */
.lib-online-item-main { display:grid; grid-template-columns:52px minmax(0,1fr) minmax(0,150px) 52px; gap:8px; align-items:center;
  flex:1 1 auto; min-width:0;
  align-items:flex-start; gap:2px; padding:5px 8px; border:0; background:transparent;
  color:var(--text); cursor:pointer; text-align:left; font:inherit; }
.lib-online-item-main:focus-visible { outline:1px solid var(--sel-edge); outline-offset:-1px; }
.lib-online-item-creator, .lib-online-item-size { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text-dim); font-size:10px; }
.lib-online-item-size { text-align:right; font-variant-numeric:tabular-nums; }
/* max-width, because justify-self:start sizes the tag to its text rather than
   to its 52px column, so .lib-kind's own ellipsis never engaged and a long
   label ran into the title (Phil, 2026-09-28: "multitracker, screamtracker 2
   etc"). Known formats get a short tag (js/library-online-ui.js SHORT_FORMAT);
   this keeps anything else - a sample row's licence label, a word nobody has
   mapped yet - inside its column. */
.lib-online-item-format { justify-self:start; max-width:100%; }
.lib-online-detail { display:flex; align-items:center; gap:8px; }
.lib-online-detail-path { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text-dim); font-size:10px; }
.lib-online-detail .btn { flex:0 0 auto; }
.lib-online-item-title { overflow:hidden; max-width:100%; color:var(--text-hi);
  text-overflow:ellipsis; white-space:nowrap; font-size:11px; }
.lib-online-item-meta { overflow:hidden; max-width:100%; color:var(--text-dim);
  text-overflow:ellipsis; white-space:nowrap; font-size:10px; }
.lib-online-fav { flex:0 0 28px; align-self:center; margin-right:5px; padding:2px 4px;
  color:var(--text-dim); font-size:15px; line-height:1; }
.lib-online-fav.on { color:var(--accent-ink); }
.lib-online-detail { margin-top:8px; padding:8px; background:var(--bg-inset);
  border:1px solid var(--border-control); border-radius:var(--radius-control); }
/* The detail keeps its place whether or not something is picked, and the
   status line keeps one line of height, so a pick or a download does not
   move the list (Phil, 2026-09-05: "selecting a module in online tab makes
   the UI shift about, as does the download message"). */
.lib-online-detail { min-height:38px; box-sizing:border-box; }
.lib-online-detail:empty { visibility:hidden; }
#lib-online-status { min-height:16px; margin-bottom:4px; }
#lib-online-status:empty { display:block; }
.lib-online-progress { visibility:hidden; }
.lib-online-progress:not([hidden]) { visibility:visible; }
.lib-online-progress[hidden] { display:block; }
/* .lib-bar is display:flex, which beat the UA's [hidden]; the browse row and
   the More button both need the attribute to win. */
.lib-bar[hidden], .lib-online-more[hidden] { display:none; }
.lib-online-detail-title { margin:0 0 3px; color:var(--text-hi); font-size:13px; }
.lib-online-detail-meta, .lib-online-detail-note, .lib-online-rights,
.lib-online-description { color:var(--text-dim); font-size:10px; line-height:1.45; }
.lib-online-description { max-height:5.8em; overflow:hidden; margin:6px 0; }
.lib-online-rights { margin:5px 0; }
.lib-online-license { display:inline-block; margin:2px 0 7px; color:var(--accent-ink); font-size:10px; }
.lib-online-files-heading { margin:7px 0 4px; color:var(--text-dim); font-size:10px;
  letter-spacing:1px; text-transform:uppercase; }
.lib-online-file { display:flex; align-items:center; gap:8px; min-width:0; padding:4px 0;
  border-top:1px solid var(--border-control); }
.lib-online-file-name { flex:1 1 auto; min-width:0; overflow:hidden; color:var(--text-hi);
  text-overflow:ellipsis; white-space:nowrap; font-size:10px; }
.lib-online-file-size { flex:0 0 auto; color:var(--text-dim); font-size:10px; }

.wam-filter-row .wam-search {
  flex: 1 1 auto; min-width: 0; padding: 2px 4px;
}
.wam-filter-row .wam-search::placeholder { color: var(--text-dim); opacity: .7; }
/* `.sd-dialog .wam-filter-row select`, not `.wam-filter-row select`: the
   latter is (0,1,1), exactly tied with `.sd-dialog select`'s 130px cap, and
   wins only on source order. Two rules further up this file document the same
   trap being walked into. */
.sd-dialog .wam-filter-row select { flex: 0 1 auto; min-width: 0; max-width: 150px; }
/* THE THREE SELECTS ARE ONE GROUP and share one width, which is the actual
   complaint behind "awkwardly positioned" — three controls that do the same
   kind of thing should not be three different sizes. */
.wam-filter-selects { gap: 5px; }
.sd-dialog .wam-filter-selects select { flex: 1 1 0; max-width: none; }
/* PINNED WIDTH. The count grows from "3" to "43 of 43" as you type, and a
   content-sized count drags the search field's right edge with it — measured
   stepping 254 -> 232 -> 227 across three keystrokes. Tabular figures were
   already here; what was missing was a fixed slot to put them in. */
.wam-count { flex: 0 0 7ch; text-align: right; font-size: 10px; color: var(--text-dim);
              white-space: nowrap; font-variant-numeric: tabular-nums; }
.wam-filter-row .btn { flex: 0 0 auto; padding: 1px 6px; font-size: 10px; min-width: 0; }
/* Group headers when the list is grouped (sort by Category/Vendor/Kind): a
   house .inr-style row that collapses/expands its section on click — the
   "hierarchy" in the picker. */
.wam-group-hdr { display: flex; align-items: center; gap: 6px; padding: 2px 6px;
                 cursor: pointer; user-select: none; }
.wam-group-hdr:hover { background: var(--bg-hover); }
.wam-group-hdr .wam-chev { flex: 0 0 auto; font-size: 8px; color: var(--text-dim);
                           width: 10px; text-align: center; }
.wam-group-hdr .wam-gname { flex: 0 0 auto; font-size: 10px; letter-spacing: 1.5px;
                            text-transform: uppercase; color: var(--text-dim);
                            overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wam-group-hdr .wam-gcnt { flex: 0 0 auto; font-size: 9px; color: var(--text-dim);
                            opacity: .7; font-variant-numeric: tabular-nums; }
.wam-group-hdr::after { content: ''; flex: 1 1 auto; height: 1px;
                         background: var(--border-control); }
.wam-chain-col .fr-lbl { display: flex; align-items: center; gap: 6px; }
.wam-chain-col .fr-lbl select { max-width: 190px; }
/* Bypass toggle in the picker's chain column: a compact house-style button that
   lights amber when the element is bypassed (fully disconnected). */
.fx-byp { flex: none; margin-right: 4px; padding: 0 4px; font-size: 9px; line-height: 1.5;
          color: var(--text-dim); min-width: 0; }
.fx-byp.on { color: var(--accent-hi); border-color: var(--accent-dim); }
/* The Controls button (picker chain row + Effects dialog Plugins row): opens
   the plugin's own GUI in the controls window. Same compact shape as BYP. */
.fx-ctrl { flex: none; margin-left: 4px; padding: 0 4px; font-size: 9px; line-height: 1.5;
           color: var(--text-dim); min-width: 0; }
/* The plugin-controls window: hosts whatever DOM the plugin's own GUI builds
   (scoped styles ship with the plugin — see plugins/…/gui.js). Compact by
   default; the GUI sizes itself. */
.wam-gui-dialog { min-width: 320px; max-width: 560px; }
#wam-gui-host { min-height: 40px; }
/* The vendor website in the controls-window credit — a link, but quiet: it
   inherits the dim .sub colour and only signals itself with a dotted
   underline until hover, so a credit line never shouts. */
#wam-gui-credit a { color: inherit; text-decoration: underline dotted;
                    text-underline-offset: 2px; }
#wam-gui-credit a:hover, #wam-gui-credit a:focus-visible {
  color: var(--accent-hi); text-decoration: underline; }
/* The preset bar (Save/Delete + the inline name field that replaced the save
   prompt). The dialog-scoped input/select rules style the controls; these
   size the row for a window as narrow as 320px — the name field takes the
   slack and the row wraps before the buttons overflow. */
#wam-gui-preset-row { flex-wrap: wrap; }
#wam-gui-preset-row label { font-size: 10px; color: var(--text-dim); letter-spacing: .5px; }
#wam-gui-preset-row select { max-width: 150px; }
#wam-gui-preset-name { flex: 1 1 auto; min-width: 90px; }
#wam-gui-preset-row .btn { padding: 2px 8px; font-size: 10px; min-width: 0; }
/* The host-side insert row (bypass + dry/wet faders). The two faders flex
   evenly, the % readouts are fixed-width so the row doesn't jitter as the
   digits change, and the accent-color idiom matches the shipped plugin GUIs
   (the plugins/mediante-<name>/gui.js files use the same trick on their
   sliders; written with <name> rather than a glob because a star-slash ENDS
   THE COMMENT — see comment-terminators.js). */
#wam-gui-mix-row { flex-wrap: nowrap; }
#wam-gui-mix-row .fx-byp { padding: 2px 6px; font-size: 9px; margin-right: 6px; }
#wam-gui-mix-row .wam-gui-fad-lbl { font-size: 10px; color: var(--text-dim); letter-spacing: .5px; }
#wam-gui-mix-row .wam-gui-fader { flex: 1 1 auto; min-width: 40px; accent-color: var(--accent-fill); height: 16px; margin: 0; }
#wam-gui-mix-row .wam-gui-fad-val { font-size: 10px; color: var(--text-dim);
                                     font-variant-numeric: tabular-nums; width: 34px; text-align: right; }
/* Q MUST NOT MOVE as the frequency gains and loses digits — it was sliding
   about during exactly the sweeps you want to read it through. Fixed-width
   frequency column, tabular figures so the digits themselves do not jitter. */
.fx-filter { display: flex; gap: 8px; align-items: baseline; }
.fxf-type { flex: 0 0 auto; }
.fxf-hz { flex: 0 0 7.5ch; text-align: right; font-variant-numeric: tabular-nums; }
.fxf-q  { flex: 0 0 auto; font-variant-numeric: tabular-nums; }

/* Contains Tracks — the left-rail list and the per-channel group button in the
   pattern header. */
.fx-tracks { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
/* AN EMPTY HELP LINE RESERVES NOTHING. #fx-tracks-note is usually empty, and
   empty it still cost the column gap plus its own margins -- 18px that held
   the track pane clear of the column's bottom edge, so it stopped 18px short
   of where the filter curve beside it ended and the two columns visibly
   failed to line up (Phil 2026-08-24). With it out of flow the pane fills the
   column and the two bottoms agree. */
/* GENERALISED 2026-08-24, from the note the same session left: ".fr-help's
   margin is a shared rule, so other dialogs may have empty help lines
   reserving space the same way... it's worth a look if you notice similar gaps
   elsewhere." They do. Measured with the dialogs open: Song Properties carries
   THREE empty help lines -- spr-namenote, spr-transnote, spr-mixch-why -- each
   zero-height and each still reserving its 10px bottom margin. 30px of a
   dialog that has to fit on a laptop screen, spent on three lines that say
   nothing until something goes wrong.
   :empty is exactly right for this: these are filled at runtime and the rule
   stops matching the moment they are, so a note that appears still pushes the
   layout down as it should. (It matches only elements with NO child nodes at
   all -- a source-formatted `<div>
</div>` would keep its whitespace text node
   and not match. All of ours are written closed.) */
.fr-help:empty { display: none; }
.fx-trk { align-items: center; gap: 5px; }
/* The dot is IDENTITY and nothing else: always filled, always this track's
   group colour, and on the RIGHT where the other lists put their swatches.
   Filled-vs-hollow was a sixth meaning for a mark that already had one. */
#fx-tracks .fx-dot { margin-left: auto; margin-right: 0; }
/* MEMBERSHIP IS THE DOT, not a tick and not the selection bar. `.inr.sel` draws
   an inset amber bar down the left and a leading dot is how an unused
   instrument is marked, so a row wearing both read as "selected and unused".
/* AMBER NAME ON A MEMBER ROW — Phil's call, 2026-08-14: "add an amber text
   highlight to selected tracks... keep consistency with other windows". I had
   removed this on the grounds that amber text is the SELECTION idiom; his
   point is that in this box membership IS the selection — you click a row to
   toggle it — so matching the other lists is the consistency that matters.
   The gutter tick stays: it is what reads at a glance down a column of 22. */
#fx-tracks .fx-trk.in .fx-trkname { color: var(--accent-hi); }

/* MEMBERSHIP IS ALSO A TICK IN THE MARK GUTTER — the same channel as the unused
   bullet, because both are per-row booleans. It does NOT use amber text: that
   is what a picked row looks like, two columns away in the same window.
   Drawn as a ::before in the gutter .inn already reserves. */
#fx-tracks .fx-trk.in .inn::before {
  content: '✓'; color: var(--accent-ink); font-size: 10px; line-height: 0;
  position: absolute; left: 0; top: 50%; transform: translateY(-50%); }
/* Flat until you touch it: the row is a membership control first, and a box
   around every name would read as nine edit fields. */
/* The track name is TEXT now, not a field — renaming is a right-click, so the
   whole row is a click target again and the list stops looking like a form. */
#fx-tracks .fx-trkname { flex: 1 1 auto; min-width: 0; overflow: hidden;
                         text-overflow: ellipsis; white-space: nowrap; }
#fx-tracks .fx-trkname.unnamed { color: var(--text-dim); font-style: italic; }

/* The group button in the track header, in the M/S row so the header keeps its
   height. Narrow on purpose — the column is 115px and already holds M and S. */
/* gap only: the box, the padding and the centring come from .phch-btns >
   .phch-btn now, so the group button is the same object as the other two rather
   than a narrower one that happens to sit beside them. */
.phch-grp { gap: 3px; }
/* Same outline as .fx-dot and the stem window's swatches. 7px rather than the
   old 6: border-box means the ring is drawn INSIDE, and at 6px it left a 4px
   core, which is not enough colour to name. One pixel buys back a 5px core and
   the M/S row it sits in is unchanged. */
.phch-dot { width: 7px; height: 7px; border-radius: 50%; flex: none;
            border: 1px solid var(--border); }

/* NO TEXT SHADOW IN THE EFFECTS LISTS. The shared `.inr > *` rule carries one
   because the instrument list draws its rows OVER sample waveforms and needs
   the contrast. Nothing is behind these rows, so there it is just smeared text.
   Id-scoped because the shared rule is (0,2,0). */
#fx-list .inr > *, #fx-tracks .inr > * { text-shadow: none; }

/* The filter row: the readout takes the slack, the button never shrinks. It was
   losing its right-hand end because both were flexible in a row that had run
   out of width. */
.fx-filter-row { flex-wrap: nowrap; }
.fx-filter-row > label { flex: 0 0 auto; }
.fx-filter-row > .btn { flex: 0 0 auto; }
/* NO BOTTOM MARGIN. The curve is the last thing in the detail column, and the
   9px that used to sit under it held it clear of the column's bottom edge --
   so it stopped 9px short of where the track list beside it ended, and the two
   columns visibly failed to line up (Phil 2026-08-24). */
.fx-curve { display: block; width: 100%; height: 76px; margin: 2px 0 0;
            background: var(--bg-inset); border: 1px solid var(--border);
            border-radius: var(--radius-control); }

/* ── FORM FIELD CONSISTENCY ─────────────────────────────────────────────
   Text and number boxes were dark (--bg-inset) while <select> fell through to
   the browser's own grey, so two controls on the same row did not match. One
   treatment for all of them; `appearance:none` is what stops the platform
   painting its own background over ours. Applied at dialog scope so it cannot
   disturb the toolbar's own segmented controls. */
.sd-dialog select,
.sd-dialog input[type="text"],
.sd-dialog input[type="number"],
.sd-dialog textarea {
  appearance: none; -webkit-appearance: none; -moz-appearance: none;
  background: var(--bg-inset); color: var(--text);
  border: 1px solid var(--border-control); border-radius: var(--radius-control);
  /* NOT `font: inherit` — a shorthand, and it reset every dialog field from
     11px to 12px and wiped the annotation textarea line-height. Name the
     two properties actually wanted. */
  font-family: inherit; font-size: 11px; padding: 2px 5px;
}
.sd-dialog select:focus,
.sd-dialog input[type="text"]:focus,
.sd-dialog input[type="number"]:focus,
.sd-dialog textarea:focus { border-color: var(--accent-ink); outline: none; }

/* AND A NUMBER INPUT GOES BACK TO `textfield`, which is the ONLY value that
   hides Firefox's own spin buttons. The block above lists number inputs with
   select and textarea and sets `appearance:none` for all of them; that is
   right for the other two and wrong here, and being LATER in the file at the
   same (0,2,1) specificity it silently killed the
   `.sd-dialog input[type="number"] { appearance:textfield }` rule 500 lines
   above. Measured: all 16 number inputs computed `appearance:none`.

   Chrome and Safari never showed the problem, because their spinners are
   killed separately by the ::-webkit-inner/outer-spin-button rules -- so this
   read as a Firefox-only quirk. Phil, 2026-08-22: "Firefox is the only one to
   have working click & hold for spinners, and that's because it draws its own
   arrows in addition to the ones we place in the GUI." It drew them because
   we asked it to.

   `textfield` still leaves our background, border and font in force -- those
   are set explicitly above and are not part of what the appearance keyword
   restores. NEEDS A FIREFOX EYE to confirm the arrows are gone; it cannot be
   verified from here. */
.sd-dialog input[type="number"], .set-num[type="number"] {
  appearance: textfield; -moz-appearance: textfield;
}
/* appearance:none removes the drop-down arrow too, so it is drawn back as a
   background image rather than left with no affordance at all. */
.sd-dialog select {
  padding-right: 18px;
  background-image: linear-gradient(45deg, transparent 50%, var(--text-dim) 50%),
                    linear-gradient(135deg, var(--text-dim) 50%, transparent 50%);
  background-position: calc(100% - 11px) calc(50% + 1px), calc(100% - 7px) calc(50% + 1px);
  background-size: 4px 4px, 4px 4px;
  background-repeat: no-repeat;
}

/* The Effects group list's ghost row, matching .seq-row.seq-add. */
.fx-add { opacity:.55; color:var(--text-dim); cursor:pointer; }
.fx-add:hover { opacity:1; color:var(--accent-ink); background:var(--accent-wash); }
.fx-add.full, .fx-add.full:hover { opacity:.32; color:var(--text-dim);
                                   background:none; cursor:default; }

/* ── FED BY SENDS (signal routing phase 1) ───────────────────────────────
   The send matrix from the destination's side, under Contains tracks. Rows
   carry controls, so they are laid out like a form row rather than like a
   list row -- but they keep .inr so the hover and the row rhythm match the
   two lists above them. */
.fx-sends-pane { flex: 0 1 auto; min-height: 0; }
.fx-sends-head { display: flex; align-items: center; gap: 6px; }
.fx-sends-head > label { flex: 1 1 auto; min-width: 0; overflow: hidden;
                        text-overflow: ellipsis; white-space: nowrap; }
.fx-sends-head > select { flex: 0 0 auto; max-width: 50%; }
.fx-sends { flex: 1 1 auto; min-height: 44px; max-height: 30vh; overflow-y: auto; }
.fx-send { align-items: center; gap: 5px; }
.fx-send-src { flex: 1 1 auto; min-width: 0; overflow: hidden;
               text-overflow: ellipsis; white-space: nowrap; }
/* .sd-dialog's own number-input base is `flex:0 0 62px` and outranks a plain
   two-class selector, so this has to be the three-class form or the box stays
   62px wide and the source name pays for it. */
.sd-dialog .fx-send .fx-send-level { flex: 0 0 46px; padding: 2px 3px; }
/* NARROW ON PURPOSE. The column is ~220px and the row holds five things; the
   source name is the one that has to be readable, so everything else is sized
   to its content and the name takes the slack. */
.fx-send .btn { flex: 0 0 auto; padding: 1px 4px; min-width: 20px; font-size: 10px; }
/* A LIVE TOGGLE READS AS FILLED, which is the app's rule for "on" -- the same
   channel the transport buttons use. PRE and M are both booleans about this
   row, so they wear it identically. */
.fx-send .btn.on { color: var(--accent-ink); background: var(--accent-wash);
                   border-color: var(--accent-ink); }
/* SILENT IS NOT ABSENT. A muted send, or one at the -60 dB floor, is still in
   the file and still in this list -- it is just not in the engine's table. So
   it dims rather than disappearing. */
.fx-send.silent .fx-send-src, .fx-send.silent .fx-send-level { opacity: .5; }
.fx-send.empty { opacity: .55; color: var(--text-dim); }
/* WHAT THE ENGINE REFUSED, on the row it happened to. --alert is the token
   this project already uses for a refusal (js/library-ui.js's .lib-bad, the
   colour-hex box, the drop-refusal) rather than a warning colour of its own. */
.fx-send.dropped .fx-send-src { color: var(--alert-hi); }
.fx-send-drop { flex: 0 0 auto; color: var(--alert-hi); font-size: 10px; }
/* The aux badge in the group list, in the same slot as "n trk" and "n plg". */
.fx-return-badge { color: var(--accent-ink); }
/* A BUS THAT IS NOT IN THE MASTER SUM, in the same slot again. --alert-hi, not
   --accent-ink: "silent" is the one badge here that means "you will not hear
   this in the mixdown", which is the same register as a dropped send. */
.fx-out-badge { color: var(--alert-hi); }

/* ── SIGNAL ROUTING PHASE 2 ──────────────────────────────────────────────
   THE TWO NOTE SPANS WRAP rather than being clipped. `.inr` is nowrap +
   overflow:hidden, so both the engine's drop reason and the feedback note were
   simply invisible past the fifth control in a 220px column. They are the
   exceptional case, so the row only grows when one of them is there. */
.fx-send { flex-wrap: wrap; }
.fx-send-drop, .fx-send-fb-note { flex: 1 0 100%; }
/* AND THE NAME'S BASIS GOES TO ZERO, which is what wrapping costs. A nowrap
   flex line SHRINKS its items to fit; a wrapping one BREAKS THE LINE FIRST and
   only then shrinks what is on it. So with `flex: 1 1 auto` the name kept its
   content width and pushed the sixth control (−) onto a line of its own the
   moment FB appeared — measured: 64+46+26+21+20+20 plus five 5px gaps = 222 in
   a 197px row. At basis 0 the six controls are 158px and fit, and the name
   takes the slack exactly as it did before, ellipsising when there is none. */
.fx-send > .fx-send-src { flex: 1 1 0; }
/* WHEN IT ARRIVES, not whether. A feedback send is delivered one render block
   after the block that produced it — the fact that makes the loop legal, and
   the one thing somebody reading the matrix has to know. --text-dim, not
   --alert: it is a property, not a refusal. */
.fx-send-fb-note { color: var(--text-dim); font-size: 10px; }
/* OUTPUT sits under Fed by sends, so the two halves of "what comes in, where it
   goes" read as one block. flex:0 0 auto or the column's flex distribution
   squeezes it to nothing between two scrollable panes. */
.fx-track-col > .fx-out-row { flex: 0 0 auto; margin-top: 2px; }
.fx-dialog #fx-out-row[hidden] { display: none !important; }
.fx-out-row > label { flex: 0 0 auto; }
.fx-out-row > select { flex: 1 1 auto; min-width: 0; }

/* KEY ROUTES, on the plugin element they address. The line is a WRAPPED second
   row inside the chain row rather than a sibling of it: el.children is indexed
   by chain position in js/fx-dialog.js, so a sibling would shift every index
   after it — and a key belongs to its plugin, not between two plugins. */
.fx-stack .fx-row.has-keys { flex-wrap: wrap; }
/* ORDER 3, or it lands in the middle of the row. `.inr > .insize` is order 1
   and `.intype` order 2 (the shared row grammar), so a child with the default
   order 0 sorts BEFORE the vendor and the category chip and the key line ends
   up between the plugin's name and its own subtitle. It is the last line of
   the row, so it takes the last order. */
.fx-key-line { order: 3; flex: 1 0 100%; display: flex; flex-wrap: wrap; align-items: center;
               gap: 4px; margin: 2px 0 1px 0; }
.fx-key-lbl { color: var(--text-dim); font-size: 9px; letter-spacing: 1px;
              text-transform: uppercase; flex: 0 0 auto; }
.fx-key { display: flex; align-items: center; gap: 3px; flex: 0 1 auto; min-width: 0;
          border: 1px solid var(--seam-soft); border-radius: 3px; padding: 0 2px; }
.fx-key-src { color: var(--accent-ink); font-size: 10px; max-width: 84px;
              overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Same three-class trick the send level needs: .sd-dialog's number-input base
   is flex:0 0 62px and outranks a two-class rule. */
.sd-dialog .fx-key .fx-key-level { flex: 0 0 44px; padding: 1px 2px; font-size: 10px; }
.fx-key-input, .fx-key-add { font-size: 10px; padding: 0 2px; max-width: 110px; }
.fx-key .btn { flex: 0 0 auto; padding: 0 4px; min-width: 18px; font-size: 10px; }
.fx-key.dropped .fx-key-src { color: var(--alert-hi); }
.fx-key-drop { color: var(--alert-hi); font-size: 9px; }
/* A KEY THAT LEADS ITS AUDIO (routing phase 5 · U9). --accent-hi, NOT the
   --alert-hi the two rules above take: a drop is a route that was REFUSED and
   is not there, while this one is wired and audible and merely early. Same ink
   as .sd-suminc.warn, which is this app's "you should know, and you can fix
   it" register. */
.fx-key-late { color: var(--accent-hi); font-size: 9px; }

/* === COLOUR THEMES =======================================================
   A theme is a block of token overrides selected by data-theme on <html>.
   js/theme.js sets that attribute and nothing else -- the cascade does the
   rest, which is why a theme costs no JS per token and cannot flash the wrong
   palette on load (index.html stamps the attribute pre-paint).

   The default palette is the:root block at the top of this file. It is
   reachable as the "amber" theme, which sets NO attribute at all, so the
   untouched app and the amber theme are the same code path.

   THE BLOCKS BELOW ARE GENERATED. `node stuff/theme-derive.js --all`.
   Hue is rotated in OKLCH, not HSL, and then pushed up where a contrast floor
   demands it -- both for reasons measured in that file's header. Hand-editing
   a value here would survive exactly until someone re-ran the generator, and
   would silently drop the contrast guarantee in the meantime.

   WHAT A BRAND THEME DELIBERATELY DOES NOT TOUCH: the pattern-grid cell inks
   (--grid-note / --grid-instr / --grid-cmd / --grid-level1 / --grid-level2 /
   --grid-vol). Those are Windows MED's own Display slots, they are assigned to
   DATA rather than to chrome, and they must stay mutually distinguishable --
   the same argument that keeps mute and solo, and the three VU bands, apart.
   A tribute theme may override them explicitly; it is recreating a whole look,
   which is a different act from tinting this one.                          */

/* Violet -- brand ink #7C5CFF, OKLCH hue 286.2.
   GENERATED by stuff/theme-derive.js; do not hand-edit. Re-run it. */
/* --- BEGIN GENERATED THEMES ------------------------------------------
   Everything between these two markers is written by
   `node stuff/theme-derive.js --write`. It REPLACES the region, so a
   hand-authored theme must live BELOW the END marker or it will be lost
   on the next regeneration. Do not hand-edit inside. -------------- */
/* Glow -- ramp: OKLCH hue 65 (deep) -> 50.5 (bright,
   from brand ink #FF7A00). Hue follows LIGHTNESS, so the dark chrome and
   the accent are different hues of one family rather than one hue at two
   lightnesses -- which is what stops the greys reading as grey.
   GENERATED by stuff/theme-derive.js; do not hand-edit. Re-run it. */
:root[data-theme="glow"] {
  --bg: #211b15;
  --bg-mid: #2b231d;
  --bg-panel: #302821;
  --bg-inset: #1b1510;
  --scrollbar-thumb: #423830;
  --scrollbar-thumb-hover: #605248;
  --bg-header: linear-gradient(180deg, #393028 0%, #2a2017 100%);
  --border: #423830;
  --border-hi: #605248;
  --border-control: #423830;
  --accent-ink: #ed7905;
  --accent-dim: #985000;
  --ink-halo: 1px 1px 0 #000000;
  --accent-hi: #ff9b5b;
  --text: #d7baa9;
  --text-dim: #a08f84;
  --text-hi: #f9d9c7;
  --btn-face: #443a32;
  --btn-hi: #54473e;
  --btn-press: #231d17;
  --seg-off: #1a1007;
  --seg-on: #ff9b5b;
  --bg-hover: #3a312a;
  --seam: #15100b;
  --seam-soft: #28211a;
  --seam-dark: #0e0906;
  --bg-rail: #1f1913;
  --accent-wash: #2d1e11;
  --bevel-hi: #4d4239;
  --bg-hover-warm: #393028;
  --bg-rail-hover: #2d251e;
  --bg-grid-head: #1f1913;
  --seam-row: #261f19;
  --solo-bg: #6a411c;
  --solo-edge: #ce732a;
  --solo-ink: #ffcaad;
  --accent-ground: #3f2711;
  --accent-ground-soft: #2f2216;
  --accent-ground-deep: #1f1813;
  --stripe-bar-gutter: #251e18;
  --stripe-beat-gutter: #1f1913;
  --bg-bar: #1a140f;
  --bg-status: #1d1711;
  --bg-float: #221c16;
  --bg-menubar-hi: #352c25;
  --btn-hover-hi: #5f5147;
  --btn-hover-lo: #4b4037;
  --ink-off: #7e6d61;
  --accent-bright: #ffbe9a;
  --sep: #3a312a;
  --bevel-lo: #3a312a;
  --bg-menubar-lo: #28211a;
  --bg-chip-hover: #28211a;
  --accent-rgb: 237 121 5;
  --accent-hi-rgb: 255 155 91;
  --fxc-ink: #a08f84;
  --fxc-line: #5e524a;
  --fxc-hot: #ed7905;
  --fxc-ghost: #9e5409;
  --logo-ink: #FF7A00;
  --logo-glow: 0 0 22px rgba(255,122,0,.30);
}

/* Funk -- ramp: OKLCH hue 310 (deep) -> 286.2 (bright,
   from brand ink #7C5CFF). Hue follows LIGHTNESS, so the dark chrome and
   the accent are different hues of one family rather than one hue at two
   lightnesses -- which is what stops the greys reading as grey.
   GENERATED by stuff/theme-derive.js; do not hand-edit. Re-run it. */
:root[data-theme="funk"] {
  --bg: #1e1a22;
  --bg-mid: #27232c;
  --bg-panel: #2c2831;
  --bg-inset: #18141b;
  --scrollbar-thumb: #3c3843;
  --scrollbar-thumb-hover: #575262;
  --bg-header: linear-gradient(180deg, #342f3a 0%, #251f2b 100%);
  --border: #3c3843;
  --border-hi: #575262;
  --border-control: #3c3843;
  --accent-ink: #a085ff;
  --accent-dim: #7051a8;
  --ink-halo: 1px 1px 0 #000000;
  --accent-hi: #b5abff;
  --text: #bebcde;
  --text-dim: #938fa4;
  --text-hi: #dddcff;
  --btn-face: #3e3a46;
  --btn-hi: #4c4755;
  --btn-press: #201c24;
  --seg-off: #15101a;
  --seg-on: #b5abff;
  --bg-hover: #35313b;
  --seam: #131016;
  --seam-soft: #242028;
  --seam-dark: #0c090e;
  --bg-rail: #1c1820;
  --accent-wash: #261e2f;
  --bevel-hi: #46414f;
  --bg-hover-warm: #342f3a;
  --bg-rail-hover: #29252e;
  --bg-grid-head: #1c1820;
  --seam-row: #221e26;
  --solo-bg: #544072;
  --solo-edge: #9477e6;
  --solo-ink: #d3d2ff;
  --accent-ground: #332642;
  --accent-ground-soft: #282130;
  --accent-ground-deep: #1c181f;
  --stripe-bar-gutter: #211d25;
  --stripe-beat-gutter: #1c1820;
  --bg-bar: #17131a;
  --bg-status: #1a161d;
  --bg-float: #1f1b23;
  --bg-menubar-hi: #302c36;
  --btn-hover-hi: #565161;
  --btn-hover-lo: #443f4c;
  --ink-off: #726d82;
  --accent-bright: #cac8ff;
  --sep: #35313b;
  --bevel-lo: #35313b;
  --bg-menubar-lo: #242028;
  --bg-chip-hover: #242028;
  --accent-rgb: 160 133 255;
  --accent-hi-rgb: 181 171 255;
  --fxc-ink: #938fa4;
  --fxc-line: #504c5a;
  --fxc-hot: #a085ff;
  --fxc-ghost: #6d5baa;
  --logo-ink: #7C5CFF;
  --logo-glow: 0 0 22px rgba(124,92,255,.30);
}

/* Heat -- ramp: OKLCH hue 28 (deep) -> 356.8 (bright,
   from brand ink #FF3D9A). Hue follows LIGHTNESS, so the dark chrome and
   the accent are different hues of one family rather than one hue at two
   lightnesses -- which is what stops the greys reading as grey.
   GENERATED by stuff/theme-derive.js; do not hand-edit. Re-run it. */
:root[data-theme="heat"] {
  --bg: #231918;
  --bg-mid: #2d2221;
  --bg-panel: #332726;
  --bg-inset: #1c1413;
  --scrollbar-thumb: #453636;
  --scrollbar-thumb-hover: #634f50;
  --bg-header: linear-gradient(180deg, #3b2e2d 0%, #2d1e1d 100%);
  --border: #453636;
  --border-hi: #634f50;
  --border-control: #453636;
  --accent-ink: #ff5e92;
  --accent-dim: #a53c50;
  --ink-halo: 1px 1px 0 #000000;
  --accent-hi: #ff90b4;
  --text: #dcb4c0;
  --text-dim: #a48b90;
  --text-hi: #ffd2e1;
  --btn-face: #473838;
  --btn-hi: #574545;
  --btn-press: #251b1a;
  --seg-off: #1c0e0d;
  --seg-on: #ff90b4;
  --bg-hover: #3d2f2f;
  --seam: #170f0e;
  --seam-soft: #2a1f1e;
  --seam-dark: #0f0807;
  --bg-rail: #211717;
  --accent-wash: #301b1a;
  --bevel-hi: #503f3f;
  --bg-hover-warm: #3b2e2d;
  --bg-rail-hover: #2f2323;
  --bg-grid-head: #211817;
  --seam-row: #271d1c;
  --solo-bg: #73373c;
  --solo-edge: #de5b7f;
  --solo-ink: #ffc5d8;
  --accent-ground: #442221;
  --accent-ground-soft: #321f1e;
  --accent-ground-deep: #201716;
  --stripe-bar-gutter: #261c1b;
  --stripe-beat-gutter: #211717;
  --bg-bar: #1b1312;
  --bg-status: #1e1615;
  --bg-float: #241a19;
  --bg-menubar-hi: #372a2a;
  --btn-hover-hi: #624e4f;
  --btn-hover-lo: #4e3d3d;
  --ink-off: #83696c;
  --accent-bright: #ffb7d0;
  --sep: #3d2f2f;
  --bevel-lo: #3d2f2f;
  --bg-menubar-lo: #2a1f1e;
  --bg-chip-hover: #2a1f1e;
  --accent-rgb: 255 94 146;
  --accent-hi-rgb: 255 144 180;
  --fxc-ink: #a48b90;
  --fxc-line: #605052;
  --fxc-hot: #ff5e92;
  --fxc-ghost: #aa4262;
  --logo-ink: #FF3D9A;
  --logo-glow: 0 0 22px rgba(255,61,154,.30);
}

/* Nightvision -- ramp: OKLCH hue 155 (deep) -> 135.0 (bright,
   from brand ink #7CDD2E). Hue follows LIGHTNESS, so the dark chrome and
   the accent are different hues of one family rather than one hue at two
   lightnesses -- which is what stops the greys reading as grey.
   GENERATED by stuff/theme-derive.js; do not hand-edit. Re-run it. */
:root[data-theme="nightvision"] {
  --bg: #171e19;
  --bg-mid: #1f2821;
  --bg-panel: #242d26;
  --bg-inset: #111813;
  --scrollbar-thumb: #333d34;
  --scrollbar-thumb-hover: #4c594d;
  --bg-header: linear-gradient(180deg, #2b352d 0%, #1a261d 100%);
  --border: #333d34;
  --border-hi: #4c594d;
  --border-control: #333d34;
  --accent-ink: #49b825;
  --accent-dim: #28782f;
  --ink-halo: 1px 1px 0 #000000;
  --accent-hi: #67d71f;
  --text: #b3c8aa;
  --text-dim: #899786;
  --text-hi: #d2e8c7;
  --btn-face: #353f36;
  --btn-hi: #424e43;
  --btn-press: #19201b;
  --seg-off: #0a160e;
  --seg-on: #67d71f;
  --bg-hover: #2c362e;
  --seam: #0d130e;
  --seam-soft: #1c241e;
  --seam-dark: #070c08;
  --bg-rail: #151c17;
  --accent-wash: #15261a;
  --bevel-hi: #3c483d;
  --bg-hover-warm: #2b352d;
  --bg-rail-hover: #212922;
  --bg-grid-head: #151c17;
  --seam-row: #1b221c;
  --solo-bg: #295831;
  --solo-edge: #51a542;
  --solo-ink: #9ef26a;
  --accent-ground: #183420;
  --accent-ground-soft: #1a291d;
  --accent-ground-deep: #151c17;
  --stripe-bar-gutter: #1a211c;
  --stripe-beat-gutter: #151c17;
  --bg-bar: #101712;
  --bg-status: #131a15;
  --bg-float: #181f1a;
  --bg-menubar-hi: #283129;
  --btn-hover-hi: #4b584c;
  --btn-hover-lo: #3a463c;
  --ink-off: #667665;
  --accent-bright: #8aed41;
  --sep: #2c362e;
  --bevel-lo: #2c362e;
  --bg-menubar-lo: #1c241e;
  --bg-chip-hover: #1c241e;
  --accent-rgb: 73 184 37;
  --accent-hi-rgb: 103 215 31;
  --fxc-ink: #899786;
  --fxc-line: #485248;
  --fxc-hot: #49b825;
  --fxc-ghost: #32751e;
  --logo-ink: #7CDD2E;
  --logo-glow: 0 0 22px rgba(124,221,46,.30);
}

/* Chill -- ramp: OKLCH hue 268 (deep) -> 186.2 (bright,
   from brand ink #14C2B5). Hue follows LIGHTNESS, so the dark chrome and
   the accent are different hues of one family rather than one hue at two
   lightnesses -- which is what stops the greys reading as grey.
   GENERATED by stuff/theme-derive.js; do not hand-edit. Re-run it. */
:root[data-theme="chill"] {
  --bg: #181c23;
  --bg-mid: #20262d;
  --bg-panel: #242b32;
  --bg-inset: #13161c;
  --scrollbar-thumb: #323b43;
  --scrollbar-thumb-hover: #495860;
  --bg-header: linear-gradient(180deg, #2b333a 0%, #1c232d 100%);
  --border: #323b43;
  --border-hi: #495860;
  --border-control: #323b43;
  --accent-ink: #00b0be;
  --accent-dim: #007090;
  --ink-halo: 1px 1px 0 #000000;
  --accent-hi: #00d2d1;
  --text: #a8c8c6;
  --text-dim: #83979a;
  --text-hi: #c6e9e4;
  --btn-face: #343d46;
  --btn-hi: #404c54;
  --btn-press: #1a1e25;
  --seg-off: #0d121d;
  --seg-on: #00d2d1;
  --bg-hover: #2c343c;
  --seam: #0e1117;
  --seam-soft: #1d232a;
  --seam-dark: #080a0f;
  --bg-rail: #161a21;
  --accent-wash: #182231;
  --bevel-hi: #3b464e;
  --bg-hover-warm: #2b333a;
  --bg-rail-hover: #21272f;
  --bg-grid-head: #161a21;
  --seam-row: #1c2027;
  --solo-bg: #1f516e;
  --solo-edge: #00a0b4;
  --solo-ink: #48f5e5;
  --accent-ground: #1a2f44;
  --accent-ground-soft: #1b2532;
  --accent-ground-deep: #161a21;
  --stripe-bar-gutter: #1b2026;
  --stripe-beat-gutter: #161a21;
  --bg-bar: #12151b;
  --bg-status: #14181f;
  --bg-float: #191d24;
  --bg-menubar-hi: #282f37;
  --btn-hover-hi: #48565f;
  --btn-hover-lo: #39444c;
  --ink-off: #61757c;
  --accent-bright: #00eede;
  --sep: #2c343c;
  --bevel-lo: #2c343c;
  --bg-menubar-lo: #1d232a;
  --bg-chip-hover: #1d232a;
  --accent-rgb: 0 176 190;
  --accent-hi-rgb: 0 210 209;
  --fxc-ink: #83979a;
  --fxc-line: #465156;
  --fxc-hot: #00b0be;
  --fxc-ghost: #08707b;
  --logo-ink: #14C2B5;
  --logo-glow: 0 0 22px rgba(20,194,181,.30);
}
/* --- END GENERATED THEMES -------------------------------------------- */

/* --- HAND-AUTHORED THEMES --------------------------------------------
   The historical tributes. These are NOT hue rotations of the base and
   cannot be generated: each one is a specific measured palette of four to
   eight Amiga pens, mapped onto the token vocabulary by hand. Provenance,
   confidence label and contrast exemptions for every one of them live in
   `stuff/tribute-spec-2026-08-22.md`; do not change a value here without
   changing it there. ------------------------------------------------ */













/* === DENSITY: COMPACT ====================================================
   ONE BOOLEAN. `data-spacing="tight"` on <html> redefines the --d-* tokens and
   the three chrome-bar heights, and NOTHING else -- no font size, no hit
   target, no border. Density and scale are orthogonal: shrinking type is what
   scale already does, and better.

   WHY NOT A SLIDER, and why not three steps. Every value here is a small
   integer px on a UI built from 1px borders, so a continuous factor produces
   6.8px. And a "Comfortable" step above Normal would have to UNDO deliberate
   fixes -- .sd-suminc's pinned line-height, .sd-outline's 6-to-17px jump,
   .btn-glyph's height:12px that stopped the transport shifting 2px when arming
   REC -- so loosening re-opens bugs that are already fixed and documented.

   --transport-h MOVES WITH ITS PADDING, and only because of it. The bar has no
   slack of its own: its tallest child chain is .seg 26 + gap 1 + .sl 11 + 2px
   border = 40, and 40 + 12px padding is exactly the 52 it ships at. Taking 6px
   of padding is what makes 46 possible; shrinking .seg instead would drag
   calc(3ch + 21px) with it, and that 21 is three letter-spacings + two paddings
   + two borders + 1 for a measured sub-pixel flicker.

   --sec-head-h STOPS AT 21, not 20. Its natural content is .sec-caret at 13px
   line-height 1, plus 6px padding and 2px borders. At 20 the header would still
   render at 21 while a collapsed section's floor sat at 20, leaving the section
   one pixel shorter than its own title bar.

   NOT COMPACTED, deliberately: .bpr-spin buttons (the smallest interactive
   element in the app), .phch-btn, .panel-collapse (the only route back from a
   folded panel), the splitters, the scrollbars, .sec-caret, .set-hint, and
   everything already at 8px. None of those has slack to give.            */
/* COMPACT, SECOND GENERATION. Phil, 2026-08-24: "can we make 'compact' the
   default UI density, ie normal, and can we push the density further for the
   new compact option?" So what shipped as Compact is now what the app opens
   with, and this block starts from there and goes further.

   Every value below was MEASURED after the fact, not chosen and hoped for --
   the heights in particular, because most of them are floors that only bind if
   the content actually fits inside them. The first generation's own comment
   records exactly that trap: --sec-head-h was a no-op at 21 because the natural
   content measured 24, and only padding made the token binding.

   STILL NOT COMPACTED, and for the same reason as before: .bpr-spin buttons,
   .phch-btn, .panel-collapse, the splitters, the scrollbars, .sec-caret and
   .set-hint. Those are the things you click or read at their smallest already.
   Nothing here shrinks type. */
:root[data-spacing="tight"] {
  --menubar-h:         19px;
  --status-h:          17px;
  --d-transport-pad:   1px 8px;
  --d-edbar-pad:       1px 8px;
  --d-edbar-gap:       4px;
  --d-dlgtitle-pad:    2px 10px;
  --d-set-pad:         7px 11px;
  --d-set-gap:         8px;
  --d-set-rowgap:      4px;
  --d-sechead-gap:     9px;
  --d-menubar-item-pad: 0 5px;
  --d-menu-pad:        1px;
  --d-menuitem-pad:    1px 5px;
  --d-menu-gutter:     6px;
  --d-menu-keygap:     10px;
  --d-ctx-minw:        132px;
  --d-drop-minw:       150px;
  --sec-head-h:        19px;   /* natural content is 17; 18 never bound */
  --d-panelhead-pad:   0 6px;
  /* --transport-h IS ABSENT ON PURPOSE. Measured with the token released: the
     bar renders at its natural height either way, so it does not bind in this
     layout. Restating it at the base value would be a line that looks like a
     saving and is not one -- and the gate rejects a Compact token equal to its
     Normal counterpart for exactly that reason. It inherits the base 46. */
  /* Round two: the transport bar and the window edges Phil named.
     Every one of these is spacing BETWEEN things, never a control's own size. */
  --d-dlgbody-pad:   8px;
  --d-dlgfoot-pad:   6px 10px;
  --d-dlgfoot-gap:   8px;
  --d-transport-gap: 5px 6px;
  --d-tright-gap:    3px;
  --d-panes-gap:     8px;
  --d-infogrid-pad:  3px 6px;
  --d-frrow-gap:     6px;
  --d-frrow-mb:      6px;
  --d-set-dialog-h:  562px;
  --d-phbus-w:       22px;
  --d-mixstrip-w:    84px;   /* compact, and round 3's width rule above */
  --d-mixstrip-gap:  3px;
  --d-mixstrip-pad:  4px;
  /* The row heights are content heights and the content does not change
     with density -- .mixs pins font-size:10px in both -- so compact
     inherits all six rather than restating them. Measured: identical. */
}

/* THE FLIP MUST NOT ANIMATE. .sec-head's transition list includes min-height
   and padding, both density-owned, so without this the panel headers ease over
   180ms and anything measuring straight afterwards -- the pattern grid's own
   relayout included -- reads mid-animation values. Same mechanism
   body.lay-dragging already uses for splitter drags. */
body.density-flip, body.density-flip * {
  transition: none !important;
}

/* Professional -- GENERATED by stuff/tribute-derive.js from a pen set.
   OctaMED Pro 4.xx, measured: #9999AA is 29.8% of the screen, #CCCCCC 16.2%, #666677 8.5%, black 41.2%, with #FF0000 / #FF8888 / #FFAAAA / #FFCCCC for the oscilloscope, equalisers and cursor. ProTracker 2.3f measured as #888888 / #BBBBBB / #555555 / #3344FF.
   LIGHT chrome over a DARK pattern well, which is how every tracker of
   the era was laid out -- and why the base grid inks still work here. */
:root[data-theme="professional"] {
  --bg: #9999AA;
  --bg-mid: #9999AA;
  --bg-panel: #9999AA;
  --bg-bar: #9999AA;
  --bg-status: #000000;
  --bg-rail: #9999AA;
  --bg-float: #9999AA;
  --bg-menubar-hi: #000000;
  --bg-menubar-lo: #000000;
  --bg-header: linear-gradient(180deg, #CCCCCC 0%, #666677 100%);
  --header-ink: #000000;
  --bg-hover: #AAAABB;
  --bg-hover-warm: #AAAABB;
  --bg-chip-hover: #AAAABB;
  --bg-rail-hover: #AAAABB;
  --row-hover-cool: #AAAABB;
  --bg-grid-head: #9999AA;
  --bg-pattern: #000000;
  --bg-inset: #CCCCCC;
  --btn-face: #9999AA;
  --btn-hi: #CCCCCC;
  --btn-lo: #666677;
  --btn-edge-hi: #CCCCCC;
  --btn-on-bg: #000000;
  --btn-on-bg-hi: #000000;
  --btn-on-ink: #CCCCCC;
  --btn-press: #666677;
  --btn-hover-hi: #CCCCCC;
  --btn-hover-lo: #888899;
  --bevel-hi: #CCCCCC;
  --bevel-lo: #666677;
  --border: #000000;
  --border-control: #000000;
  --border-hi: #888899;
  --sep: #000000;
  --seam: #000000;
  --seam-dark: #000000;
  --seam-soft: #888899;
  --seam-row: #888899;
  --fxc-line: #888888;
  --text: #000000;
  --text-hi: #000000;
  --text-dim: #333333;
  --ink-off: #333333;
  --fxc-ink: #333333;
  --accent-ink: #0000AA;
  --accent-hi: #0000AA;
  --accent-dim: #666677;
  --accent-fill: #FF8888;
  --accent-fill-hi: #CC7788;
  --accent-bright: #FF8888;
  --fxc-hot: #0000AA;
  --fxc-ghost: #6666BB;
  --on-accent: #000000;
  --seg-off: #000000;
  --seg-on: #FF8888;
  --accent-ground: #BB99AA;
  --accent-ground-soft: #8888AA;
  --accent-ground-deep: #9999AA;
  --accent-wash: #AA99AA;
  --stripe-beat-gutter: #111111;
  --stripe-bar-gutter: #222222;
  --mute-bg: #993333;
  --mute-edge: #884455;
  --mute-ink: #FFFFFF;
  --solo-bg: #FFAAAA;
  --solo-edge: #BB8899;
  --solo-ink: #000000;
  --vu-track: #CCCCCC;
  --vu-track-edge: #666677;
  --cursor-line: #FF0000;
  --grid-row: #CCCCCC;
  --grid-row-play: #FF8888;
  --scrollbar-track: #CCCCCC;
  --scrollbar-thumb: #FF8888;
  --scrollbar-thumb-hover: #FFAAAA;
  --menubar-ink: #CCCCCC;
  --status-ink: #9999AA;
  --play-lit: #FF8888;
  --rec-lit: #FF0000;
  --rec-ink: #000000;
  --menu-hi-bg: #000000;
  --menu-hi-ink: #9999AA;
  --menu-hi-key: #9999AA;
  --sel-fill: rgb(255 136 136 / 0.26);
  --sel-edge: #FF8888;
  --sel-fill-soft: rgb(255 136 136 / 0.26);
  --sel-edge-soft: #FF8888;
  --shadow-strong: transparent;
  --shadow-mid: transparent;
  --logo-bg: #666677;
  --logo-ink: #CCCCCC;
  --logo-glow: 0 0 0 transparent;
  --accent-rgb: 0 0 170;
  --accent-hi-rgb: 255 136 136;
  /* THE SUCCESS/ALERT FAMILY, fitted to THIS theme 2026-08-23. The base
     #4cad52 and #aa3333 were picked against a dark app and no theme overrode
     them, so on a light panel they measured 1.01-1.29:1 -- the stem dialog's
     "+ Add stem" was effectively invisible. Hue is preserved and only lightness
     moves, away from the panel, so a green stays a green. Derived and measured
     by stuff/semantic-fit.js; the hover ground LIFTS on a light theme and
     darkens on a dark one, because a mid-tone tint cannot carry a readable
     green at all. */
  --green: #003c07;   /* 4.54:1 on the panel */
  --ok-ground: #b6b6c2;
  --ok-hi: #003f08;   /* 6.10:1 on --ok-ground */
  --alert: #6a000b;   /* 4.63:1 on the panel */
  --alert-hi: #500006;   /* 5.58:1 on the panel */
  /* THE VU BANDS, fitted to THIS theme's own track 2026-08-23. They were frozen
     while --vu-track is themed, so the meter washed out on every light tribute --
     and the worst case was the one that matters most: Das Nonplusultra's CLIP
     band measured 1.03:1 on its tan track, an overload indicator you could not
     see. Hue is preserved so the meter still reads green -> yellow -> red; only
     lightness moves, to a 3.0 floor (non-text graphics). stuff/semantic-fit.js. */
  --vu-floor: #2a5c2e;   /* 4.90:1 on the track */
  --vu-safe: #068418;   /* 3.03:1 */
  --vu-warn: #656500;   /* 3.83:1 */
  --vu-clip: #a70017;   /* 4.92:1 */
  --green-dim: #245629;   /* 3.07:1 on the panel */
  /* The bar is black here, so the title's dim rung has to come off --menubar-ink
     (#CCCCCC), not off the panel's --text-dim. 5.77:1 on the bar. */
  --menubar-ink-dim: #868686;
}

/* Magique -- GENERATED by stuff/tribute-derive.js from a pen set.
   MagicWB 2.0 (Martin Huttenloher, 1992-94), all eight pens measured from stuff/menu screenshots/MagicWB/DemoPic.png.
   LIGHT chrome over a DARK pattern well, which is how every tracker of
   the era was laid out -- and why the base grid inks still work here. */
:root[data-theme="magique"] {
  --bg: #959595;
  --bg-mid: #999999;
  --bg-panel: #959595;
  --bg-bar: #959595;
  --bg-status: #959595;
  --bg-rail: #959595;
  --bg-float: #959595;
  --bg-menubar-hi: #FFFFFF;
  --bg-menubar-lo: #FFFFFF;
  --bg-header: #3B67A2;
  --header-ink: #FFFFFF;
  --bg-hover: #AFAFAF;
  --bg-hover-warm: #AFAFAF;
  --bg-chip-hover: #AFAFAF;
  --bg-rail-hover: #AFAFAF;
  --row-hover-cool: #AFAFAF;
  --bg-grid-head: #959595;
  --bg-pattern: #AFAFAF;
  --bg-inset: #AFAFAF;
  --btn-face: #959595;
  --btn-hi: #AFAFAF;
  --btn-edge-hi: #FFFFFF;
  --btn-on-bg: #3B67A2;
  --btn-on-bg-hi: #556699;
  --btn-on-ink: #FFFFFF;
  --btn-press: #000000;
  --btn-hover-hi: #AFAFAF;
  --btn-hover-lo: #959595;
  --bevel-hi: #FFFFFF;
  --bevel-lo: #000000;
  --border: #000000;
  --border-control: #000000;
  --border-hi: #888888;
  --sep: #000000;
  --seam: #000000;
  --seam-dark: #000000;
  --seam-soft: #888888;
  --seam-row: #888888;
  --fxc-line: #777777;
  --text: #000000;
  --text-hi: #000000;
  --text-dim: #333333;
  --ink-off: #333333;
  --fxc-ink: #333333;
  --accent-ink: #FFA997;
  --accent-hi: #FFC4B6;
  --accent-dim: #7B7B7B;
  --accent-fill: #3B67A2;
  --accent-fill-hi: #556699;
  --accent-bright: #FFA997;
  --fxc-hot: #000000;
  --fxc-ghost: #555555;
  --on-accent: #FFFFFF;
  --seg-off: #000000;
  --seg-on: #FFA997;
  --accent-ground: #999988;
  --accent-ground-soft: #888888;
  --accent-ground-deep: #959595;
  --accent-wash: #999999;
  --stripe-beat-gutter: #AAAAAA;
  --stripe-bar-gutter: #AAAAAA;
  --mute-bg: #993333;
  --mute-edge: #885555;
  --mute-ink: #FFFFFF;
  --solo-bg: #AA907C;
  --solo-edge: #998877;
  --solo-ink: #000000;
  --vu-track: #AFAFAF;
  --vu-track-edge: #7B7B7B;
  --grid-row: #000000;
  --grid-row-play: #FFA997;
  --scrollbar-track: #AFAFAF;
  --scrollbar-thumb: #AA907C;
  --scrollbar-thumb-hover: #775544;
  --menubar-ink: #000000;
  --status-ink: #333333;
  --play-lit: #FFA997;
  --rec-lit: #FFA997;
  --rec-ink: #000000;
  --menu-hi-bg: #000000;
  --menu-hi-ink: #959595;
  --menu-hi-key: #959595;
  --play-row: rgb(170 144 124 / 0.32);
  --play-row-gutter: rgb(170 144 124 / 0.42);
  --sel-fill: rgb(59 103 162 / 0.30);
  --sel-edge: #3B67A2;
  --sel-fill-soft: rgb(59 103 162 / 0.30);
  --sel-edge-soft: #3B67A2;
  --shadow-strong: transparent;
  --shadow-mid: transparent;
  --logo-bg: #3B67A2;
  --logo-ink: #FFFFFF;
  --logo-glow: 0 0 0 transparent;
  --grid-note: #004455;
  --grid-instr: #663300;
  --grid-cmd: #550055;
  --grid-level1: #333333;
  --grid-level2: #444444;
  --grid-vol: #004400;
  --accent-rgb: 0 0 0;
  --accent-hi-rgb: 255 169 151;
  /* THE SUCCESS/ALERT FAMILY, fitted to THIS theme 2026-08-23. The base
     #4cad52 and #aa3333 were picked against a dark app and no theme overrode
     them, so on a light panel they measured 1.01-1.29:1 -- the stem dialog's
     "+ Add stem" was effectively invisible. Hue is preserved and only lightness
     moves, away from the panel, so a green stays a green. Derived and measured
     by stuff/semantic-fit.js; the hover ground LIFTS on a light theme and
     darkens on a dark one, because a mid-tone tint cannot carry a readable
     green at all. */
  --green: #003606;   /* 4.58:1 */
  --ok-ground: #b3b3b3;
  --ok-hi: #003c07;   /* 6.07:1 on --ok-ground */
  --alert: #610009;   /* 4.64:1 */
  --alert-hi: #430004;   /* 5.65:1 */
  /* MagicWB's menus are white too. Nothing else needs restating here: this
     theme's --text is already black and --text-dim #333333, which read on
     white at 21:1 and 12.6:1, and its tick is black. */
  --bg-menu: #FFFFFF;
  /* THE VU BANDS, fitted to THIS theme's own track 2026-08-23. They were frozen
     while --vu-track is themed, so the meter washed out on every light tribute --
     and the worst case was the one that matters most: Das Nonplusultra's CLIP
     band measured 1.03:1 on its tan track, an overload indicator you could not
     see. Hue is preserved so the meter still reads green -> yellow -> red; only
     lightness moves, to a 3.0 floor (non-text graphics). stuff/semantic-fit.js. */
  --vu-floor: #2a5c2e;   /* 3.59:1 on the track */
  --vu-safe: #006a0e;   /* 3.12:1 */
  --vu-warn: #4e4e00;   /* 3.97:1 */
  --vu-clip: #80000f;   /* 4.97:1 */
  --green-dim: #1e5123;   /* 3.11:1 on the panel */
  /* THE ACCENT IS SALMON NOW, NOT BLACK. Phil, 2026-08-23: 'black is used for
     the accent ink. It would be better for things like highlighted tabs and
     selected items if this was the same pink colour as the bright ink.'
     The bright #FFA997 cannot BE the ink -- measured 1.62:1 on the panel and
     1.19:1 on an input field, which is the readability problem he flagged in
     the same message. So the hue stays and the LIGHTNESS moves: #581105 is the
     same salmon deepened until it reads, keeping 99% of its chroma, at 4.68:1
     on the panel and 6.39:1 on a field. The bright stays the bright, and
     --accent-rgb carries it so every accent WASH (the playing row, selection
     tints) is pink rather than grey. Ink deep, fill bright -- which is the
     split --accent-ink/--accent-fill already exists to express. */
  --accent-rgb: 255 169 151;
}

/* Das Nonplusultra -- GENERATED by stuff/tribute-derive.js from a pen set.
   The Ultimate Soundtracker v1.21 (Karsten Obarski, 1987) with ScreamTracker 3 cream. Measured by png-palette.py from stuff/menu screenshots/screamtracker and ultimate soundtracker/.
   LIGHT chrome over a DARK pattern well, which is how every tracker of
   the era was laid out -- and why the base grid inks still work here. */
:root[data-theme="nonplusultra"] {
  --bg: #997755;
  --bg-mid: #AA8866;
  --bg-panel: #AA8866;
  --bg-bar: #997755;
  --bg-status: #997755;
  --bg-rail: #997755;
  --bg-float: #AA8866;
  --bg-menubar-hi: #BB9977;
  --bg-menubar-lo: #AA8866;
  --bg-header: #553311;
  --header-ink: #FFDD88;
  --bg-hover: #BB9977;
  --bg-hover-warm: #BB9977;
  --bg-chip-hover: #BB9977;
  --bg-rail-hover: #BB9977;
  --row-hover-cool: #BB9977;
  --bg-grid-head: #997755;
  --bg-pattern: #000000;
  --bg-inset: #997755;
  --btn-face: #AA8866;
  --btn-hi: #BB9977;
  --btn-edge-hi: #DDBB99;
  --btn-on-bg: #553311;
  --btn-on-bg-hi: #553311;
  --btn-on-ink: #FFDD88;
  --btn-press: #553311;
  --btn-hover-hi: #BB9977;
  --btn-hover-lo: #AA8866;
  --bevel-hi: #DDBB99;
  --bevel-lo: #553311;
  --border: #442200;
  --border-control: #442200;
  --border-hi: #775533;
  --sep: #442200;
  --seam: #442200;
  --seam-dark: #442200;
  --seam-soft: #775533;
  --seam-row: #775533;
  --fxc-line: #554433;
  --text: #000000;
  --text-hi: #000000;
  --text-dim: #332222;
  --ink-off: #332222;
  --fxc-ink: #111111;
  --accent-ink: #FFDD88;
  --accent-hi: #FFE9AF;
  --accent-dim: #553311;
  --cursor-line: #FFDD88;
  --accent-fill: #553311;
  --accent-fill-hi: #553311;
  --accent-bright: #FFDD88;
  --fxc-hot: #000000;
  --fxc-ghost: #FFDD88;
  --on-accent: #FFDD88;
  --seg-off: #000000;
  --seg-on: #FFDD88;
  --accent-ground: #BB9977;
  --accent-ground-soft: #997755;
  --accent-ground-deep: #AA8866;
  --accent-wash: #BB9966;
  --stripe-beat-gutter: #111111;
  --stripe-bar-gutter: #222211;
  --mute-bg: #993333;
  --mute-edge: #773322;
  --mute-ink: #FFFFFF;
  --solo-bg: #FFDD88;
  --solo-edge: #AA8855;
  --solo-ink: #000000;
  --vu-track: #997755;
  --vu-track-edge: #553311;
  --grid-row: #AA8866;
  --grid-row-play: #FFDD88;
  --scrollbar-track: #997755;
  --scrollbar-thumb: #553311;
  --scrollbar-thumb-hover: #332211;
  --menubar-ink: #000000;
  --status-ink: #332222;
  --play-lit: #FFDD88;
  --rec-lit: #553311;
  --rec-ink: #FFDD88;
  --menu-hi-bg: #000000;
  --menu-hi-ink: #AA8866;
  --menu-hi-key: #AA8866;
  --sel-fill: rgb(255 221 136 / 0.26);
  --sel-edge: #FFDD88;
  --sel-fill-soft: rgb(255 221 136 / 0.26);
  --sel-edge-soft: #FFDD88;
  --shadow-strong: transparent;
  --shadow-mid: transparent;
  --logo-bg: #553311;
  --logo-ink: #FFDD88;
  --logo-glow: 0 0 0 transparent;
  --accent-rgb: 0 0 0;
  --accent-hi-rgb: 255 221 136;
  /* THE SUCCESS/ALERT FAMILY, fitted to THIS theme 2026-08-23. The base
     #4cad52 and #aa3333 were picked against a dark app and no theme overrode
     them, so on a light panel they measured 1.01-1.29:1 -- the stem dialog's
     "+ Add stem" was effectively invisible. Hue is preserved and only lightness
     moves, away from the panel, so a green stays a green. Derived and measured
     by stuff/semantic-fit.js; the hover ground LIFTS on a light theme and
     darkens on a dark one, because a mid-tone tint cannot carry a readable
     green at all. */
  --green: #003004;   /* 4.51:1 */
  --ok-ground: #c2a991;
  --ok-hi: #003606;   /* 6.13:1 on --ok-ground */
  --alert: #540007;   /* 4.65:1 */
  --alert-hi: #370002;   /* 5.52:1 */
  /* THE VU BANDS, fitted to THIS theme's own track 2026-08-23. They were frozen
     while --vu-track is themed, so the meter washed out on every light tribute --
     and the worst case was the one that matters most: Das Nonplusultra's CLIP
     band measured 1.03:1 on its tan track, an overload indicator you could not
     see. Hue is preserved so the meter still reads green -> yellow -> red; only
     lightness moves, to a 3.0 floor (non-text graphics). stuff/semantic-fit.js. */
  --vu-floor: #073e10;   /* 3.00:1 on the track */
  --vu-safe: #8df58c;   /* 3.04:1 */
  --vu-warn: #fefe65;   /* 3.82:1 */
  --vu-clip: #460004;   /* 4.06:1 */
  --green-dim: #184b1e;   /* 3.11:1 on the panel */
  /* Same treatment, but this theme's grounds allow less of it. Its bright
     #FFDD88 measures 2.48:1 on the panel and 3.11:1 on a field, and deepening
     a YELLOW until it reads costs most of what makes it yellow -- at the 4.5
     needed on the field only 35% of the chroma survives (#1b1200, a warm
     near-black). So the ink takes that warm near-black rather than pure black,
     and the AMBER does its work where it can be seen: --accent-rgb carries the
     bright, so every accent wash and highlight tint is amber. */
  --accent-rgb: 255 221 136;
}

/* Blorange -- Workbench 1.3, corrected 2026-08-22 from Phil's own memory of
   the machine: "Workbench 1.3 didn't have orange title bars - they were
   originally white with blue text, and dithered stripes filling the rest of
   the space between the window title & gadgets. They only showed orange on
   machines with 2.0+ ROMs. Orange was originally only used for icons and
   certain UI elements, like the disk empty/full gauge. Highlighted menu items
   became orange on black. The scroll bars were white on blue, the window
   gadgets were black white & blue, and the text was mostly white on blue" --
   and, separately, "the menu bar at the top was blue text on white".

   So the four OCS pens do the following jobs, and orange is the RAREST of them
   rather than the chrome: blue #0055AA is the ground, white #FFFFFF is the ink
   and the title bar, black #000000 is every edge, and orange #FF8800 is the
   accent plus the one menu highlight. The dither that filled a 1.3 title bar
   between the title and the gadgets is a subtle white-to-pale-blue gradient
   here, which was Phil's suggestion for it.

   NOT to be confused with Magique, which is MagicWB under 2.0+ and keeps its
   own blue title bars -- Phil, 2026-08-22: "Don't mix up my Workbench 1.3
   observations with Magique/MagicWB. This is purely for Blorange." */
:root[data-theme="blorange"] {
  --bg: #0055AA;
  --bg-mid: #0055AA;
  --bg-panel: #0055AA;
  --bg-bar: #0055AA;
  --bg-status: #0055AA;
  --bg-rail: #0055AA;
  --bg-float: #0055AA;
  --bg-menubar-hi: #FFFFFF;
  --bg-menubar-lo: #FFFFFF;
  --bg-header: linear-gradient(180deg, #FFFFFF 0%, #CCDDEE 100%);
  --header-ink: #0055AA;
  --bg-hover: #3377CC;
  --bg-hover-warm: #3377CC;
  --bg-chip-hover: #3377CC;
  --bg-rail-hover: #3377CC;
  --row-hover-cool: #3377CC;
  --bg-grid-head: #0055AA;
  --bg-pattern: #000022;
  --bg-inset: #003377;
  --btn-face: #0055AA;
  --btn-hi: #1166BB;
  --btn-edge-hi: #FFFFFF;
  --btn-on-bg: #FFFFFF;
  --btn-on-bg-hi: #FFFFFF;
  --btn-on-ink: #0055AA;
  --btn-press: #000022;
  --btn-hover-hi: #3377CC;
  --btn-hover-lo: #0055AA;
  --bevel-hi: #FFFFFF;
  --bevel-lo: #000000;
  --border: #000000;
  --border-control: #000000;
  --border-hi: #003366;
  --sep: #000000;
  --seam: #000000;
  --seam-dark: #000000;
  --seam-soft: #003366;
  --seam-row: #003366;
  --fxc-line: #4466AA;
  --text: #FFFFFF;
  --text-hi: #FFFFFF;
  --text-dim: #BBCCEE;
  --ink-off: #AACCDD;
  --fxc-ink: #BBCCEE;
  --accent-ink: #FF8800;
  --accent-hi: #FF8800;
  --accent-dim: #000022;
  --cursor-line: #FF8800;
  --accent-fill: #FF8800;
  --accent-fill-hi: #AA5511;
  --accent-bright: #FF8800;
  --fxc-hot: #FFCC66;
  --fxc-ghost: #FF8800;
  --on-accent: #000022;
  --seg-off: #000022;
  --seg-on: #FF8800;
  --accent-ground: #446688;
  --accent-ground-soft: #2266AA;
  --accent-ground-deep: #0055AA;
  --accent-wash: #225599;
  --stripe-beat-gutter: #001133;
  --stripe-bar-gutter: #001144;
  --mute-bg: #993333;
  --mute-edge: #552233;
  --mute-ink: #FFFFFF;
  --solo-bg: #FF8800;
  --solo-edge: #884411;
  --solo-ink: #000000;
  --vu-track: #003377;
  --vu-track-edge: #000022;
  --grid-row: #FFCC66;
  --grid-row-play: #FF8800;
  --scrollbar-track: #0055AA;
  --scrollbar-thumb: #FFFFFF;
  --scrollbar-thumb-hover: #CCDDEE;
  --menubar-ink: #0055AA;
  --status-ink: #BBCCEE;
  --play-lit: #FF8800;
  --rec-lit: #FF8800;
  --rec-ink: #000000;
  --menu-hi-bg: #000000;
  --menu-hi-ink: #FF8800;
  --menu-hi-key: #FF8800;
  --sel-fill: rgb(255 136 0 / 0.30);
  --sel-edge: #FF8800;
  --sel-fill-soft: rgb(255 136 0 / 0.30);
  --sel-edge-soft: #FF8800;
  --shadow-strong: transparent;
  --shadow-mid: transparent;
  --logo-bg: #000000;
  --logo-ink: #FF8800;
  --logo-glow: 0 0 0 transparent;
  --accent-rgb: 255 204 102;
  --accent-hi-rgb: 255 136 0;
  /* THE SUCCESS/ALERT FAMILY, fitted to THIS theme 2026-08-23. The base
     #4cad52 and #aa3333 were picked against a dark app and no theme overrode
     them, so on a light panel they measured 1.01-1.29:1 -- the stem dialog's
     "+ Add stem" was effectively invisible. Hue is preserved and only lightness
     moves, away from the panel, so a green stays a green. Derived and measured
     by stuff/semantic-fit.js; the hover ground LIFTS on a light theme and
     darkens on a dark one, because a mid-tone tint cannot carry a readable
     green at all. */
  --green: #80e184;   /* 4.52:1 */
  --ok-ground: #003d7a;
  --ok-hi: #77d77a;   /* 6.05:1 on --ok-ground */
  --alert: #ffbeb8;   /* 4.62:1 */
  --alert-hi: #ffdbd7;   /* 5.68:1 */
  /* The selection bar's count follows --accent-ink everywhere else, but here
     that is Workbench orange on a blue wash: 3.10:1, which clears the 3.0 UI
     floor and misses AA for text this small. White on blue is both readable
     and the more faithful answer -- 1.3's text was mostly white on blue. */
  --selbar-ink: #FFFFFF;
  /* A WORKBENCH MENU IS WHITE, with blue text -- the same pairing as this
     theme's menu bar. The inks have to be restated because --text here is
     WHITE (correct on the blue panels, invisible on a white menu). Measured on
     #FFFFFF: label 7.29:1, shortcut key 4.57:1, disabled key 3.02:1. The
     highlight row is unchanged and still does the 1.3 thing -- orange on
     black, 8.77:1. */
  --bg-menu: #FFFFFF;
  --menu-ink: #0055AA;
  --menu-ink-dim: #2E76CE;
  --menu-ink-off: #4E96F0;
  --menu-tick: #0055AA;
  /* THE VU BANDS, fitted to THIS theme's own track 2026-08-23. They were frozen
     while --vu-track is themed, so the meter washed out on every light tribute --
     and the worst case was the one that matters most: Das Nonplusultra's CLIP
     band measured 1.03:1 on its tan track, an overload indicator you could not
     see. Hue is preserved so the meter still reads green -> yellow -> red; only
     lightness moves, to a 3.0 floor (non-text graphics). stuff/semantic-fit.js. */
  --vu-floor: #588b5a;   /* 3.02:1 on the track */
  --vu-safe: #6cd46c;   /* 6.48:1 */
  --vu-warn: #e0e040;   /* 8.57:1 */
  --vu-clip: #ff7d75;   /* 4.85:1 */
  --green-dim: #7fb381;   /* 3.01:1 on the panel */
  /* The bar is white here; dimmed from --menubar-ink (#0055AA). 4.97:1. */
  --menubar-ink-dim: #2770C7;
}


/* The Custom theme has no values HERE by design: they are the user's, stored
   in ui.customTheme as ten pens and expanded through the shared role map onto
   <html> as inline properties (js/theme.js + stuff/tribute-derive.js). This
   stub exists so the registry gate can hold the three theme surfaces together
   for 'custom' like any other slug. */
:root[data-theme="custom"] {}

/* --- BEGIN GENERATED GRID INKS -- node stuff/grid-inks.js --write ---------
   THE SIX PATTERN SLOTS, GRADED PER THEME. Phil, 2026-08-22: "Pattern ink
   accents, these should change per theme I think... Maybe it could be the
   same colours but graded slightly for each theme." So the multi-colour
   approach STAYS -- these are MED SoundStudio's own Display slots, and a
   tracker is unreadable if Note, Instr.Number and Command stop being tellable
   apart -- and each ink is pulled AT MOST 22 DEGREES toward the theme's own
   hue, then moved in lightness until it clears a contrast floor against THAT
   THEME'S WELL.

   The floor is what answers "the pattern text is too dim in this mode and Das
   Nonplusultra, especially the row numbers". The cap is what keeps "slightly"
   honest: 30% of the 194-degree gap to Professional's blue is a 58-degree
   swing, and it turned the instrument column pink before the cap went in.
   The same lightness rule handles a LIGHT well with no special case --
   Magique's #AFAFAF sends every ink downward instead of up.

   Placed after every theme block so it wins on source order.
   DO NOT HAND-EDIT: regenerate. ------------------------------------------ */
:root[data-theme="glow"] {
  --grid-note: #73cdb3;
  --grid-instr: #cd9d63;
  --grid-cmd: #cf7ba6;
  --grid-vol: #9cb26a;
  --grid-level1: #ba9f81;
  --grid-level2: #917c63;
  --grid-row: #9b9084;
  --stripe-beat: #211b15;
  --stripe-bar: #29221b;
  --play-row: rgb(255 255 255 / 0.15);
  --play-row-gutter: rgb(255 255 255 / 0.21);
  --cursor-line: #9d5409;
}

:root[data-theme="funk"] {
  --grid-note: #6bc8e1;
  --grid-instr: #d5986c;
  --grid-cmd: #b683cb;
  --grid-vol: #64bb9a;
  --grid-level1: #bf9c86;
  --grid-level2: #957966;
  --grid-row: #9d8f86;
  --stripe-beat: #1e1a21;
  --stripe-bar: #252129;
  --play-row: rgb(255 255 255 / 0.15);
  --play-row-gutter: rgb(255 255 255 / 0.21);
  --cursor-line: #7455ad;
}

:root[data-theme="heat"] {
  --grid-note: #6bc8e1;
  --grid-instr: #d5986c;
  --grid-cmd: #c87db3;
  --grid-vol: #9cb26a;
  --grid-level1: #bf9c86;
  --grid-level2: #957966;
  --grid-row: #9d8f86;
  --stripe-beat: #231a19;
  --stripe-bar: #2a201f;
  --play-row: rgb(255 255 255 / 0.15);
  --play-row-gutter: rgb(255 255 255 / 0.21);
  --cursor-line: #aa4154;
}

:root[data-theme="nightvision"] {
  --grid-note: #6ecdb9;
  --grid-instr: #b8a85f;
  --grid-cmd: #cf7ba6;
  --grid-vol: #82b87e;
  --grid-level1: #afa47f;
  --grid-level2: #878062;
  --grid-row: #969384;
  --stripe-beat: #171e19;
  --stripe-bar: #1c251e;
  --play-row: rgb(255 255 255 / 0.15);
  --play-row-gutter: rgb(255 255 255 / 0.21);
  --cursor-line: #28782f;
}

:root[data-theme="chill"] {
  --grid-note: #66cccf;
  --grid-instr: #b5a960;
  --grid-cmd: #ab87d4;
  --grid-vol: #69bb95;
  --grid-level1: #ada57f;
  --grid-level2: #858162;
  --grid-row: #959384;
  --stripe-beat: #181c23;
  --stripe-bar: #1d2329;
  --play-row: rgb(255 255 255 / 0.15);
  --play-row-gutter: rgb(255 255 255 / 0.21);
  --cursor-line: #007090;
}

:root[data-theme="professional"] {
  --grid-note: #6ac8e0;
  --grid-instr: #d5986c;
  --grid-cmd: #ae86d2;
  --grid-vol: #64bb9a;
  --grid-level1: #bf9c86;
  --grid-level2: #957966;
  --grid-row: #9d8f86;
  --stripe-beat: #0c0c0e;
  --stripe-bar: #151518;
  --play-row: rgb(255 255 255 / 0.19);
  --play-row-gutter: rgb(255 255 255 / 0.25);
  --cursor-line: #ff0000;
}

:root[data-theme="magique"] {
  --grid-note: #004753;
  --grid-instr: #494000;
  --grid-cmd: #533074;
  --grid-vol: #004c36;
  --grid-level1: #5f412d;
  --grid-level2: #5e5a3d;
  --grid-row: #444235;
  --stripe-beat: #a9a9a9;
  --stripe-bar: #a3a3a3;
  --play-row: rgb(255 255 255 / 0.52);
  --play-row-gutter: rgb(255 255 255 / 0.58);
  --cursor-line: #5d5d5d;
}

:root[data-theme="nonplusultra"] {
  --grid-note: #73cdb3;
  --grid-instr: #cb9e62;
  --grid-cmd: #cf7ba6;
  --grid-vol: #9cb26a;
  --grid-level1: #ba9f81;
  --grid-level2: #907c63;
  --grid-row: #9b9184;
  --stripe-beat: #0e0b08;
  --stripe-bar: #1b1610;
  --play-row: rgb(255 255 255 / 0.19);
  --play-row-gutter: rgb(255 255 255 / 0.25);
  --cursor-line: #ffdd88;
}

:root[data-theme="blorange"] {
  --grid-note: #73cdb3;
  --grid-instr: #cd9d62;
  --grid-cmd: #cf7ba6;
  --grid-vol: #9cb26a;
  --grid-level1: #ba9f81;
  --grid-level2: #907c63;
  --grid-row: #9b9084;
  --stripe-beat: #000a32;
  --stripe-bar: #001340;
  --play-row: rgb(255 255 255 / 0.18);
  --play-row-gutter: rgb(255 255 255 / 0.24);
  --cursor-line: #ff8800;
}
/* --- END GENERATED GRID INKS --------------------------------------------- */


/* ── Sample editor: pinned commands, the loop row, the format dropdowns,
   and the context menu's pin glyph (2026-09-05) ─────────────────────── */
/* The preview button keeps one width whether it says Preview or Stop, so
   the strip beside it does not shift (Phil, 2026-09-05). */
#sop-pv-play { min-width:6.8em; text-align:center; }
.ctx-item .ctx-lbl { flex:1 1 auto; }
.ctx-pin { padding:0 3px; opacity:0.35; cursor:pointer; font-size:11px; line-height:0; }
.ctx-item:hover .ctx-pin { opacity:0.8; }
.ctx-pin:hover { opacity:1; }
.ctx-pin.on { opacity:0.95; color:var(--accent-hi); }
/* The drawn pin (js/editor.js pinIcon), styled as .pinbtn svg is: outline
   when not pinned, a solid head when pinned. */
.ctx-pin svg { display:block; width:11px; height:11px;
  fill:none; stroke:currentColor; stroke-width:1.3;
  stroke-linejoin:round; stroke-linecap:round; }
.ctx-pin.on svg .pin-head { fill:currentColor; }
.smp-looprow input[type="number"] { width:72px; }
.smp-looprow .fr-check { margin:0 2px; }
.smp-fieldlbl { color:var(--text-dim); font-size:10px; }
.smp-pinned .smp-lbl + .btn { margin-left:1px; }
/* ── PINNED-COMMAND GROUPS (2026-09-06) ─────────────────────────────────
   Phil: "we should avoid reflowing a group across rows." A group is ONE
   inline-flex box, so the bar wraps BETWEEN groups and never through the
   middle of one - a label stranded at the end of a line with its buttons on
   the next names nothing. Its own flex context does not wrap, which is the
   whole of the mechanism. */
.smp-pingroup { display:inline-flex; align-items:center; gap:4px; flex:0 0 auto; }
/* flex:0 0 auto is the other half of "never reflow a group": inline-flex keeps
   the group from wrapping INTERNALLY, and refusing to shrink keeps a group that
   is wider than the space left from being squeezed until its buttons are. It
   moves to the next line whole instead, which is the whole ask. */
/* The label is also the drag handle - Phil: "button groups could be dragged
   and rearranged". The cursor is set in js/sample-body.js armGroupDrag so it
   can become grabbing mid-drag; the padding here is what makes it big enough
   to grab at all. */
.smp-pingrip { padding:1px 3px; border-radius:var(--radius-control); }
.smp-pingrip:hover { color:var(--text-hi); background:var(--bg-inset); }
.smp-pingroup.pin-dragging { opacity:0.45; }
/* THE DROP INDICATOR IS A SHADOW, NOT A BORDER: a border would widen the box
   and shove every group after it sideways while the pointer is still deciding. */
.smp-pingroup.pin-drop-l { box-shadow:-3px 0 0 -1px var(--accent-hi); }
.smp-pingroup.pin-drop-r { box-shadow: 3px 0 0 -1px var(--accent-hi); }
/* THE COMMANDS CHIP, at the head of the bar (Phil, 2026-09-06: "put the
   Commands control at the start of the pinned bar, as a small chip"). Smaller
   than the command buttons because it is not one: it opens the menu they come
   from. */
.smp-chip { font-size:10px; padding:1px 6px; letter-spacing:0.3px; color:var(--accent-ink); border-color:var(--accent-dim); }
.smp-chip:hover:not(:disabled) { color:var(--accent-hi); }
.ipr-fmt-row { display:flex; gap:4px; align-items:center; }
.ipr-fmt-row select { font-size:10px; }

/* The parent-sample line under the instrument name (2026-09-06). */
.ipr-sample-info { font-size:10px; color:var(--text-dim); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1 1 auto; min-width:0; }
.ipr-sample-row { display:flex; align-items:center; gap:6px; margin-top:2px; }
.ipr-sample-row input { flex:0 1 14em; min-width:6em; }
.ipr-sample-row .ipr-sample-lib { flex:0 0 auto; }
/* Inline rename in a library row: the name span becomes a box. */
.lib-rename { font:inherit; width:100%; min-width:0; background:var(--bg-inset); color:var(--text-hi); border:1px solid var(--accent-dim); border-radius:var(--radius-control); padding:0 3px; }

/* ── CLOUD (package B): the Jobs window and the Online dialog ───────────────
   ONE CONTIGUOUS BLOCK, prefixes .cj- (jobs) and .cl- (the online dialog), so
   a merge of the four restoration branches is a single hunk.

   NO CONTROL NAMES ITS OWN BORDER COLOUR OR RADIUS here: both come from
   --border-control / --radius-control, which stuff/regression/
   control-uniformity.js enforces. Everything else is an existing token. */
.sd-dialog.cj-dialog { width:min(620px, calc(94vw / var(--ui-scale))); }
/* THE MODEL NAMES WERE CUT OFF AT 130px, AND THE DIALOG WIDTH WAS NEVER THE
   REASON. `.sd-dialog select` caps every select at max-width:130px and is
   (0,1,1) — the FOURTH time this trap has been walked into (see .tr-dialog,
   .tempo-lpb and the stem dialog above). The cloud dialog's rows are 660px
   wide and every dropdown in them sat at 130px, so "BS Roformer SW (vocals,
   bass, drums, guitar, piano, other)" — 56 characters, and the greyed rows add
   "— needs a sample" to that — showed about eleven of them. Phil, 2026-09-07:
   "The MVSep drop down menus could be a lot wider, the text gets truncated."
   The `.sd-dialog.` prefix makes these (0,2,1) and settles it on specificity
   rather than on where the rule sits in the file.
   The extra width and the narrower label column are worth having as well: a
   long model name needs the room even uncapped. */
.sd-dialog.cl-dialog { width:min(820px, calc(94vw / var(--ui-scale))); }
.sd-dialog.cl-dialog .cl-row > select,
.sd-dialog.cl-dialog .cl-pickrow > select { max-width:none; flex:1 1 auto; min-width:0; }
.sd-dialog.cl-dialog .cl-pickrow > input, .sd-dialog.cl-dialog .cl-pickrow > textarea { max-width:none; }
.cl-dialog .cl-row > label { flex-basis:120px; width:120px; }
.cl-dialog .cl-fieldhint { padding-left:132px; }

.cj-list { display:flex; flex-direction:column; gap:8px; }
.cj-job { border:1px solid var(--border); border-radius:var(--radius-control); padding:8px; display:flex; flex-direction:column; gap:5px; }
.cj-head { display:flex; align-items:baseline; gap:8px; }
.cj-name { font-family:var(--font-title); font-weight:600; font-size:11px; letter-spacing:1px; text-transform:uppercase; color:var(--accent-hi); flex:1 1 auto; }
.cj-model { font-size:9px; color:var(--text-dim); letter-spacing:.5px; }
.cj-state { font-size:9px; letter-spacing:1px; text-transform:uppercase; color:var(--text-dim); }
.cj-job.cj-done .cj-state { color:var(--accent-hi); }
.cj-job.cj-failed .cj-state { color:var(--alert); }
/* THE BAR HAS TWO MODES, and they are a shape difference rather than only a
   colour one: a determinate bar fills from the left, an indeterminate one is a
   dim band with no fill at all. A bar sitting at zero because nothing is known
   reads as "stuck", which is the opposite of the truth. */
.cj-bar { height:5px; background:var(--bg-panel); border:1px solid var(--border-control); border-radius:var(--radius-control); overflow:hidden; }
.cj-fill { height:100%; width:0; background:var(--accent-ink); }
.cj-bar.cj-indet { border-style:dashed; }
.cj-bar.cj-indet .cj-fill { width:0; }
.cj-meta { font-size:9px; color:var(--text-dim); letter-spacing:.5px; display:flex; gap:10px; flex-wrap:wrap; }
.cj-text { font-size:10px; color:var(--text); letter-spacing:.5px; }
.cj-btns { display:flex; gap:6px; flex-wrap:wrap; }
.cj-btns .btn { font-size:10px; padding:3px 8px; letter-spacing:.5px; }
/* The log tail: monospace, scrolled, and short. It is a diagnostic, not a
   console, and a job that printed a megabyte must not make this window tall. */
.cj-log { font-family:var(--font-text); font-size:9px; line-height:1.35; color:var(--text-dim);
  background:var(--bg-panel); border:1px solid var(--border-control); border-radius:var(--radius-control);
  padding:4px 6px; max-height:110px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
.cj-results { display:flex; flex-direction:column; gap:2px; font-size:9px; color:var(--text-dim); letter-spacing:.5px; }
/* The status-bar line is clickable only while a job is live. The cursor is the
   only affordance the bar has room for. */
#status-msg.cj-live { cursor:pointer; color:var(--accent-hi); }

.cl-form { display:flex; flex-direction:column; gap:6px; }
.cl-row { display:flex; align-items:center; gap:12px; }
.cl-row > label { flex:0 0 150px; width:150px; font-size:11px; color:var(--text-dim); letter-spacing:1px; cursor:help; }
.cl-row > select, .cl-row > input[type=text], .cl-row > textarea { flex:1 1 auto; min-width:0; }
/* Height only. `.sd-dialog textarea` is already the baseline for every textarea
   in every dialog, and a second copy of its colours here would be a second
   place to keep in step. */
.cl-row textarea { min-height:52px; }
.cl-fieldhint { font-size:9px; color:var(--text-dim); opacity:.75; letter-spacing:.5px; padding-left:162px; }
/* A picker with a button beside it. The select takes the room the button does
   not, so widening the dialog widens the select rather than the gap. */
.cl-pickrow { display:flex; align-items:center; gap:8px; flex:1 1 auto; min-width:0; }
/* Any control can sit in one, not just a select: a parameter row wraps its
   input or textarea here too when the field carries a help link. */
.cl-pickrow > select, .cl-pickrow > input, .cl-pickrow > textarea { flex:1 1 auto; min-width:0; }
.cl-info { flex:0 0 auto; }
/* The "?" is an <a>, so it needs the parts .btn assumes a <button> brings:
   centred text on the button's own line-height, and no underline. */
.cl-help { display:inline-flex; align-items:center; justify-content:center;
  min-width:26px; text-decoration:none; }
.cl-help[hidden] { display:none; }
/* What MVSep says about the chosen model, shown under the picker on INFO. */
.cl-modeldesc { font-size:10px; line-height:1.45; color:var(--text-dim); margin-left:132px;
  padding:6px 8px; border-left:2px solid var(--border-control); background:var(--bg-inset); }
.cl-modeldesc[hidden] { display:none; }
/* A row whose control cannot affect anything — the sample-rate choice on a
   model that is sent no audio. Dimmed rather than removed: it is a fact about
   the model, and a row that disappears and comes back as the picker moves is
   harder to read than one that greys. */
.cl-row.cl-disabled > label, .cl-disabled > .cl-row > label { opacity:.45; }
.cl-disabled > .cl-fieldhint { opacity:.5; }
/* The matchering reference file input, which had no styling at all and drew
   the browser's default button in the middle of the dialog. */
.cl-file { flex:1 1 auto; min-width:0; font-size:10px; margin-left:132px; }
.cl-file[hidden] { display:none; }
/* MVSep "My presets" (2026-09-26): the id box is short, the name takes the rest. */
.sd-dialog.cl-dialog .cl-pickrow > input.cl-presetid { flex:0 0 120px; }
/* …and the "Find my ids ↗" link at the end of that row (2026-09-27): a small
   dim link, like the Help window's "As text ↗". */
.cl-pickrow > a.cl-presetlink { flex:0 0 auto; margin-left:4px; font-size:10px; white-space:nowrap;
  color:var(--text-dim); text-decoration:none; }
.cl-pickrow > a.cl-presetlink:hover, .cl-pickrow > a.cl-presetlink:focus-visible { color:var(--accent-hi); text-decoration:underline; }
.cl-pickrow > a.cl-presetlink[hidden] { display:none; }
.cl-group { font-family:var(--font-title); font-weight:600; font-size:10px; letter-spacing:2px; text-transform:uppercase; color:var(--accent-hi); margin-top:6px; }
/* The two API-key fields in Settings. A password field with no width sizes
   itself from the browser default, which is wider than every other control in
   that dialog. */
.set-row .cl-key { flex:1 1 auto; min-width:0; }
.set-row .cl-proxy { flex:1 1 auto; min-width:0; }
/* ══ STEMS (package C) ════════════════════════════════════════════════════
   One contiguous block, class prefixes .m2p- (import MIDI as pattern) and
   .stp- (the stem picker), per stuff/restoration-cloud-plan-2026-09-06.md.
   Every colour is a token: stuff/regression/colour-tokens.js fingerprints the
   token REFERENCES, so a literal that happens to resolve the same still fails.
   ────────────────────────────────────────────────────────────────────────── */

/* ── Convert to Mix Mode (Song Properties plan stage 3) ──────────────────────
   A label column and a value column, the plan's §5.3 layout. The options sit
   in the value column under what they change, and wrap rather than clip. */
.m2m-dialog { width:min(600px, 94vw); }
.m2m-dialog .m2m-lead { margin:0 0 10px; }
.m2m-row { display:flex; gap:12px; align-items:flex-start; margin:0 0 8px; }
.m2m-row[hidden] { display:none; }
.m2m-lbl { flex:0 0 64px; font-size:10px; letter-spacing:1px; color:var(--text-dim); padding-top:1px; }
.m2m-val { flex:1 1 auto; min-width:0; font-size:11px; color:var(--text); line-height:1.5; }
.m2m-dialog .m2m-opt { display:flex !important; align-items:flex-start; margin:2px 0; white-space:normal;
                       line-height:1.4; }
.m2m-dialog .m2m-opt[hidden] { display:none !important; }
.m2m-dialog .m2m-opt input { margin-top:2px; flex:0 0 auto; }
.m2m-warn { margin:4px 0 8px; padding:6px 8px 6px 22px; font-size:10px; line-height:1.5;
            color:var(--accent-ink); background:var(--bg-inset); border-radius:var(--radius-control); }
.m2m-warn[hidden] { display:none; }
.fx-convert-row { justify-content:flex-start; }
.fx-convert-row[hidden] { display:none; }

/* ── Import MIDI as Pattern ─────────────────────────────────────────────── */
.m2p-dialog { width:min(680px, 94vw); }
.m2p-dialog .fr-row label { width:96px; flex:0 0 96px; }
/* A second label inside the same row is not the row's label and must not take
   the 96px column, or "Quantise" pushes its own select off the edge. */
.m2p-dialog .fr-row label.m2p-inline { width:auto; flex:0 0 auto; margin-left:12px; }
.m2p-dialog .fr-help { margin-left:96px; }
.m2p-dialog .fr-row input[type="number"] { width:7em; }
.m2p-unit { font-size:10px; color:var(--text-dim); }
.m2p-unit b { color:var(--text); font-weight:normal; }

/* The source description. Three lines that never wrap into each other, so the
   dialog does not change height as one file replaces another. */
.m2p-stats { font-size:10px; color:var(--text-dim); line-height:1.7; margin-bottom:10px;
             padding:6px 8px; background:var(--bg-inset); border-radius:var(--radius-control); }
.m2p-stats b { color:var(--text-hi); font-weight:normal; font-size:11px; }
/* The file's own tempo, said quietly: it is shown so the user can see it was
   considered, not so they reach for it. */
.m2p-nominal { color:var(--text-dim); font-style:italic; }

/* The four options wrap rather than squeeze — a checkbox whose label has been
   clipped is a control that lies about what it does. */
.m2p-opts { flex-wrap:wrap; gap:4px 16px; }
.m2p-chk { display:inline-flex; align-items:center; gap:5px; width:auto !important;
           flex:0 0 auto !important; font-size:10px; color:var(--text); cursor:pointer; }

/* The drum map. A full General MIDI kit is thirty rows, so it scrolls inside
   the dialog instead of making the dialog taller than the screen. */
.m2p-drums { max-height:180px; overflow-y:auto; overflow-x:hidden; margin:0 0 10px 96px;
             border:1px solid var(--border); border-radius:var(--radius-control); }
.m2p-dm { width:100%; border-collapse:collapse; font-size:10px; }
.m2p-dm th { position:sticky; top:0; z-index:1; text-align:left; font-weight:normal;
             color:var(--text-dim); background:var(--bg-inset); padding:3px 6px;
             border-bottom:1px solid var(--border); }
.m2p-dm td { padding:1px 6px; color:var(--text); border-bottom:1px solid var(--sep); }
.m2p-dm tr:last-child td { border-bottom:none; }
.m2p-dm-pitch { color:var(--text-dim); width:3em; }
.m2p-dm-n { color:var(--text-dim); width:3em; text-align:right; }
.m2p-dm-sel { width:100%; font-size:10px; }
/* THE ROUTING TABLE rides the drum table's look (.m2p-dm inside .m2p-drums);
   only widths and wrapping here, so it adds no colour of its own. */
.m2p-rt-sel { width:100%; font-size:10px; }
.m2p-rt-trk { width:4.5em; font-size:10px; }
.m2p-rt-src, .m2p-rt-res, .m2p-rt-span { white-space:nowrap; }
.m2p-rt-span { margin-left:4px; }
.m2p-songtempo { margin-left:96px; }

/* THE PREVIEW IS MONOSPACED AND SCROLLS SIDEWAYS. It is the pattern editor's
   own column order (note, instrument, command), so a wide song must scroll
   rather than wrap — a wrapped tracker row is unreadable. */
.m2p-preview-wrap { overflow-x:auto; overflow-y:auto; max-height:190px;
                    background:var(--bg-inset); border:1px solid var(--border);
                    border-radius:var(--radius-control); margin-bottom:6px; }
.m2p-preview { margin:0; padding:6px 8px; font-family:var(--font-text);
               font-size:10px; line-height:1.45; color:var(--text);
               white-space:pre; }
.m2p-preview:empty { display:none; }

/* ── The stem picker ────────────────────────────────────────────────────── */
.stp-dialog { width:min(760px, 96vw); }
.stp-dialog .fr-help { margin-left:0; }
.stp-bar { display:flex; align-items:center; gap:12px; margin-bottom:8px; }
.stp-chk { display:inline-flex; align-items:center; gap:5px; font-size:10px; color:var(--text); cursor:pointer; }

/* A job can return 53 stems, so the LIST scrolls and the dialog does not grow
   past the screen. Each row keeps a fixed height whatever state it is in —
   reading, decoded, or refused — so the list does not jump under the pointer
   as the downloads land one after another. */
.stp-list { max-height:52vh; overflow-y:auto; overflow-x:hidden;
            border:1px solid var(--border); border-radius:var(--radius-control);
            background:var(--bg-inset); padding:4px; margin-bottom:8px; }
.stp-row { padding:5px 6px; border-bottom:1px solid var(--sep); }
.stp-row:last-child { border-bottom:none; }
.stp-head { display:flex; align-items:center; gap:8px; }
.stp-pick { display:flex; align-items:center; gap:6px; flex:1 1 auto; min-width:0; cursor:pointer; }
.stp-name { font-size:11px; color:var(--text-hi); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }

/* The kind badge. One colour per shape, so a list of mixed results reads at a
   glance instead of by filename extension. */
.stp-badge { flex:0 0 auto; font-size:9px; letter-spacing:0.5px; padding:1px 5px;
             border-radius:var(--radius-control); border:1px solid var(--border-control);
             color:var(--text-dim); }
.stp-badge.stp-audio { color:var(--accent-ink); border-color:var(--accent-dim); }
.stp-badge.stp-midi  { color:var(--amber); border-color:var(--amber-dim); }
.stp-badge.stp-text  { color:var(--text-dim); }
.stp-badge.stp-image { color:var(--text-dim); }

.stp-audio { display:flex; align-items:center; gap:6px; margin:4px 0 3px 22px; }
.stp-play { flex:0 0 auto; width:2.2em; }
.stp-play.on { color:var(--accent-hi); }
/* The waveform fills whatever is left. A fixed width would clip on a narrow
   window and leave a gap on a wide one, and the click-to-seek maths reads the
   rendered box either way. */
.stp-wave { flex:1 1 auto; min-width:0; height:46px; display:block; cursor:crosshair;
            background:var(--bg-panel); border:1px solid var(--border);
            border-radius:var(--radius-control); }
.stp-foot { display:flex; align-items:center; gap:8px; margin-left:22px; }
.stp-dest { flex:0 0 auto; font-size:10px; }
.stp-meta { font-size:10px; color:var(--text-dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.stp-midimeta { margin:3px 0 3px 22px; white-space:normal; }

/* A text result scrolls in its own box rather than stretching the row: some
   models return a whole chord chart. */
.stp-text { margin:4px 0 4px 22px; padding:5px 7px; max-height:120px; overflow:auto;
            font-family:var(--font-text); font-size:10px; line-height:1.45;
            color:var(--text); background:var(--bg-panel);
            border:1px solid var(--border); border-radius:var(--radius-control);
            white-space:pre-wrap; }
.stp-img { margin:4px 0 4px 22px; }
.stp-img img { max-width:100%; display:block; border:1px solid var(--border);
               border-radius:var(--radius-control); }
/* ── RESTORE window (declip / dequantise) ────────────────────────────────
   One contiguous block, class prefix .rst-, colours from existing tokens
   only (the colour-tokens gate reads every literal in this file). The
   window reuses .sd-progress / .sd-progress-track / .sd-progress-fill for
   its bar rather than growing a second progress-bar vocabulary. */
.rst-dialog { width:min(680px, 94vw); }
.rst-dialog .sd-body { max-height:min(76vh, 900px); overflow:auto; }

/* The analysis and stats readouts. A key/value list, not a table: the
   values are sentences as often as they are numbers, and a table column
   sized for "8 effective bits, step 7.813e-3, gain 1.0000, 251 distinct
   values" would leave the numeric rows mostly whitespace. */
.rst-panel { background:var(--bg-inset); border:1px solid var(--seam); padding:6px 8px;
             margin:4px 0 8px; font-size:11px; color:var(--text-dim); min-height:20px; }
.rst-arow { display:flex; gap:10px; align-items:baseline; padding:1px 0; }
.rst-akey { flex:0 0 34%; color:var(--text-dim); }
.rst-aval { flex:1 1 auto; color:var(--text); font-variant-numeric:tabular-nums; }
.rst-warn { color:var(--alert); padding:2px 0 0; }

.rst-checks { flex-wrap:wrap; gap:12px; }
.rst-checks label { display:inline-flex; align-items:center; gap:4px; color:var(--text-dim); font-size:11px; }
.rst-overrides { flex-wrap:wrap; gap:8px; }
.rst-overrides label { color:var(--text-dim); font-size:11px; }
.rst-num { width:9ch; }

/* The sticky footer. Sticky rather than a flex row outside the scroller,
   because the body is the app's shared .sd-body and giving it a second
   layout mode here would change every dialog that ever borrows the class. */
.rst-actions { position:sticky; bottom:0; z-index:2; margin-top:4px; padding-top:6px;
               background:var(--bg-panel); border-top:1px solid var(--seam); }

/* ══ THE MIXER WINDOW (task #11 part 3 / signal routing phase 3) ═════════════
   js/mixer-window.js. A SECOND HOST for the same strips the channel headers
   draw, not a move — the docked mini strips stay where they are, which is this
   project's standing rule about things having a fixed place.

   The window's whole reason for existing is Phil's: "see all the channel faders
   closer together, without having to line up with the channels in the pattern."
   So a strip here is NARROW and fixed, where a docked one is --pc-w wide to sit
   over its pattern column. */
.sd-dialog.mix-dialog {
  width:min(900px, calc(94vw / var(--ui-scale)));
  /* A DEFINITE HEIGHT, not auto. The strips hold vertical range inputs, and a
     vertical range has an enormous INTRINSIC BLOCK SIZE — the docked strip was
     measured at 620px from exactly that when its height went to `auto`. Giving
     the dialog a real height keeps every ancestor in the chain definite, so the
     meters' percentages resolve at all. */
  height:min(460px, calc(88vh / var(--ui-scale)));
  /* Dragged bigger for more strips on screen, or smaller to get out of the way.
     The size is recorded in the layout store, so it survives a reload — see
     `windows` in js/layout.js. .sd-dialog is already overflow:hidden, which is
     what makes `resize` legal here. */
  resize:both;
  /* A FLOOR THAT FITS THE STRIP (round 5 · U8). Phil: "the mixer window needs
     a minimum height overall ... otherwise they begin to overlap each other,
     and other parts of the UI." 120px was below the strip's own fixed rows:
     head + inserts + sends + pan + M/S + out come to 79px before the clip lamp
     (5) and the readout (11), and the rail carries another ~35px of gaps,
     padding and scrollbar. MEASURED on a laid-out window: the rail stands 130px
     taller than its meter well, and the dialog 45px taller than the rail. So
     175px is the height at which the well is ZERO, and every pixel below that
     was rows being pushed through each other.

     200px leaves the well 25px -- small, but still a meter, and still the
     user's choice to go there. The scale ladder gets out of the way before
     this, at the container query below. */
  min-width:200px; min-height:200px;
}
/* THE VIEW BUTTON FOLLOWS THE SUBTITLE, it does not push away from it.
   .lw-titlebtn carries margin-left:auto so it can reach the right edge in the
   list windows, whose .sub is usually EMPTY. The mixer's sub always reads
   ("16 channels · 3 buses"), so the title bar had TWO auto margins — and
   flexbox splits the free space between them. Measured in a 1180px window: the
   sub sat at x=551, floating in the middle of the bar and moving every time the
   window was resized, which is the opposite of a thing having a fixed place.
   6px is what .sd-title [data-sop="expand"] uses for the same job. */
.mix-dialog .sd-title .lw-titlebtn { margin-left:6px; }
/* The Instrument List's Help ↗ (2026-09-27) sits between its subtitle and
   the view button, so the button follows it the same way. */
.lwi-dialog .sd-title .lw-titlebtn { margin-left:6px; }
/* The body is a ROW: the scrolling rail, then the pinned master. It must not
   scroll itself — the rail is the scroller — so min-width:0 on the rail is
   load-bearing, exactly as min-height:0 is on .sd-body. */
.mix-body { display:flex; flex-direction:row; align-items:stretch;
            gap:var(--d-mixstrip-gap); padding:var(--d-mixstrip-pad); overflow:hidden;
            }

.mix-rail { flex:1 1 auto; min-width:0; display:flex; flex-direction:row;
            align-items:stretch; gap:var(--d-mixstrip-gap);
            overflow-x:auto; overflow-y:hidden; }
/* ── THE ROWS LINE UP ACROSS THE WHOLE RAIL ────────────────────────────────
   A meter is a COMPARISON instrument, and strips whose meters start and end at
   different heights cannot be compared at a glance. They did: a channel with two
   sends has a taller sends block, and since the meter is the part that grows, it
   absorbed the difference and came out shorter than its neighbours.

   Round 3 did that by making the rail a grid of the strip's own rows and each
   strip a SUBGRID spanning them, so every row was sized by the tallest strip.
   ROUND 4 REPLACED THAT with a fixed template — every strip declares the same
   six track sizes from --d-mixs-r-* and stretches to one rail row, so the rows
   agree because they are the same numbers rather than because something
   reconciled them. Still declarative, still true when a send is added while the
   window is open (js/mixer-window.js publishes --mix-sends-h for the one row
   whose height is genuinely content's), and now ALSO true of a strip nobody has
   scrolled to — which the subgrid form was not.

   The reason it had to change is at .mixs: a strip that hands its rows to the
   rail cannot be contained, and containment is the fix for a fader drag that
   repaints the whole rail.

   STILL BEHIND @supports, and the flex layout above is still the fallback. The
   strips no longer need subgrid themselves, but the group BLOCK does — it hands
   its members the rail's columns — so the feature query stays. */
@supports (grid-template-rows: subgrid) {
  .mix-rail {
    display:grid; grid-auto-flow:column;
    grid-auto-columns:var(--d-mixstrip-w);
    /* TWO ROWS: the group header band, and everything else.

       ROW 1 IS THE GROUP HEADER BAND (round 3 · P1). Phil: "the group headers
       are positioned at the top, with the member channels enclosed below them".
       A block puts its bar there and starts at row 1; an ungrouped channel and
       the master start at row 2 and the bar is simply above them. With no block
       in the rail — plain channel order, or a module with no groups — nothing
       occupies the row and an `auto` track with nothing in it is 0px, which is
       why the flat view is pixel-for-pixel what it was.

       THIS IS WHY THE BAR CAN HAVE HEIGHT AT ALL. Round 2 drew it absolutely
       positioned, because a box inside the block that took height would have
       been subtracted from the outermost subgrid track and pushed every meter
       in the block down by that much. Giving the RAIL the row instead means the
       bar is laid out like everything else and still nothing moves.

       ROW 2 IS EVERY STRIP, stretched. A strip declares its own seven rows
       from the --d-mixs-r-* tokens, so all it needs from the rail is a height
       to divide — and they all get the same one.

       WHY THAT IS BETTER THAN SHARING SEVEN, beyond letting a strip be
       contained: shared rows were sized by the tallest strip, which sounds
       right and was not. A strip
       that has never been scrolled to reports contain-intrinsic-size rather
       than its real height, so the tracks depended on where the rail had been
       scrolled. Measured on bastardo2: 24/16/16/15/…/18/24 on a rail nobody
       had scrolled, settling to 18/16/16/13/…/15/19 once every strip had been
       rendered once. Declared tracks cannot drift like that. */
    grid-template-rows:auto minmax(0, 1fr);
    column-gap:var(--d-mixstrip-gap); row-gap:0;
    align-items:stretch;
  }
  .mix-rail > .mixs { grid-row:2; }
  /* A NESTED GROUP IS A SUBGRID IN BOTH AXES (round 2 · U4). It spans exactly
     the columns its contents need (grid-column:span N, written inline by
     js/mixer-window.js) and hands its own children the RAIL's rows, so a strip
     inside a group box and one outside it size their meters identically. A box
     that merely wrapped its members would be one column wide and would size its
     rows alone, which is the misalignment this whole subgrid exists to remove. */
  .mix-rail > .mixg {
    display:grid; grid-row:1 / -1;
    grid-template-rows:subgrid; grid-template-columns:subgrid;
    column-gap:var(--d-mixstrip-gap); row-gap:0;
  }
  /* THE BAR ACROSS THE TOP OF THE BLOCK, spanning the group's own strip and
     every member — which is the top stroke of Phil's L, and the bus strip in
     column 1 below is the down stroke. */
  .mixg > .mixg-head { grid-row:1; grid-column:1 / -1; }
  .mixg > .mixs { grid-row:2; }
  /* A PINNED STRIP IS STUCK TO THE RIGHT EDGE. It has to be IN the grid to
     share its rows — a second container could only ever size its rows
     independently, which is the misalignment this block exists to remove — and
     sticky is what keeps it on screen when the rail scrolls past it.

     ON A CLASS, NOT ON THE MASTER (round 5 · U6). Phil: "master and returns
     should have an option to pin/unpin them, the master shouldn't be pinned to
     the right all the time." js/mixer-window.js decides WHICH strips carry the
     class and writes --pin-right for the stack order; everything about how a
     pinned strip LOOKS is still here.

     --pin-right defaults to 0, which is the rightmost one — the master, unless
     the user has unpinned it. */
  .mix-rail > .mixs-pinned {
    position:sticky; right:var(--pin-right, 0px); z-index:3;
    margin-left:var(--d-mixstrip-gap);
    border-left:1px solid var(--btn-press);
    /* TWO SOLID SHADOWS, and the second one is a bug fix. Phil: "partially
       visible group headers can enclose the master channel, which looks wrong."
       They did: a block's bar spans grid-row 1 across every column the block
       owns, a pinned strip sits in ROW 2 and slides left over those columns, so
       the bar went on being painted in the empty row above it — the master
       wearing another bus's header, looking like a member of it.

       An outer box-shadow is painted at the ELEMENT's z-index and clipped to
       exclude its own border box, so an upward offset is exactly a band above
       the strip and nothing else. 100px rather than the bar's 16 because the
       row is auto-height: anything up to 100 is covered, and the rail's
       overflow-y:hidden trims the rest. The spread widens both bands over the
       gap to the left, which the first shadow alone used to cover. */
    box-shadow:calc(var(--d-mixstrip-gap) * -1) 0 0 var(--bg-panel),
               0 -100px 0 var(--d-mixstrip-gap) var(--bg-panel);
  }
  /* AN UNPINNED MASTER IS AN ORDINARY LAST STRIP. It keeps the rule that
     separates it from channel 1 — that is about what it IS, not about where it
     is held — and loses the sticky, the stacking and the shadows with it. */
  .mix-rail > .mixs-master:not(.mixs-pinned) {
    margin-left:var(--d-mixstrip-gap);
    border-left:1px solid var(--btn-press);
  }
  /* …and the separate pin container has nothing to hold. */
  /* NAMED THROUGH ITS PARENT so this beats the .mix-pin rule below at equal
     specificity -- an @supports block does not raise specificity, and source
     order would otherwise hand the win to the later declaration. Measured: the
     empty pin still computed display:flex and drew its 1px divider and 4px gap
     past the master. */
  .mix-body > .mix-pin { display:none; }
}
/* PINNED RIGHT, outside the scroller. The docked rail pins its master LEFT
   because .phrow is the pattern header's one sticky column and a right pin
   would strand the master across a gulf of empty header at low track counts.
   Neither holds here: this window is sized to its own strips, so the master
   sits flush against the last one at every count and the DAW convention wins.
   The rule is the same border token the header uses between master and
   channel 1, so the two rails separate their master the same way. */
.mix-pin { flex:0 0 auto; display:flex; align-items:stretch;
           padding-left:var(--d-mixstrip-gap);
           border-left:1px solid var(--btn-press); }

/* ── ONE STRIP ─────────────────────────────────────────────────────────────
   A fixed narrow column, so a 28-track module fits on screen at once. Its rows
   are always present — empty rather than absent where a kind has nothing to put
   in one — so nothing in a strip moves as the model changes underneath it. */
/* ── A NESTED GROUP'S BOX (round 2 · U4, rebuilt as the L in round 3 · P1) ──
   Phil, 2026-09-12, after seeing round 2's attempt: "the nested mixer view is
   not at all what I wanted ... See how the group headers are positioned at the
   top, with the member channels enclosed below them. Ableton has more of an
   L-shaped header where the group's channel strip is to the left, and the top
   bar extends above the member channels. The colour coding is what really makes
   this work ... Ableton uses the same colour for group members, renoise seems
   to have a different colour for everything. Renoise puts the group's channel
   strip/pattern column on the right. I prefer Ableton on both."

   So: a bar along the top spanning the whole block (rail row 1, see the
   @supports block above), the group's OWN strip in column 1 under it, and the
   members enclosed to its right. Bar plus bus strip is the L; everything else
   is inside it.

   ONE COLOUR FOR THE BLOCK, and it is the module's, not the theme's:
   fxGroupColor(i), which fxPlan already hands to a group AND to each of its
   tracks. js/mixer-window.js writes it onto the block as --mixg-*, so a
   stylesheet does not have to enumerate a palette whose length it cannot know.
   The four names below are the defaults for a block with no colour at all.

   DRAWN WITH A SHADOW AND A TINT, NOT WITH A BORDER OR PADDING, and that is a
   correctness point rather than a taste one. This element is a SUBGRID: the
   spec subtracts its own margin, border and padding from the outermost tracks
   it inherits, so a 1px top border would push every meter inside the box down
   by a pixel and break the one property the rail's grid is for. box-shadow
   costs no layout at all, so the frame is drawn and nothing moves. Measured by
   the mixer gate: every meter in the rail must share one top and one height. */
.mixg {
  position:relative;
  /* THE FALLBACK LAYOUT, for a browser with no subgrid. flex-wrap with the bar
     at 100% gives the same reading order — bar, then bus strip, then members —
     without the absolute positioning round 2 needed. Inside @supports the grid
     placement above takes over and neither of these applies. */
  display:flex; flex-direction:row; flex-wrap:wrap; align-items:stretch;
  column-gap:var(--d-mixstrip-gap); row-gap:0; min-width:0;
  background:var(--mixg-soft);
  border-radius:var(--radius-control);
  box-shadow:inset 0 0 0 1px var(--mixg-edge);
}
/* An aux group is fed by sends rather than by membership. It keeps its own
   colour like every other block — colour identifies the group, not its kind —
   and marks itself with a dashed rule under its bar rather than with a colour
   of its own, beside the strip's own `aux` badge and its A-prefixed number.
   (Round 2 gave an aux box the seam instead of the accent; that was a second
   colour for one block, and one colour per block is now the rule.) The border
   sits inside the bar's own 16px box, so it costs the grid nothing. */
.mixg-return > .mixg-head { border-bottom:1px dashed var(--mixg-ink); }
/* ── THE BAR ───────────────────────────────────────────────────────────────
   Painted in the block's colour outright, with an ink chosen for THAT colour
   rather than for the theme — js/mixer-window.js computes it from the Rec. 601
   luma, because the palette runs from #d4890a to #3a9bd4 and one fixed ink is
   unreadable on half of it. */
.mixg-head {
  display:flex; align-items:center; gap:3px; flex:0 0 100%; min-width:0;
  height:16px; padding:0 2px 0 1px; box-sizing:border-box;
  background:var(--mixg-colour);
  color:var(--mixg-ink);
  border-radius:var(--radius-control) var(--radius-control) 0 0;
  font-size:9px; line-height:16px; white-space:nowrap;
}
.mixg-title { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis;
              white-space:nowrap; font-weight:bold; }
/* THE BUS'S NUMBER, leading the bar the way a channel strip's leads its head
   (round 5). Dimmer than the name: it identifies, the name is what you read. */
.mixg-num { flex:0 0 auto; font-size:8px; opacity:.75; white-space:nowrap;
            font-variant-numeric:tabular-nums; }
.mixg-count { flex:0 0 auto; margin-left:auto; font-size:8px; opacity:.8; white-space:nowrap; }
/* ── NO COLOUR DOTS INSIDE A BLOCK (round 5) ──────────────────────────────
   Phil: "No need to show the coloured dots when in nested view." The dot says
   which bus a strip belongs to, which is worth saying in the FLAT rail where a
   channel and its bus can be thirty columns apart. Inside a block the whole
   box is that colour, the bar above it carries the name and the number, and
   enclosure has already made the point -- so the dot is the fourth way of
   saying one thing, on the strip with the least room to say it. */
.mixg .mixs-dot { display:none; }
/* THE CARET, the same rotated corner .mixs-ins-caret uses so the two
   disclosures in this window are one control in two places.

   IT POINTS THE WAY THE BLOCK MOVES (round 5). Phil: "the caret arrow
   directions on group headers feel wrong, they are right and down, when they
   should be right and left." He is right, and the reason is that this
   disclosure is HORIZONTAL: a collapsed block is one strip wide and expanding
   it pushes its members out to the RIGHT. Down is what the inserts caret means,
   because that panel opens downwards -- borrowing the shape was right and
   borrowing the direction was not.

   So: RIGHT when shut, because that is where the members will appear; LEFT when
   open, because that is where they will go. */
.mixg-collapse {
  flex:0 0 14px; height:14px; padding:0; display:flex; align-items:center; justify-content:center;
  background:none; border:1px solid transparent; border-radius:var(--radius-control);
  color:inherit; font:inherit; cursor:pointer;
}
.mixg-collapse:hover { border-color:currentColor; }
.mixg-caret { width:5px; height:5px; border-right:1.5px solid currentColor;
              border-bottom:1.5px solid currentColor; transform:rotate(-45deg) translate(-1px, -1px); }
.mixg-collapse[aria-expanded="true"] .mixg-caret { transform:rotate(135deg) translate(-1px, -1px); }
/* ── THE DOWN STROKE: the group's own strip, in column 1 ───────────────────
   Tinted more strongly than its members so the bar and the bus strip read as
   ONE header — the L — with the members enclosed by it. A background-image of a
   flat colour over the theme's own ground, rather than a background-color, so
   the strip keeps whatever ground its theme gives it and the group colour is a
   wash on top. */
.mixg > .mixs-group, .mixg > .mixs-return {
  background-image:linear-gradient(var(--mixg-strong), var(--mixg-strong));
  border-color:var(--mixg-edge);
}
/* ...and the members, in the same colour and quieter, which is Ableton's rule
   and explicitly not Renoise's colour-per-track. */
.mixg > .mixs:not(.mixs-group):not(.mixs-return) {
  background-image:linear-gradient(var(--mixg-soft), var(--mixg-soft));
  border-color:var(--mixg-edge);
}
/* COLLAPSED is a block one column wide holding the bar and the bus strip. The
   members are not in the DOM at all (js/mixer-window.js does not render them),
   so there is nothing here to hide — and nothing for the metering loop, which
   collects its bars positionally, to collect for a cell nobody can see. */
/* COLLAPSED, THE BAR KEEPS THE NAME AND DROPS THE COUNT (round 4, Phil's
   answer to round 3's question 2). It was the other way round. The count is in
   the title attribute in both states, so nothing is lost by hiding it here —
   and the name is the one thing a folded block cannot do without. */
.mixg.collapsed .mixg-count { display:none; }
/* ── WHY THE RAIL SKIPS WHAT IT IS NOT SHOWING (round 3, P0) ──────────────
   MEASURED 2026-09-13 on a 25-channel module, 36 strips, against the same
   module with the window shut:

     one style-invalidating write, forced layout    window shut   13.3 ms
                                                    window open   28.2 ms
     the same on a 4-channel module, 5 strips       window open   10.1 ms

   So the Mixer window doubled the cost of EVERY layout in the app, and the
   surcharge scaled with strip count — which is exactly Phil's report that the
   hitching is "worse the more channels there are". Bisecting the rail found no
   guilty control: hiding the meters, the faders, the inserts or the ladder each
   bought 1-3 ms. It is the element COUNT. Thirty-six strips of real controls is
   a lot of boxes, and the rail scrolls, so at any moment eight or ten are on
   screen and the rest are laid out for nobody.

   `content-visibility:auto` is the one property that says that: a strip outside
   the viewport skips layout and paint, and resolves when it scrolls in.
   `contain-intrinsic-size` keeps its footprint so the rail's scroll width does
   not move (measured identical, 2876 px both ways), and the `auto` keyword
   remembers each strip's real size once it has been rendered once, so the
   placeholder is only used before a strip has ever been seen.

   Re-measured with it on: 28.2 ms -> 14.5 ms, which IS the window-shut figure.
   The window now costs what it costs to draw and nothing to ignore.

   ── ...AND WHY IT MOVED OFF `.mixs` ONE TIER LATER (round 3, P1) ──────────
   `content-visibility:auto` turns on LAYOUT CONTAINMENT, always — not only
   while the element is skipped. And a grid container with layout containment
   cannot be a subgrid: `grid-template-rows:subgrid` computes to `none`. So
   putting it on `.mixs` silently switched off the one property the whole rail
   exists to have. MEASURED on bastardo2 (25 channels, 36 strips, the same
   module the note above was measured on):

     rail's own row track sizes, with it on `.mixs`   0 0 0 0 404px 0 0
     meter top spread across the 36 strips            375 px
     ...and five runMixerGate assertions red, including "every meter in the
     rail starts and ends at the same height" (top spread 84, height 24).

   A meter is a COMPARISON instrument. Strips whose meters start and end at
   different heights cannot be compared at a glance, which is the sentence the
   @supports block below opens with.

   THE FIX IS THE SAME PROPERTY ONE LEVEL DOWN. A strip's seven ROWS are not
   subgrids — they are ordinary flex and block boxes — so they may be contained
   freely, and they hold ~24 of a strip's ~26 elements. The strip itself stays
   uncontained and goes on handing the rail its rows. Best-of-four forced-layout
   cost, 36 strips, measured in one session so the four are comparable:

     where content-visibility:auto sits   meter top spread   forced layout
     ---------------------------------------------------------------------
     on `.mixs`   (P0 as shipped)              84 px           12.22 ms
     nowhere      (no skipping at all)          0 px           19.46 ms
     on the seven rows (this)                   0 px           10.25 ms
     ...and the same plus `.mixs-ins`           0 px           10.66 ms

   So on a forced layout the rows are not a compromise between the two — they
   are CHEAPER than the strip form and they keep the alignment as well.

   WHERE IT IS NOT FREE, SAID PLAINLY. A 40-step pointer drag on track 1's
   fader, best of three, per-step cost including the style-and-layout flush:

     on `.mixs`   (P0, meters misaligned)   p50 4.5 ms   p90  6.5   worst 10.1
     nowhere                                p50 9.0 ms   p90 13.3   worst 14.3
     on the seven rows (this)               p50 9.4 ms   p90 12.8   worst 16.0

   That gap is the ALIGNMENT itself, not the containment: a live subgrid has to
   re-resolve seven shared tracks across 36 items on every dirty layout, and a
   dead one has a single 1fr track to resolve. Half of P0's drag saving was the
   feature going missing. It still clears the bar P0 set — no step over 16.7 ms
   on 36 strips — and the way to get both is to stop needing subgrid at all:
   state the six non-flexible row heights in this file and drive the sends row
   from a `--mix-send-rows` custom property taken from the MODEL, at which point
   every strip declares the same template, alignment is by construction, and
   `content-visibility` may go back on `.mixs`. Recorded rather than done —
   it re-specifies the rail and this tier is about not losing the property.

   THE OTHER COST, STATED. The block-axis fallbacks below are what a row reports
   before it has EVER been rendered, and the rail sizes each row from the
   tallest strip — so a strip that is taller than every strip on screen, and has
   never been scrolled to, under-contributes until it is. `auto` then remembers
   its real size for good. The fallbacks are therefore the measured heights of
   an ordinary strip rather than one round number, so the gap is a pixel or two
   and not a row. `.mixs-ins` is deliberately NOT in the set: paint containment
   clips, and the inserts panel's expanded detail is drawn as a flyout OUT of
   that box (see .mixs-ins-detail). */
.mixs {
  flex:0 0 var(--d-mixstrip-w); min-width:0;
  /* ── THE SEVEN ROWS, DECLARED (round 4 · the rail rewrite) ──────────────
     Six fixed tracks from the density tokens and one 1fr between them, in
     signal order: name · inserts · sends · pan · fader+meter · M/S · output.
     EVERY strip says exactly this, including the master, which simply leaves
     three of them empty — so two strips' meters start at the same y because
     the numbers above them are the same numbers, not because a shared track
     reconciled two different ones.

     TWO ROWS ARE GENUINELY CONTENT'S. The INSERTS row is one of them since
     round 5: a PINNED panel sits in flow inside it, so the row has to be as
     tall as the tallest pinned panel in the rail — and as tall on the strips
     whose panel is shut, or their meters would not line up with the rest. Same
     mechanism as the sends row below: js/mixer-window.js publishes --mix-ins-h
     and the token is the floor.

     And sends grows with how many a strip has, and
     a send row can carry a feedback note or the engine's own drop message. So
     the token is a FLOOR and js/mixer-window.js publishes --mix-sends-h on the
     rail, one measurement per render, taken from the tallest sends box. It
     inherits, so every strip still reads one number. The old form paid for the
     same answer on every dirty layout instead, across thirty-six items.

     THE TRACKS ARE THE MEASURED HEIGHTS, not round numbers. Taken across all
     36 strips of bastardo2 with content-visibility skipping forced off — which
     matters, because a skipped row reports contain-intrinsic-size and the
     numbers that were in this file before it were taken with some of them
     skipped. */
  display:grid;
  grid-template-rows:
    var(--d-mixs-r-head) max(var(--d-mixs-r-ins), var(--mix-ins-h, 0px))
    max(var(--d-mixs-r-sends), var(--mix-sends-h, 0px))
    var(--d-mixs-r-pan) minmax(0, 1fr)
    var(--d-mixs-r-ms) var(--d-mixs-r-out);
  row-gap:2px;
  padding:3px; box-sizing:border-box;
  background:var(--bg-grid-head);
  border:1px solid var(--seam);
  font-size:10px; color:var(--text-dim);
  /* A STACKING CONTEXT PER STRIP — see .mixs-ins. The inserts flyout has to beat
     the paint-contained rows below it inside its own strip, and must NOT beat
     the sticky master strip outside it. */
  position:relative; z-index:1;
}
.mixs:focus-visible { outline:1px solid var(--accent-dim); outline-offset:-1px; }
.mixs-master { background:var(--bg-header); }
.mixs-head { display:flex; align-items:center; gap:3px; flex:0 0 auto; min-width:0; }
.mixs-dot { flex:0 0 auto; width:7px; height:7px; border-radius:50%; }
.mixs-name {
  flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  max-width:none;
  color:var(--text-hi); cursor:text;
  border:1px solid transparent; border-radius:var(--radius-control); padding:0 2px;
}
.mixs-name:hover { border-color:var(--border-control); background:var(--bg-inset); }
/* An empty name is a PLACEHOLDER, not a value: it says the field is fillable,
   which is the one thing a placeholder is for. */
.mixs-name.unnamed { opacity:.55; font-style:italic; }
/* THE CHANNEL NUMBER (round 2 · U2). Phil: "There's a lot of unused space on
   each channel strip ... or show channel numbers." Ahead of the name, so a
   column is identified the same way the docked header identifies it, and in the
   accent ground the docked channel button uses so the two read as the same
   label rather than as two different ones. */
.mixs-num {
  flex:0 0 auto; font-size:8px; line-height:11px; padding:0 3px;
  color:var(--accent-hi); background:var(--accent-ground-soft);
  border:1px solid var(--accent-dim); border-radius:var(--radius-control);
  font-variant-numeric:tabular-nums; cursor:help;
}
/* ── EVERY STAGE IS PINNED TO ITS OWN ROW (round 2 · U1) ───────────────────
   The strip reads in signal order and a kind that LACKS a stage does not draw
   an empty box for it — but the rail's shared rows must still line up, so the
   rows are NAMED here rather than left to source order. A strip that omits its
   pan leaves row 4 empty and everything below it stays exactly where it was;
   with no strip in the rail holding that stage the row collapses on its own,
   which is how "absent, not blank" and "the meters line up" are both true. */
.mixs-head  { grid-row:1; }
.mixs-ins   { grid-row:2; }
.mixs-sends { grid-row:3; }
.mixs-pan   { grid-row:4; }
.mixs-body  { grid-row:5; }
.mixs-ms    { grid-row:6; }
.mixs-out   { grid-row:7; }
/* ── AND WHERE THE SKIPPING LIVES (round 3, P1) ────────────────────────────
   One rule per row rather than `.mixs > *` with one size, because the block
   fallback IS the number a never-yet-rendered strip hands the rail, and one
   round number for seven different rows is how the rail comes out a row short.
   Each figure below is that row measured on an ordinary strip. `.mixs-body` is
   the 1fr row — its height is the track's, never the content's — so its
   fallback cannot perturb anything and only needs to be plausible. */
/* ── AND SINCE ROUND 4 THE SKIPPING IS BACK ON THE STRIP ───────────────────
   Round 3 had to put it on the six ROWS: `content-visibility:auto` turns on
   layout containment, a grid container with layout containment cannot be a
   subgrid, and putting it on `.mixs` therefore switched off the alignment the
   rail existed to have. The strip no longer hands its rows to anyone, so that
   collision is gone and the property goes back where it belongs — one skipped
   box per off-screen strip instead of six, and one intrinsic size to state
   instead of six that each had to be right.

   `contain:paint` COMES WITH IT, and it is the round-4 P0 fix rather than
   housekeeping. Dragging a range input's thumb invalidates a paint region, and
   with nothing containing it that region resolves against the whole rail — 2876
   px of strips — so one thumb repainted all thirty-six. Measured on bastardo2:
   13.9 ms a frame during a real pointer drag, against a 6.9 ms idle rate, and
   6.9 on a four-channel module. Containing the strip is what bounds it.

   THE INSERTS FLYOUT SURVIVES because it never leaves the strip: it is
   `width:100%` and hangs below the inserts row, which is near the top of a
   ~400 px strip. It escapes its ROW, which is why `.mixs-ins` was excluded from
   the row-level form, and it is clipped by nothing now that the containment is
   one level up.

   ONE INTRINSIC SIZE, and it is a whole strip's. The rail's scroll width comes
   from the columns, not from this, so the block axis only has to be close
   enough that the scrollbar does not jump when a strip is first rendered. */
.mixs { content-visibility:auto; contain:paint;
        contain-intrinsic-size:auto var(--d-mixstrip-w) auto 420px; }
/* The inserts and sends rows own their own height; both are empty until a group
   has a chain or a strip has a send, and an empty row costs nothing. */
.mixs-ins, .mixs-sends { flex:0 0 auto; min-width:0; display:flex; flex-direction:column; gap:1px; }
/* THE PART THAT GROWS. Every pixel the window gains goes to the meters, which
   is the point of a vertical meter and the same decision the docked strip made.
   min-height:0 so a flex child may actually shrink.
   A COLUMN since round 2: clip lamp, then the well, then the peak readout —
   which is the order U2 asks for, and the only one in which no fader position
   can cover the lamp. */
.mixs-body { flex:1 1 auto; min-height:0; display:flex; flex-direction:column;
             align-items:stretch; gap:2px; }
/* THE CLIP LAMP, ABOVE THE WELL (round 2 · U2). It was a ::after stripe across
   the top of the meter, which is exactly where a fader at unity sits — so a
   channel clipping at full level hid its own lamp behind its own knob. Its own
   row cannot be covered by any knob position, and it is always in the DOM
   (unlit) so nothing moves when it lights. Phil: "the clip indicator (which
   doesn't reset when you click it)" — the click is wired in js/mixer-window.js
   and goes through the same clearTrackClips every host's lamps answer to.

   AND IT IS THE METER'S WIDTH, NOT THE STRIP'S (round 5 · U5). Phil: "clip
   indicator at the top is too wide". The row still spans the strip — that is
   what keeps the click target generous, and the click clears the latch — but
   the lit part is laid out the way .mixs-well lays out its children, so the
   lamp sits over the meter it reports on and nowhere else. Same centring,
   same scale column, same gap: if the well's arrangement changes, the lamp
   follows it instead of having to be re-measured. */
.mixs-clip {
  flex:0 0 5px; cursor:pointer;
  display:flex; flex-direction:row; align-items:stretch;
  justify-content:center; gap:2px;
}
/* The scale's column, so the lamp starts where the meter starts. */
.mixs-clip::before { content:''; flex:0 0 var(--d-mixs-scale-w); }
/* A REAL ELEMENT, not a ::after. The gate asserts that this sits over the
   meter and is no wider than it, and a pseudo-element has no rect to ask and
   no hit test to answer -- which would have left the one thing Phil reported
   ("too wide") as the one thing the suite could not measure. */
.mixs-clip > i {
  flex:0 0 var(--d-mixs-meter-w);
  border-radius:2px; background:var(--vu-track);
  border:1px solid var(--vu-track-edge); box-sizing:border-box;
}
.mixs.clipped .mixs-clip > i { background:var(--vu-clip); border-color:var(--vu-clip); }
/* The scale ladder and the meter, side by side and the same height — the ladder
   is the FADER's scale, so a mark has to sit at the height the knob reads it. */
.mixs-well { flex:1 1 auto; min-height:0; display:flex; flex-direction:row;
             align-items:stretch; justify-content:center; gap:2px; }
/* ── THE SCALE (round 2 · U3) ───────────────────────────────────────────────
   Absolutely positioned marks in a relative column, because a mark's place is a
   PERCENTAGE OF THE TRAVEL and nothing else in a flex column can express that.
   translateY(50%) puts the mark's centre on its own `bottom`, so "0" sits at the
   very top rather than half a line above it. */
.mixs-scale { flex:0 0 auto; position:relative; width:var(--d-mixs-scale-w); min-height:0; }
.mixs-scale > i {
  position:absolute; right:0; transform:translateY(50%);
  display:flex; align-items:center; gap:2px; white-space:nowrap;
}
.mixs-scale > i::after { content:''; width:3px; height:1px; background:var(--border-control); }
.mixs-scale > i > b {
  font-weight:normal; font-size:7px; line-height:7px; color:var(--text-dim);
  font-variant-numeric:tabular-nums;
}
/* THE PEAK READOUT, UNDER THE METER (round 2 · U2), filled by the metering
   loop's own pass — see drawPeaks in js/mixer-window.js. Centred under the well
   rather than hung off one side: it is about the meter above it, and a number
   pinned to an edge reads as belonging to whatever is beside it. */
/* min-height RESERVES THE LINE, and that is the whole of why it is here.
   Measured on a laid-out window: an EMPTY readout is a 0px flex child, so the
   well above it took the slack — and the moment the transport started and a
   number appeared, every meter in the rail shrank by 11px and grew again on
   stop. The meters stayed level with each other throughout (they share the
   rail's row), which is exactly what would have made it look like a rendering
   glitch rather than a layout bug. Things have a fixed place: the row is one
   line tall whether or not it has a value in it. */
.mixs-read { flex:0 0 auto; min-height:11px;
             font-variant-numeric:tabular-nums; font-size:9px; line-height:11px;
             text-align:center; color:var(--text-dim); }
/* Bar, (fader lane), bar — the docked strip's arrangement, at this window's own
   size. --vu-lane is not used here: the lane is the fader's own box and it is
   inserted between the bars in A3. */
.mixs-meter {
  flex:0 0 auto; min-height:0;
  display:flex; flex-direction:row; justify-content:center;
  background:var(--vu-track); overflow:hidden;
  box-sizing:border-box; border:1px solid var(--vu-track-edge);
  position:relative;
}
/* WIDER THAN THE DOCKED BARS, deliberately (round 2 · U2). Phil: "we probably
   also have room to make the meters wider than the docked view, so it's
   relative." The docked bar is 7px because a channel header column is 21px wide
   and shares its width with the pattern grid; this window is sized to its own
   strips and can afford 11 — which is also what makes a narrow knob readable
   over it, since what shows either side of the knob is the part that is left. */
.mixs-meter > i {
  display:block; flex:0 0 var(--d-mixs-bar-w); height:100%; min-width:0;
  clip-path:inset(100% 0 0 0);
  background:linear-gradient(0deg,var(--vu-floor) 0%,var(--vu-safe) 70%,var(--vu-warn) 88%,var(--vu-clip) 100%);
}
.mixs.clipped .mixs-read { color:var(--vu-clip); }
/* ── THE SCALE LADDER GETS OUT OF THE WAY BEFORE IT COLLIDES (round 5 · U8) ──
   Phil: "a minimum height where the meter scales are visible, otherwise they
   begin to overlap each other, and other parts of the UI".

   WHERE THAT IS, MEASURED rather than guessed: the five marks sit at fractions
   of the travel and the tightest pair is 15% apart, so 7px labels touch when
   the well falls below 7 / 0.15 = 47px. .mixs-body is that well plus a constant
   20 — the 5px lamp, the 11px readout and two 2px gaps — which puts the
   collision at 67 and the threshold on the round number above it.

   ON .mixs-body, THE STRIP'S COLUMN — not .mix-body, the DIALOG's row, which is
   what this was keyed to first and which the gate caught within one run: at a
   225px window the dialog's body still measured 192px while the strip's well
   had already collapsed to nothing, because the sends and inserts rows had
   grown into it. The ladder was shown over a zero-height well with four
   overlapping pairs. WHAT DECIDES WHETHER THE LADDER FITS IS THE WELL, and
   nothing else — so the query has to be asked of the box that is the well.
   That also makes it right for the rows growing rather than the window
   shrinking, which is the half of Phil's sentence about "other parts of the
   UI".

   A CONTAINER QUERY, not a JS class: hiding the ladder does not change the
   height being queried — .mixs-body takes its height from the strip's grid row
   — so there is no hysteresis to manage and nothing to re-measure on resize.

   THE CLIP LAMP'S SPACER GOES WITH IT. That ::before reserves the ladder's
   column so the lamp lands over the meter; with no ladder to reserve it for,
   keeping it would push the lamp 18px off the meter it reports on — the U5 bug,
   re-entering through a query. */
.mixs-body { container-type:size; container-name:mixbody; }
@container mixbody (height < 68px) {
  .mixs-scale { display:none; }
  .mixs-clip::before { display:none; }
}

/* ── A MARKED CHANNEL (round 5 · U3) ───────────────────────────────────────
   Phil: "should it be possible to shift click multiple channels and then right
   click and select 'add to group x'". The mark has to read at a glance across a
   rail of thirty-six, and it has to survive the group tint a member already
   wears -- so it is an INSET RING in the accent rather than a background, which
   would fight the block colour it sits inside and lose.

   box-shadow rather than outline: an outline is drawn outside the border box
   and would overlap the neighbouring strip across a 4px gap, which reads as two
   strips marked where one is. Inset keeps it inside the strip it is about.

   No transition: marking is a pointer gesture over many strips at once, and a
   fade on each would turn a shift-drag across eight channels into a smear. */
.mixs-marked {
  box-shadow:inset 0 0 0 1px var(--accent-fill);
}
.mixs-marked .mixs-num { background:var(--accent-fill); color:var(--on-accent); }

/* The rows the controls land in (A3 / A4). */
.mixs-pan, .mixs-ms, .mixs-out { flex:0 0 auto; min-width:0; display:flex; align-items:center; gap:2px; }
.mixs-out { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
.mixs-send-add { min-width:0; width:100%; padding-right:18px; }

/* ── THE UNDOCK BUTTON ON THE MIXER FOLD BAR ────────────────────────────────
   #pat-hdr-fold is a <button> spanning the whole header, so this cannot live
   inside it. It rides its own absolutely positioned layer at the same bottom
   edge, one z-index above, pointer-transparent except for the button itself —
   which costs the fold bar its rightmost ~22px as a fold target, the same
   deliberate trade the fold bar made with the splitter's leftmost 38px.

   The rail is STICKY for the reason .phfold-rail is: the header is often wider
   than the pane, and a control that scrolls out of sight is a control you
   cannot reach. */
.phtools { position:absolute; left:0; right:0; bottom:5px; height:11px;
           z-index:14; pointer-events:none; }
/* THE FALLBACK IS SMALL, and that is not decoration. --pat-view-w is published
   by a ResizeObserver on #pattern-area and stays UNSET until that pane has a
   width -- so until the first observation the usual `100%` fallback is the whole
   SCROLLABLE header, which on a 16-track song is ~2000px and puts this button
   (and, with the same fallback, the fold caret beside it) off the right of the
   screen. Measured at x=2018 in a 1280px window; 100vw only moved it to 1420,
   because the rail starts at the header's left edge and not at the viewport's.
   320px is never the right answer either, but it is always ON SCREEN, and the
   first observation replaces it within a frame. */
.phtools-rail { position:sticky; left:0; display:block;
                width:var(--pat-view-w, 320px); text-align:right;
                line-height:11px; }
.phundock { pointer-events:auto; font-size:9px; line-height:9px; padding:0 4px;
            vertical-align:top; }

/* ── THE MIXER WINDOW'S CONTROLS (phase 3, A3) ──────────────────────────────
   The docked strip's idioms at this window's own size. Deliberately the SAME
   shapes: a fader that is the meter's middle lane, a pan knob that is narrower,
   taller and in a neutral ink so two knobs on one strip are told apart by shape
   as well as colour. Nothing here restates a colour or a radius — every one
   comes from the tokens the docked controls already use.

   EVERY CONTROL RULE HERE IS PREFIXED `.mix-dialog`, and that is load-bearing
   rather than tidy. The dialog baseline `.sd-dialog input[type="range"]` — the
   very rule dialog-controls.js exists to guarantee — was setting `height:14px`
   on a VERTICAL fader inside a 298px meter well, and `width:9px; height:14px;
   margin-top:-6px` on its thumb. Measured: the fader drew 14px tall, and inline
   `height:auto` — an IDENTICAL declaration, winning only by being higher in the
   cascade — made it 296. The baseline is right for the horizontal sliders it was
   written for; a specialised control has to out-rank it rather than have it
   removed. The docked strips live outside .sd-dialog and never met this.

   AND THE SELECTOR NAMES THE ELEMENT AND ITS TYPE, not merely two classes. An
   ATTRIBUTE selector counts at CLASS level, so `.sd-dialog input[type="range"]`
   is (0,2,1) while `.mix-dialog .mixs-fader` is (0,2,0) — it still LOSES, and
   the fader measured 14px again with that prefix in place.
   `input[type="range"].mixs-fader` is (0,3,1), which wins without depending on
   source order. */
.mixs-meter > i:first-child { margin-right:calc(var(--vu-grab, 6px) / -2); }
.mixs-meter > i:last-child  { margin-left:calc(var(--vu-grab, 6px) / -2); }
.mix-dialog input[type="range"].mixs-fader {
  /* VERTICAL. writing-mode is the modern vertical range; direction:rtl makes it
     run bottom-to-top, which is what a fader means. */
  writing-mode:vertical-lr; direction:rtl;
  /* ITS OWN TOKEN NAME, not --vu-knob. That one is declared by .phch-fader and
     .phmst-fader with a different expression, and a custom property that means
     two things depending on which control you are inside is exactly what
     colour-tokens.js flags -- and what made a --vu-knob assertion pass for the
     master while the channel knob had been flattened. */
  /* ── A NARROW KNOB OVER A WIDE METER (round 2 · U2) ──────────────────────
     Phil: "The channel fader knob overlaps the entire level meter", and then:
     "the knob should be narrower so the meters can be seen behind it, but we
     probably also have room to make the meters wider than the docked view, so
     it's relative."

     So this is a RATIO, not a size. The well is 11 + 11 + 11 - 6 = 27px of
     content: two 11px bars, the fader's 11px box between them, less the 6px of
     grab the bars are pulled back over. A 9px knob is a third of that, centred
     on the lane — which leaves 9 of each 11px bar reading either side of it.
     It used to be `11px + --vu-grab` = 17px, spanning the whole well.

     THE HIT AREA IS UNCHANGED. The input's own box is still 11px wide and the
     full height of the well; only what is DRAWN got narrower, so nothing about
     grabbing the fader moved. */
  --mix-knob: 9px;
  flex:0 0 var(--d-mixs-bar-w); width:var(--d-mixs-bar-w);
  /* NOT height:100%. In a row flex container align-items:stretch already gives
     this its height, and `100%` against an indefinite parent resolves to the
     input's own INTRINSIC height — enormous for a vertical range, and the thing
     that once made a collapsed docked strip 654px tall. */
  display:block; height:auto; min-height:0; margin:0; padding:0;
  accent-color:var(--accent-fill);
  -webkit-appearance:none; appearance:none; background:transparent;
  /* The knob goes OVER the bars: the second bar is painted after the input in
     DOM order, so without a stacking position it would cover it. */
  position:relative; z-index:2; cursor:pointer;
}
.mix-dialog input[type="range"].mixs-fader:disabled { cursor:default; opacity:.45; }
.mix-dialog input[type="range"].mixs-fader::-webkit-slider-runnable-track {
  width:3px; background:var(--bg-rail);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
}
.mix-dialog input[type="range"].mixs-fader::-moz-range-track {
  width:3px; background:var(--bg-rail);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
}
/* A THIN BAR, 9x5, rather than the 17x7 block it was: what makes it read as a
   handle at that size is the rim and the radius, which it already had.

   STILL A FLAT --accent-ink FILL, and the first cut of this was not: it drew a
   grip line down the middle with a linear-gradient of --accent-ink and
   --border-control. track-fader.js went red, and it was right to. That gate
   exists because Magique renders --accent-fill and --accent-ink as a SURFACE and
   the FOREGROUND on it, and a knob that fills with the surface comes out "blue
   with a pink outline" (Phil, measured). Its rule is "every resting thumb that
   mentions an accent is background:var(--accent-ink) with a
   border:1px solid var(--border-control)" — a flat statement about a small solid
   control, and a gradient is not an exception to it, it is the thing it is
   watching for. The grip was decoration; the narrowing is the feature. */
.mix-dialog input[type="range"].mixs-fader::-webkit-slider-thumb {
  -webkit-appearance:none; appearance:none;
  /* THE SAME CENTRING EXPRESSION AS BEFORE, only with a smaller knob in it: it
     is linear in the knob's width, so the offset follows the size without the
     alignment being re-derived (and without a new 1px error being introduced
     beside the old one). */
  width:var(--mix-knob); height:5px; margin-left:calc((var(--mix-knob) - 1px) / -2);
  background:var(--accent-ink); border:1px solid var(--border-control);
  border-radius:var(--radius-control); cursor:pointer; box-sizing:border-box;
}
.mix-dialog input[type="range"].mixs-fader::-moz-range-thumb {
  width:var(--mix-knob); height:5px; box-sizing:border-box;
  background:var(--accent-ink); border:1px solid var(--border-control);
  border-radius:var(--radius-control); cursor:pointer;
}
.mix-dialog input[type="range"].mixs-panc {
  -webkit-appearance:none; appearance:none; background:transparent;
  width:100%; height:11px; margin:0; padding:0; cursor:pointer;
  accent-color:var(--pan-ink);
}
.mix-dialog input[type="range"].mixs-panc:disabled { cursor:default; opacity:.3; }
/* THE CENTRE DETENT IS DRAWN INTO THE RAIL, the same gradient the docked pan
   uses — dead centre is a value you cannot reliably hit by dragging, so the
   control has to show where it is. */
.mix-dialog input[type="range"].mixs-panc::-webkit-slider-runnable-track {
  height:3px; border-radius:var(--radius-control);
  background:linear-gradient(90deg,
    var(--bg-rail) 0, var(--bg-rail) calc(50% - 1px),
    var(--border-control) calc(50% - 1px), var(--border-control) calc(50% + 1px),
    var(--bg-rail) calc(50% + 1px), var(--bg-rail) 100%);
  border:1px solid var(--border-control);
}
.mix-dialog input[type="range"].mixs-panc::-moz-range-track {
  height:3px; border-radius:var(--radius-control);
  background:linear-gradient(90deg,
    var(--bg-rail) 0, var(--bg-rail) calc(50% - 1px),
    var(--border-control) calc(50% - 1px), var(--border-control) calc(50% + 1px),
    var(--bg-rail) calc(50% + 1px), var(--bg-rail) 100%);
  border:1px solid var(--border-control);
}
.mix-dialog input[type="range"].mixs-panc::-webkit-slider-thumb {
  -webkit-appearance:none; appearance:none;
  width:5px; height:11px; margin-top:-5px; box-sizing:border-box;
  background:var(--pan-ink); border:1px solid var(--border-control);
  border-radius:var(--radius-control); cursor:pointer;
}
.mix-dialog input[type="range"].mixs-panc::-moz-range-thumb {
  width:5px; height:11px; box-sizing:border-box;
  background:var(--pan-ink); border:1px solid var(--border-control);
  border-radius:var(--radius-control); cursor:pointer;
}
/* ── M AND S, IN THE DOCKED STRIP'S OWN CLOTHES (round 2 · U8) ─────────────
   Phil: "mute & solo buttons don't follow the same styling as docked mixer."
   They carried `.btn` and took the generic toggle look; they now carry
   `phch-btn phch-mute` / `phch-btn phch-solo` in the markup, so every rule that
   dresses the docked pair — the accent ground that means the channel PLAYS, the
   mute and solo fills that mean it does not — dresses these unchanged.
   NOTHING IS RESTATED HERE: the colours are not copied, the classes are shared,
   and a theme token changed in one place still reaches both.

   Only the SIZE is this window's, which is why these two declarations survive:
   a 76px strip splits its row between two buttons rather than three, and
   .phch-btns > .phch-btn (which does that job in the header) is scoped to a
   parent this window does not have. */
.mixs-ms { gap:3px; }
.mixs-m, .mixs-s {
  flex:1 1 0; min-width:0; font-size:9px; line-height:11px; padding:0;
  display:flex; align-items:center; justify-content:center;
}
/* …AND A DISABLED ONE STILL READS AS DISABLED. .btn carried this and .phch-btn
   never needed it, because no docked mute has ever been unavailable — a bus
   mute in this window is (its op is a sibling branch), and without this the
   greyed-with-a-reason convention had nothing to grey. */
.phch-btn:disabled { opacity:.3; cursor:default; }
/* The inserts line and the "where it goes" line are BUTTONS that look like
   text: they are the only two rows whose content is a sentence rather than a
   control, and boxing them would double the chrome in a 76px strip. */
/* ── THE INSERTS PANEL (round 3 · P2) ──────────────────────────────────────
   Phil: "That box needs to be an expandable/collapsible panel that can show
   everything ... They could maybe appear just by hovering the mouse over them,
   or they could have a caret to show/hide the controls."

   THE EXPANDED DETAIL IS A FLYOUT, and that is a measurement rather than a
   taste. The rail's rows are SHARED — that is the whole point of the subgrid —
   so a panel that expanded in flow would grow row 2 for every strip in the rail
   at once: ~76px stolen from the meter of all thirty-six columns because ONE
   strip's caret is open, and thirty-five of them would show an empty box that
   tall. The meter is the comparison instrument and the part that grows; a
   disclosure on one strip has no business shortening it everywhere.

   Drawn over the strip instead, anchored to its own row, so NOTHING in the rail
   moves when it opens — which is also the stricter reading of Phil's "the panel
   expands in place, it does not move other controls somewhere else".

   WHICH IS WHY .mixs-ins IS THE ONE ROW WITHOUT content-visibility (see .mixs):
   that property brings paint containment, and paint containment CLIPS — the
   flyout would be cut off at the 12px toggle it hangs from. */
.mixs-ins-panel { min-width:0; display:flex; flex-direction:column; gap:1px; }
/* The row is the flyout's containing block, and it has to out-rank the rows
   below it: `content-visibility` on .mixs-body makes that row a stacking
   context, and two auto-z-index stacking contexts paint in DOM order — which
   would put the meter over the panel.

   AND THE STRIP HAS TO FENCE THAT IN. `.mixs` is `position:relative;z-index:1`
   for this one reason: without a stacking context of its own, a z-index of 6 on
   a row competes at RAIL level and beats the sticky master strip's z-index:3 —
   so an ordinary strip's inserts row would paint over the master, and a pointer
   aimed at the master would land on it. Measured with elementFromPoint over the
   master's name: `null`, because the top layer there was another strip. */
.mixs-ins { position:relative; z-index:6; }
.mixs-ins-toggle { display:flex; align-items:center; gap:3px; width:100%; min-width:0;
  background:none; border:1px solid transparent; border-radius:var(--radius-control);
  color:var(--text-dim); font:inherit; font-size:9px; line-height:12px; padding:0 2px;
  text-align:left; cursor:pointer; }
.mixs-ins-toggle:hover { color:var(--accent-hi); border-color:var(--border-control); }
.mixs-ins-panel.pinned .mixs-ins-toggle { color:var(--accent-hi); border-color:var(--border-control); }
.mixs-ins-summary { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mixs-ins-caret { flex:0 0 auto; width:6px; height:6px; border-right:1px solid currentColor;
  border-bottom:1px solid currentColor; transform:rotate(-45deg); }
/* THE CARET TURNS FOR PINNED, NOT FOR OPEN (round 5). Phil: "The caret
   shouldn't change until it's actually clicked on." A caret that turns on
   hover says "this is how it will stay" about something that lasts as long as
   the pointer does -- and then turns back on its own, which reads as the
   control having been pressed and undone. Hovering shows the panel and leaves
   the caret alone; only the click moves it. */
.mixs-ins-panel.pinned .mixs-ins-caret { transform:rotate(45deg); }
/* THE FLYOUT — what HOVERING gives you. Nothing in the rail moves, which is
   the whole point of looking rather than deciding.

   NO GAP ABOVE IT (round 5). It had margin-top:1px, and Phil: "the hover
   doesn't work too well, it often disappears when moving the mouse to it."
   The flyout is a DOM CHILD of the panel, so entering it cannot fire
   pointerleave -- but a pointer crossing a 1px band that is neither box is
   over the STRIP for a frame, and that leave was real. Flush against the
   toggle there is no such band. The grace period in js/mixer-window.js is the
   other half: a pointer does not travel in straight lines. */
.mixs-ins-detail {
  position:absolute; top:100%; left:0; z-index:7;
  width:100%; box-sizing:border-box;
  display:flex; flex-direction:column; gap:2px;
  padding:3px; margin-top:0;
  background:var(--bg-menu); color:var(--text);
  border:1px solid var(--border-control); border-radius:var(--radius-control);
  box-shadow:0 2px 6px var(--shadow-mid);
}
/* THE MASTER IS PINNED TO THE WINDOW'S RIGHT EDGE and .mix-body is
   overflow:hidden, so a flyout hanging to the RIGHT of it would be cut off by
   the window itself. Its panel opens the other way. */
.mixs-master .mixs-ins-detail { left:auto; right:0; }
/* ── PINNED PUTS IT IN FLOW (round 5) ────────────────────────────────────
   Phil: "caret right - inserts hovering over mixer. caret down - inserts fixed
   down, rest of the strip moves down too ... expanding inserts should scale
   the meter heights - both individually and all at once."

   So the two states are two BOXES, not one box with a delay on it. Hovering is
   a flyout and disturbs nothing; pinning takes room, and the room comes from
   the meters. Round 4 had this as a rail-wide MODE (.mix-ins-all) on the
   argument that one panel must not shorten every meter — which was the right
   call while the caret was the only gesture and the wrong one once hover
   arrived to do the non-disturbing half.

   EVERY METER STILL SHORTENS BY THE SAME AMOUNT, including on the strips whose
   panel is shut. That is not a compromise: a meter is a comparison instrument,
   and the rail's rows are shared, so one number is the only answer that keeps
   them comparable. js/mixer-window.js publishes --mix-ins-h, the tallest
   pinned panel, exactly as it does for the sends row.

   `.mix-ins-all` is now simply "pin every strip", so it needs no rules of its
   own beyond forcing the class on. */
.mixs-ins-panel.pinned .mixs-ins-detail {
  position:static; width:auto; margin-top:2px; box-shadow:none;
  background:var(--bg-inset);
  /* IT SCROLLS WHEN IT IS CAPPED. js/mixer-window.js will not let a pinned
     panel take the meters below a floor, so on a short window the panel is the
     one that gives — and a panel you scroll is usable where a meter that lies
     about the level is not. min-height:0 so it may actually shrink inside the
     grid row rather than forcing it open. */
  min-height:0; overflow-y:auto;
}
/* ...and a pinned panel does not need the master's right-edge correction. */
.mixs-master .mixs-ins-panel.pinned .mixs-ins-detail { left:auto; right:auto; }
/* ── A CHAIN ROW AT STRIP WIDTH (round 5) ─────────────────────────────────
   Phil: "all you can see are the bypass and GUI buttons, there's no plugin
   name. Space is tight, so maybe the buttons could appear when you hover over
   a plugin name."

   MEASURED before changing anything, on an 80px row: bypass 24.6 + controls 21
   + name 0 + size 48.8 + type 37.8. The row's children wanted 132px of 80, and
   the NAME is the one that flexes — so the name is what got crushed to nothing
   while two provenance chips kept their full width.

   THE ROWS ARE SHARED with the Buses dialog and the plugin picker (js/wam-
   plugins.js renderChainInto builds all three), so this is scoped to the strip
   rather than changed in the builder. A 220px dialog row has room for the
   author and the chunk size; an 80px strip does not, and what it must show
   instead is which plugin this is.

   THE BUTTONS ARE COLLAPSED, NOT REMOVED. width:0 with overflow hidden keeps
   them in the tab order, and :focus-within brings them back — so a keyboard
   reaches what a pointer reveals on hover. display:none would take them out of
   the document and make the row unreachable without a mouse. */
.mixs-fx-chain .inr > .insize,
.mixs-fx-chain .inr > .intype,
.mixs-fx-chain .inr > .wam-prov { display:none; }
.mixs-fx-chain .inr > .inname { flex:1 1 auto; min-width:0; overflow:hidden;
                                text-overflow:ellipsis; white-space:nowrap; }
.mixs-fx-chain .inr > .fx-byp,
.mixs-fx-chain .inr > .fx-ctrl {
  flex:0 0 auto; width:0; min-width:0; padding-left:0; padding-right:0;
  border-left-width:0; border-right-width:0; overflow:hidden;
}
.mixs-fx-chain .inr:hover > .fx-byp,
.mixs-fx-chain .inr:hover > .fx-ctrl,
.mixs-fx-chain .inr:focus-within > .fx-byp,
.mixs-fx-chain .inr:focus-within > .fx-ctrl {
  width:auto; padding-left:revert; padding-right:revert;
  border-left-width:revert; border-right-width:revert;
}
/* WHOSE INSERTS THESE ARE. A track strip's panel shows its GROUP's stage — MED
   gives a track one effect group and the chain belongs to the group — so the
   flyout names it rather than letting a channel look like it owns a plugin. */
.mixs-ins-who { color:var(--accent-hi); font-size:8px; font-weight:bold;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* ONE COLUMN, so the flyout never has to be wider than the strip it belongs to.
   Two columns at a 96px strip gave each select ~43px, and "Cross echo" ellipsised
   to "Cro…" — a control that cannot say what it is set to. Widening the flyout
   past the strip was the other way out and it costs more than it buys: it would
   have to out-rank its neighbours AND the sticky master to be visible at all. */
/* ── TWO COLUMNS, SO THE RATE STOPS BEING A FULL-WIDTH BOX (round 5) ──────
   Phil: "The rate box is far bigger than it needs to be. Could fit spinner
   arrows in there. depth and width could show dropdown arrow."

   It was one column, so a five-digit number got the whole strip. TYPE keeps the
   full width because its options are words ("Normal echo"); RATE and DEPTH
   share a row because both are short; WIDTH keeps the width because a lone
   half-width control beside a gap reads as something missing. Three rows
   instead of four, which is also what "optimises the space taken up by the
   inserts" asks for. */
.mixs-fx-native { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr));
                  gap:2px 3px; min-width:0; }
.mixs-fx-native > label { grid-column:span 2; }
.mixs-fx-native > label.mixs-fx-half { grid-column:span 1; }
.mixs-fx-native label { display:flex; flex-direction:column; gap:1px; min-width:0; color:var(--text-dim); font-size:7px; }
.mixs-fx-native select, .mixs-fx-native input { min-width:0; width:100%; height:16px; padding:0 1px;
  background:var(--bg-inset); color:var(--text); border:1px solid var(--border-control); font:inherit; font-size:8px; }
/* THE ARROWS THE CONTROLS ALREADY HAVE. Nothing here suppressed them, and the
   box being half-width now is what gives the spinner somewhere to sit. Asking
   for the appearance explicitly says it is wanted, so a later
   `appearance:none` swept across the app cannot take it silently.

   NO ::-webkit-inner-spin-button RULE. Forcing one to appearance:auto with
   opacity:1 made the spinner render at its own natural size and drag the input
   from 16px to 62px, which took the whole panel with it -- measured. The
   default spinner already honours the input's height; the pseudo-element does
   not. */
/* FLEX-BASIS IS AN AXIS, AND THE AXIS IS THE CONTAINER'S. `.sd-dialog
   input[type="number"]` sets `flex:0 0 62px` — a WIDTH, in a dialog whose rows
   run horizontally — and the Mixer window is a .sd-dialog, so every number
   input in it inherits that. Inside these labels, which are flex COLUMNS, the
   same declaration is a 62px HEIGHT: measured, the RATE box was 62px tall
   inside a 71px row, and neither a `height:16px` rule nor an inline one could
   move it, because basis wins on the main axis.

   That was most of what Phil meant by "the rate box is far bigger than it needs
   to be" — the other half was its full width, which the two columns above fix.
   Stated explicitly here so the dialog base cannot decide a main-axis size it
   was not written for. */
.mixs-fx-native input[type="number"],
.mixs-fx-native select { flex:0 0 auto; }
.mixs-fx-native input[type="number"] { appearance:auto; -webkit-appearance:auto; }
.mixs-fx-native select { appearance:auto; -webkit-appearance:auto; padding-right:2px; }
.mixs-fx-native .disabled { opacity:.45; }
.mixs-fx-chain { display:flex; flex-direction:column; gap:1px; min-width:0; }
.mixs-fx-plugin { display:flex; gap:3px; align-items:baseline; min-width:0;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text-hi); font-size:8px; }
/* THE POSITION IN THE CHAIN, because an insert chain is an ORDER: "Reverb, EQ"
   and "EQ, Reverb" are different sounds, and the old single button said neither. */
.mixs-fx-plugin > b { flex:0 0 auto; color:var(--text-dim); font-weight:normal;
  font-variant-numeric:tabular-nums; }
.mixs-fx-plugin.bypassed { opacity:.5; text-decoration:line-through; }
.mixs-fx-chain.empty { color:var(--text-dim); font-size:8px; }
.mixs-ins-picker { width:100%; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  background:none; border:1px solid transparent; color:var(--accent-ink); font:inherit; font-size:8px;
  padding:0 1px; text-align:left; cursor:pointer; }
.mixs-ins-picker:hover { border-color:var(--border-control); color:var(--accent-hi); }
.mixs-insbtn, .mixs-outbtn {
  display:block; width:100%; text-align:left; box-sizing:border-box;
  background:none; border:1px solid transparent; border-radius:var(--radius-control);
  color:var(--text-dim); font-family:inherit; font-size:9px; line-height:12px;
  padding:0 2px; cursor:pointer;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.mixs-insbtn:hover, .mixs-outbtn:hover { color:var(--accent-hi); border-color:var(--border-control); }
/* OUT OF THE MASTER SUM is the one state here that means "you will not hear
   this in the mixdown", which is the same register as a dropped send — so it
   takes the same ink the Effects list gives that fact. */
.mixs-outbtn.silent { color:var(--alert-hi); }
.mixs-badge { flex:0 0 auto; font-size:8px; opacity:.75; }
/* WHAT THE ENGINE HAD TO DELAY (signal routing phase 5, U4), under the
   where-it-goes button that caused it. --text-dim: it is a PROPERTY of the
   route, not a refusal and not a warning — the same register .fx-send-fb-note
   takes for the same class of fact. The strip is 76px, so it wraps rather than
   being clipped; it is absent entirely when there is nothing to hold back, so
   the extra line is only ever on a strip that is carrying something. */
.mixs-align { display:block; color:var(--text-dim); font-size:8px; line-height:1.25;
              overflow-wrap:anywhere; }
/* THE .medz MARK (U5), on a strip whose level has no home in the .med file.
   The badge SLOT already exists and already sits beside the name; this is that
   slot used on a TRACK strip, which is the one kind that never had bits to put
   in it. --accent-ink, not --alert-hi: the fader works, and the value is kept —
   it is a fact about where, not a refusal. */
.mixs-medz { color:var(--accent-ink); }

/* ── SENDS, ON THE STRIP (phase 3, A4) ──────────────────────────────────────
   The Effects dialog owns the destination side of the matrix and has a 220px
   row to lay it out in. A strip is 76px, so the same row is stacked: the
   destination on its own line, the controls under it. The dialog stays the
   place the full matrix is edited; this is the view you need while balancing,
   because the thing you are listening to is the SOURCE. */
.mixs-send { display:flex; flex-direction:column; gap:1px; margin-bottom:2px; }
.mixs-send-dst {
  display:block; width:100%; text-align:left; box-sizing:border-box;
  background:none; border:1px solid transparent; border-radius:var(--radius-control);
  color:var(--text-dim); font-family:inherit; font-size:9px; line-height:11px;
  padding:0 2px; cursor:pointer;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.mixs-send-dst:hover { color:var(--accent-hi); border-color:var(--border-control); }
.mixs-send-row { display:flex; align-items:center; gap:1px; }
.mix-dialog input[type="number"].mixs-send-lvl {
  flex:1 1 0; min-width:0; width:100%; box-sizing:border-box;
  font-size:9px; line-height:11px; padding:0 1px; text-align:right;
  font-variant-numeric:tabular-nums;
  -moz-appearance:textfield;
}
/* SEPARATE RULES, not a selector list. An invalid selector invalidates the WHOLE
   list, so grouping vendor pseudo-element names silently drops the rule in both
   engines — the same reason the .sd-dialog range baseline splits its own. */
.mix-dialog input[type="number"].mixs-send-lvl::-webkit-outer-spin-button { -webkit-appearance:none; margin:0; }
.mix-dialog input[type="number"].mixs-send-lvl::-webkit-inner-spin-button { -webkit-appearance:none; margin:0; }
.mixs-send-pre, .mixs-send-fb, .mixs-send-mute, .mixs-send-del {
  flex:0 0 auto; font-size:8px; line-height:11px; padding:0 2px; min-width:0;
}
/* A MUTED OR -60 dB SEND IS STILL IN THE FILE, and the row says so by receding
   rather than by leaving — "silent is free" is the whole basis of the matrix's
   cost, and a row that vanished when muted would hide the thing you muted. */
.mixs-send.silent .mixs-send-dst { opacity:.55; }
.mixs-send-note { font-size:8px; color:var(--text-dim); padding:0 2px; }
/* THE ENGINE'S OWN REASON, on the row it happened to. A send the model accepted
   and the engine refused is the exact failure this phase exists to avoid, so it
   is drawn in the alert ink rather than left to the console. */
.mixs-send-drop { font-size:8px; color:var(--alert-hi); padding:0 2px; white-space:normal; }
.mix-dialog select.mixs-send-add {
  display:block; width:100%; box-sizing:border-box;
  font-size:9px; line-height:12px; padding:0 1px;
}

/* ── THE HELP WINDOW (js/help-viewer.js, 2026-09-26) ─────────────────────────
   help/*.md and the settings reference, drawn in the app. A definite size and
   resize:both, like the Mixer window: long pages want room, and the reader
   decides how much. Everything takes the theme's tokens, so a page reads as
   part of whichever theme is on. */
.sd-dialog.help-dialog {
  width:min(720px, calc(94vw / var(--ui-scale)));
  height:min(620px, calc(88vh / var(--ui-scale)));
  min-width:320px; min-height:200px;
  resize:both;
}
.help-bar { display:flex; align-items:center; gap:6px; padding:6px 10px;
  border-bottom:1px solid var(--border-control); }
.help-bar .btn { padding:2px 8px; }
.help-bar #help-jump { flex:1 1 auto; min-width:0; max-width:none; }
.help-bar #help-jump[hidden] { display:none; }
.help-raw { margin-left:auto; flex:0 0 auto; font-size:10px; color:var(--text-dim); text-decoration:none; }
.help-raw:hover, .help-raw:focus-visible { color:var(--accent-hi); text-decoration:underline; }
.help-body { flex:1 1 auto; min-height:0; overflow:auto; padding:6px 18px 18px;
  font-size:12px; line-height:1.5; color:var(--text); }
.help-body:focus { outline:none; }
.help-body h2, .help-body h3, .help-body h4, .help-body h5, .help-body h6 {
  color:var(--text-hi); line-height:1.25; margin:1.1em 0 .4em; }
.help-body h2 { font-size:17px; border-bottom:1px solid var(--border-control); padding-bottom:3px; }
.help-body h3 { font-size:14px; }
.help-body h4, .help-body h5, .help-body h6 { font-size:12px; }
.help-body > h2:first-child { margin-top:.4em; }
.help-body p { margin:.5em 0; }
.help-body ul, .help-body ol { margin:.4em 0; padding-left:1.6em; }
.help-body li { margin:.15em 0; }
.help-body li > ul, .help-body li > ol { margin:.15em 0; }
.help-body a { color:var(--accent-ink); }
.help-body a:hover, .help-body a:focus-visible { color:var(--accent-hi); }
.help-body code { font-family:var(--font-pattern); font-size:11px; padding:0 3px;
  background:var(--bg-inset); border-radius:var(--radius-control); }
.help-body pre { margin:.6em 0; padding:6px 8px; overflow:auto; background:var(--bg-inset);
  border:1px solid var(--border-control); border-radius:var(--radius-control); }
.help-body pre code { padding:0; background:none; }
.help-body blockquote { margin:.6em 0; padding:2px 10px; color:var(--text-dim);
  border-left:3px solid var(--border-hi); }
.help-body hr { border:0; border-top:1px solid var(--border-control); margin:1em 0; }
.help-body .help-table { overflow-x:auto; margin:.6em 0; }
.help-body table { border-collapse:collapse; font-size:11px; }
.help-body th, .help-body td { border:1px solid var(--border-control); padding:3px 7px; vertical-align:top; text-align:left; }
.help-body th { color:var(--text-hi); background:var(--bg-inset); }
.help-body .help-note { color:var(--text-dim); }
