        .mode-hidden { display: none; }
        :root {
            --pad-size: 48px;
            --grid-col-gap: 4px;
            --grid-width: calc(7 * var(--pad-size) + 6 * var(--grid-col-gap));
            --ot-bg: #19110b;
            --ot-panel: #1e130e;
            --ot-text: #ffd3bf;
            --ot-muted: #a89080;
            --ot-accent: #00cae6;
            /* User-selected highlight color for INTERACTIVE active states (active
               chord pads, harp-selected pads). Distinct from --ot-accent which
               body[data-phosphor=*] overrides to match phosphor hue. The user's
               uiHighlightColor pick (cyan/magenta/yellow/orange/lime/pink/white)
               must win even in green/amber/red phosphor — so this var lives at
               :root only, never overridden by body[data-phosphor]. Updated by
               window._applyUiHighlightColor in mix-tab.js. */
            --ot-ui-highlight: #00cae6;
            /* Warning-tier color for privacy/recording-awareness surfaces:
               #mic-fab (base + active), #audio-fab.suspended, .recording-indicator.
               HARDCODED amber — NEVER overridden by _applyUiHighlightColor or
               body[data-phosphor=*] blocks. Using --ot-accent here is wrong
               because _applyUiHighlightColor writes the user's chosen color into
               --ot-accent, which collapses the warning-tier separation (bug
               reported 2026-05-02 — mic-fab looked identical to .harp-selected
               when user had magenta highlight). See phosphor-exceptions.js. */
            --ot-warning: #c8a858;
            --ot-depth1: #c1a593;
            --ot-depth2: #9a7560;
            --ot-depth3: #7a5540;
            --ot-empty: #130d08;
            --ot-harpLine: #3f2c1c;
            --ot-surface: #241810;
            --ot-borderMid: #6a5040;
            --ot-textMid: #c0a898;
            --ot-textSoft: #c0a898;
            --ot-microBreakdown: #2a1e14;
            --ot-voiceS: #e86050;
            --ot-voiceA: #58b880;
            --ot-voiceT: #00cae6;
            --ot-voiceB: #e89050;
            --ot-fxShape: #9a8868;
            --ot-fxFilter: #688870;
            --ot-fxAmp: #b07858;
            --ot-fxLpg: #a86858;
            --ot-fxDelay: #5878a0;
            --ot-fxReverb: #988068;
            --ot-fxGlide: #a89070;
            --ot-fxSpatial: #a89870;
            --ot-fabGreen: #2a8a40;
            --ot-fabRed: #a03020;
            /* Category colors — match categoryColor() keys in ui.js */
            --ot-cat-shape: #7ab880;
            --ot-cat-filter: #58b0b8;
            --ot-cat-amp: #c88060;
            --ot-cat-lpg: #c06880;
            --ot-cat-pitch: #a878b8;
            --ot-cat-tuning: #68a898;
            --ot-cat-delay: #5878b8;
            --ot-cat-reverb: #7858b0;
            --ot-cat-spatial: #c8a858;
            /* Info bar secondary colors */
            --ot-infoCbw: #8899aa;
            --ot-infoCbwParallel: #7a9988;
            /* Panel-level surface tones (for modals / cards) */
            --ot-panelAlt: #2a2418;
            --ot-panelHover: #3a3020;
            /* Text on accent/active background (bright buttons) */
            --ot-textOnAccent: #ffffff;
            /* --ot-accentGlow, --ot-accentGlowDim, --ot-accentGlowSoft removed (#glow-removal) */
            /* Slider thumb accent tints — distinct per slider family.
               These are the explicit per-slider hexes used by slider-registry.js
               accentColor schemas. Kept separate from --ot-fx* (which carry their
               own roles in the FX category palette) so each slider family stays
               visually identifiable when phosphor is OFF. Under phosphor, the
               nav-trace color overrides via _resolveSliderAccentCRT().
               #hex-to-css-var-chunk1 (2026-05-05) */
            --ot-slider-dub: #4868a0;             /* dub delay sliders (4) */
            --ot-slider-comp: #b07858;            /* compressor + envelope ADSR + lofi (11) */
            --ot-slider-level: #a89870;           /* master + spatial level/width/haas/pan (12) */
            --ot-slider-pitch: #907858;           /* glide / vibrato / sub-osc / microtiming (9) */
            --ot-slider-unison: #688870;          /* unison detune (1) */
            --ot-slider-reverb: #786048;          /* freeverb send/room/damp (3) */
            --ot-slider-sample-trim-start: #4caf50;  /* sample trim start (1) */
            --ot-slider-sample-pitch: #c88060;       /* sample pitch coarse/fine (2) */
            --ot-slider-sample-trim-end: #f44336;    /* sample trim end (1) */
            --ot-slider-sample-loop-start: #b060c0;  /* sample loop start / scan / jitter (3) */
            --ot-slider-sample-loop-end: #8040a0;    /* sample loop end (1) */
            --ot-slider-tickWarn: #c06060;        /* psychoacoustic warning ticks (33) — slider-registry tick danger zones */
            --ot-fxTransient: #c8a050;            /* transient generator section accent (6) — voice-tab fxSectionLabel + fxRow */
            --ot-fxSample: #7a6848;               /* sample tab section accent (14) — sample-tab fxSectionLabel + style.color labels + 1 model-tab Sample label */
            /* chunk 4 (PR #2641 follow-on): JS inline-style sites — perf-diag warnings,
               loop-tab control buttons, ui.js misc labels. Each var holds the exact
               pre-migration hex; no phosphor block override required because callsites
               are JS inline strings (not CSS rule properties), so the phosphor cascade
               is bypassed by design — those sites already have JS-side CRT gating
               (e.g. _loopCtrlAccent / cmpHotColor) that runs before the var resolves. */
            --ot-textGray: #888888;               /* neutral gray for misc labels (4) — ui.js scalaLabel + perf dismiss + loop-tab clear-all + .xcs-label CSS rule */
            --ot-beatsMuted: #887860;             /* unison beats display: muted/fused-into-roughness band (1) — ui.js:1856 */
            --ot-fxGlideBright: #b09878;          /* WCAG AA fix on --ot-bg: 6.79 contrast vs Glide section heading; replaces #907858 (4.45 fail) (1) — ui.js:2860 */
            --ot-warnRed: #ff4040;                /* perf-diag warning tokens: drops/voc/cmp/werr/state red (6) — ui.js perf row */
            --ot-warnOrange: #ffa020;             /* perf-diag warning tokens: cmp-mid + buf-drift orange (2) — ui.js perf row */
            --ot-fabError: #c83030;               /* FAB CPU-glitch error background fallback (CRT off) (1) — ui.js:6122 */
            --ot-fabRunning: #22aa66;             /* FAB ctx.state==='running' background fallback (CRT off) (1) — ui.js:6136 */
            --ot-fabIdle: #cc8800;                /* FAB idle (suspended) background fallback (CRT off) (1) — ui.js:6136 */
            --ot-warnRing: #c05050;               /* clear-all-data 5s warning boxShadow ring (2) — ui.js:4036 */
            --ot-loopArm: #c08030;                /* loop-tab Arm-Next button + sync armed-state fallback (2) — loop-tab.js:182,296 */
            --ot-loopStop: #c03030;               /* loop-tab Stop-All button + sync recording-state fallback (2) — loop-tab.js:200,299 */
            --ot-deleteRed: #c0392b;              /* sample-tab swipe-delete button background (1) — sample-tab.js:3440 */
            --ot-textWhite: #ffffff;              /* sample-tab delete-button text on red background (1) — sample-tab.js:3441 */
            /* chunk 6 (PR #refactor/hex-chunk-6): Key/Scale/Chord modal grays + phosphor
               text-on-accent black + viz dark backgrounds + cloud-sync status pill colors.
               Each var holds the exact pre-migration hex; no phosphor block override added
               because callsites are either modal-only (xcs panel is closed under phosphor
               by default), already inside body[data-phosphor] selectors (--ot-textOnPhosphor),
               or JS inline strings on canvas-adjacent surfaces that bypass the cascade. */
            --ot-xcsBtnText: #dddddd;             /* key/scale/chord modal idle button text (1) — oldtime.css:815 .xcs-btn */
            --ot-xcsShuffleText: #999999;         /* key/scale/chord modal shuffle button text (1) — oldtime.css:838 .xcs-shuffle-btn */
            --ot-xcsCloseIdle: #666666;           /* key/scale/chord modal close button idle (1) — oldtime.css:856 .xcs-close-btn */
            --ot-xcsCloseActive: #cccccc;         /* key/scale/chord modal close button active (1) — oldtime.css:865 .xcs-close-btn:active */
            --ot-textOnPhosphor: #000000;         /* black text on bright phosphor accent fills (4) — oldtime.css:928 #mic-fab.active + :4240 selector-buttons + :4292 key-shift-badge + :4301 audio-fab */
            --ot-vizMeterBg: #111111;             /* mix VU + comp curve + velocity-debug dark canvas/panel bg (3) — mix-tab.js:12,172,386 */
            --ot-vizDebugBorder: #333333;         /* velocity-debug display border (1) — mix-tab.js:386 */
            --ot-vizPanelDark: #1a1612;           /* spatial 3D + 2D wrapper bg (matches palette.bg fallback) (2) — spatial-tab.js:13,26 */
            --ot-vizSampleVuBg: #1a1a1a;          /* sample tab record VU meter wrapper bg (1) — sample-tab.js:322 */
            --ot-statusError: #c85050;            /* settings cloud-sync status pill: error tone (1) — settings-tab.js:2614 */
            --ot-statusOk: #48a888;               /* settings cloud-sync status pill: ok tone (1) — settings-tab.js:2618 */
            /* chunk 8 (2026-05-08): phosphor bright + CRT hues — green/amber/red traces,
               glows, dims from CRT_* objects in engine.js. Enables footer VU meters,
               breath-meter, nav-trace tones under phosphor. JS code uses fallbacks for
               _resolveSliderAccentCRT() canvas drawing; phosphor blocks override as needed. */
            --ot-crtGreenTrace: #33ff33;          /* CRT_GREEN.trace for green phosphor (3) — mix-tab.js nav trace / tests comparisons */
            --ot-crtAmberTrace: #ffaa00;          /* CRT_AMBER.trace for amber phosphor (32) — mix-tab.js footer VU / breath-meter / nav trace */
            --ot-crtRedTrace: #ff3333;            /* CRT_RED.trace for red phosphor (36) — mix-tab.js footer VU / breath-meter / nav trace */
            --ot-crtAmberGlow: #cc7700;           /* CRT_AMBER.glow for amber phosphor (6) — tests + engine.js comment */
            --ot-crtRedGlow: #cc0000;             /* CRT_RED.glow for red phosphor (7) — tests + engine.js comment */
            --ot-crtAmberDim: #664400;            /* CRT_AMBER.dim for amber phosphor (6) — tests + engine.js comment */
            --ot-crtRedDim: #660000;              /* CRT_RED.dim for red phosphor (6) — tests + engine.js comment */
            --ot-amberText: #e69900;              /* amber phosphor text tone for status/accent in non-off-theme (20) — phosphor amber CSS blocks */
            /* chunk 9 (2026-05-08): phosphor theme semantic bridge — bright/soft variants
               across green/amber/red/grey. Enables reuse of canonical brightness pairs in
               phosphor blocks without repeating hex values. These are the "working colors"
               that define visual hierarchy in each phosphor theme. */
            --ot-green-bright: #2ee62e;           /* primary text/highlight in green theme (11 sites) */
            --ot-green-soft: #1fa01f;             /* secondary/soft text in green theme (15 sites) */
            --ot-amber-bright: #e69900;           /* primary text/highlight in amber theme (1 site) */
            --ot-amber-mid: #cc8800;              /* midtone text in amber theme (11 sites) */
            --ot-amber-soft: #a07000;             /* secondary/soft text in amber theme (12 sites) */
            --ot-red-bright: #e62e2e;             /* primary text/highlight in red theme (1 site) */
            --ot-red-soft: #a01f1f;               /* secondary/soft text in red theme (12 sites) */
            --ot-grey-bright: #b8b8b8;            /* primary text in grey theme (1 site) */
            --ot-grey-soft: #808080;              /* secondary/soft text in grey theme (14 sites) */
            --slider-row-gap: 8px;
            /* #rhythm: 8px base vertical rhythm across FX tabs.
               All FX spacing should be 0, 4 (sub-unit for header/desc pairs),
               8, 12 (after-desc breathing), 16, or 24 — no ad-hoc values. */
            --spacing-unit: 8px;
            --spacing-sub: 4px;
            /* #rhythm v2: section header rhythm + description→row breathing.
               These are composed with the parent flex gap (8px) to hit final visuals:
               - section header above: parent 8 + extra 16 = 24px (clear break)
               - section header below first row: parent 8 (tight intro)
               - slider → param-desc: parent 8 - 4 = 4px (tight pair)
               - param-desc → next row: parent 8 + 4 = 12px (breathing room)
               - envelope viz → next slider: parent 8 + 4 = 12px */
            --gap-row: 8px;
            --gap-pair: 4px;
            --gap-section: 24px;
            --gap-after-desc: 12px;
            /* #footer-overlap-2026-04-18: single source of truth for footer
               stack height. Used by body padding-bottom, #fab-bar height, and
               .main-tab-panel.active max-height so they stay in sync.
               156px = FX tab bar (40px) + #fab-bar-row (116px)
               Add env(safe-area-inset-bottom) wherever footer height is used
               so home-indicator padding on iPhone is respected.
               Change in ONE place — everything that references it follows. */
            --footer-base-h: 156px;
            --footer-total-h: calc(var(--footer-base-h) + env(safe-area-inset-bottom, 0px));
            /* Extra breathing room between last scrollable content and the
               footer's top edge so nothing sits flush against the tab bar. */
            --footer-scroll-gap: 16px;
            /* Landscape footer height (compact). Overridden in landscape media query. */
            --landscape-footer-h: 36px;
        }

        html {
            margin: 0;
            padding: 0;
            /* #1118: allow pull-to-refresh on html + body */
            overscroll-behavior-y: auto;
            /* #android-fix: touch-action consolidated on body (manipulation) —
               removes redundant pan-y here that conflicted with body's manipulation */
        }

        * {
            -webkit-user-select: none;
            user-select: none;
            -webkit-touch-callout: none;
            -webkit-tap-highlight-color: transparent;
            box-sizing: border-box;
        }

        body {
            background-color: var(--ot-bg);
            color: var(--ot-text);
            font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, monospace;
            display: flex;
            flex-direction: column;
            align-items: center;
            min-height: 100dvh;
            margin: 0;
            padding: 0;
            /* #1363: disable scrolling — fixed layout with fixed footer */
            overflow: hidden;
            height: 100dvh;
            overscroll-behavior: none;
            touch-action: manipulation;
            -webkit-touch-callout: none;
            -webkit-user-select: none;
            user-select: none;
            -webkit-tap-highlight-color: transparent;
            /* #footer-overlap-2026-04-18: use --footer-total-h instead of
               hardcoded 156px. The @supports block below adds safe-area via
               the variable itself — no duplicate override needed. */
            padding-bottom: var(--footer-total-h);
        }
        input, textarea {
            -webkit-user-select: auto;
            user-select: auto;
        }

        /* Main content wrapper: transparent in portrait, grid cell in landscape */
        #main-content {
            display: flex;
            flex-direction: column;
            align-items: center;
            width: 100%;
        }

        /* --- CUSTOM SLIDER THUMB (outline ring) ---
           #thumb-contrast (2026-04-19): use --ot-depth1 (not --ot-depth2) for the
           default thumb border. depth2 (#9a7560 in off theme, #1fa01f in green) has
           only 3.2 WCAG contrast on the harpLine track in the default off theme,
           which made thumbs nearly invisible. depth1 raises that to 5.7+ across
           every theme (off/green/amber/red/grey/light) without touching the phosphor
           override path (which sets inline --thumb-color to the palette trace and
           overrides any CSS default). */
        input[type="range"] {
            --thumb-color: var(--ot-depth1);
            -webkit-appearance: none;
            appearance: none;
            background: transparent;
            cursor: pointer;
            -webkit-touch-callout: none;
            -webkit-user-select: none;
            user-select: none;
            /* #android-fix: pan-y allows vertical scrolling in FX panel
               while still capturing horizontal slider drags */
            touch-action: pan-y;
        }

        input[type="range"]::-webkit-slider-runnable-track {
            height: 8px;
            background-color: var(--ot-harpLine);
            border-radius: 4px;
        }

        input[type="range"]::-moz-range-track {
            height: 8px;
            background-color: var(--ot-harpLine);
            border-radius: 4px;
        }

        input[type="range"].has-ticks::-webkit-slider-runnable-track {
            background-image: var(--tick-gradient);
            background-repeat: no-repeat;
            background-size: 100% 100%;
        }

        input[type="range"].has-ticks::-moz-range-track {
            background-image: var(--tick-gradient);
            background-repeat: no-repeat;
            background-size: 100% 100%;
        }

        input[type="range"]::-webkit-slider-thumb {
            -webkit-appearance: none;
            appearance: none;
            width: 22px;
            height: 22px;
            border-radius: 50%;
            background: var(--ot-panel);
            border: 2px solid var(--thumb-color);
            margin-top: -7px;
        }

        input[type="range"]::-moz-range-thumb {
            width: 22px;
            height: 22px;
            border-radius: 50%;
            background: var(--ot-panel);
            border: 2px solid var(--thumb-color);
            box-sizing: border-box;
        }

        /* --- PREVENT iOS AUTO-ZOOM ON INPUT FOCUS --- */
        input, select, textarea {
            font-size: 16px;
        }

        /* --- HEADER / SELECTORS --- */
        /* #1188: key flush left, mode flush right */
        #header {
            display: flex;
            flex-direction: row;
            flex-wrap: nowrap;
            align-items: flex-start;
            justify-content: space-between;
            gap: var(--grid-col-gap);
            width: 100%;
            max-width: var(--grid-width);
            padding: 4px 0;
            flex-shrink: 0;
            /* #1106/#1485: pan-y allows vertical scrolling from header area
               so Play tab content below the fold (glide sliders, assigned FX)
               is reachable via touch. Was touch-action:none which blocked all
               scroll initiation from the 104px header. Horizontal gestures
               still blocked (no key/mode button accidental scroll). */
            touch-action: pan-y;
            /* #1112: sticky again — #1111 removed overflow:hidden which was the clipping bug */
            position: sticky;
            top: 0;
            z-index: 20;
            background: var(--ot-bg);
        }
        /* #1106/#1485: match header pan-y — allow vertical scroll from selectors */
        /* 2026-05-05: .selector-row removed from comma list (PR #2612).
           No HTML/JS callers; the class never made it onto a real DOM node. */
        #key-selector,
        #mode-selector,
        .selector-group {
            touch-action: pan-y;
        }

        /* #1195: flex: 0 0 auto — basis from content (the 48px grid),
           never shrink, never grow. flex: 0 alone is 0 1 0% which
           lets the browser collapse the group below content width. */
        .selector-group {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 0;
            flex: 0 0 auto;
        }

        /* 2026-05-04 dead-CSS-cleanup: .selector-label rule removed.
           The Key/Mode <span class="selector-label"> elements were
           hidden via display:none in both portrait and landscape; per
           #1187 they were dead UX (selector buttons self-describe).
           HTML spans removed from index.html in the same change. */

        /* 2026-05-04 dead-CSS-cleanup: #mode-name-display removed —
           HTML element was deleted long ago, the CSS rule (portrait + a
           landscape `display:none !important` override) was orphaned.
           Companion JS in ui.js/presets.js removed in the same change. */

        /* 2026-05-05 dead-CSS sweep (PR #2612): .selector-row block removed.
           No HTML element ever carried this class; rule was a leftover from
           an early grouping experiment. */

        /* --- KEY GRID: 3×2 grid of 48px squares --- */
        #key-selector {
            display: grid;
            grid-template-columns: repeat(3, var(--pad-size));
            grid-auto-rows: var(--pad-size);
            gap: var(--grid-col-gap);
            width: auto;
        }

        #key-selector .sel-btn {
            width: 48px;
            height: 48px;
            aspect-ratio: 1;
            min-height: 48px;
            padding: 0;
            padding-top: 1px;
            display: flex;
            align-items: center;
            justify-content: center;
            position: relative;
        }

        #key-selector .empty-key-slot {
            width: 100%;
            aspect-ratio: 1;
            background: transparent;
            border: 1px solid var(--ot-harpLine);
            border-radius: 4px;
            opacity: 0.25;
        }

        /* --- MODE + DEPTH GROUP --- */
        #mode-depth-group {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 0;
            flex: 0 0 auto;
        }

        /* Mode selector matches key layout: 3×2 square grid #1102 */
        /* Mode selector: 3×2 grid of 48px squares */
        #mode-selector {
            display: grid;
            grid-template-columns: repeat(3, var(--pad-size));
            grid-auto-rows: var(--pad-size);
            gap: var(--grid-col-gap);
            width: auto;
        }

        #mode-selector .sel-btn {
            width: 48px;
            height: 48px;
            aspect-ratio: 1;
            min-height: 48px;
            padding: 0;
            padding-top: 1px;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        /* --- STICKY FAB BAR --- */
        #fab-bar {
            position: fixed;
            inset: auto 0 0 0;
            top: auto;
            z-index: 999;
            background: rgba(20, 17, 14, 0.97);
            /* #1660: single viz row (text overlays on top of viz).
               fx-tab-bar still stacks above the row, so stay column.
               #footer-overlap-2026-04-18: use --footer-total-h so one
               variable controls height + scroll reservation in sync. */
            height: var(--footer-total-h);
            padding-top: 0;
            padding-bottom: env(safe-area-inset-bottom, 0px);
            padding-left: env(safe-area-inset-left, 0px);
            padding-right: env(safe-area-inset-right, 0px);
            display: flex;
            flex-direction: column;
            align-items: stretch;
            justify-content: flex-start;
            border-top: 1px solid rgba(200,160,80,0.2);
            gap: 0;
            overflow: hidden;
        }
        /* #1660: status overlay — absolute-positioned inside fab-bar-row on
           top of the viz canvas. Readability over the visualizer is achieved
           via a semi-transparent backdrop on each line (see children rule
           below). No text-shadow glow (user directive 2026-04-19). */
        #fab-bar-status {
            position: absolute;
            top: 2px;
            left: 0;
            right: 0;
            text-align: center;
            z-index: 2;
            pointer-events: none;
            color: rgba(168, 152, 120, 0.9);
            font-size: 0.55rem;
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 0;
            min-height: 0;
            overflow: hidden;
        }
        /* Backdrop per-line: semi-transparent dark pill behind the text so it
           reads over any visualizer mode without needing text-shadow glow. */
        #fab-bar-status > * {
            background: rgba(0, 0, 0, 0.55);
            padding: 1px 6px;
            border-radius: 3px;
        }
        /* #1656/#1660: single horizontal row — [audio FAB] [mix-scope] [loopers].
           mix-scope fills the full footer height; status text overlays on top. */
        #fab-bar-row {
            position: relative;
            display: flex;
            flex-direction: row;
            align-items: center;
            gap: 6px;
            width: 100%;
            /* #1660: fill remaining footer height (footer height minus fx-tab-bar).
               mix-scope inside fills full height of this row. */
            min-height: 0;
            /* Symmetric padding: equal left/right inset for pixel-perfect FAB
               symmetry. Both audio-fab (left) and loop FABs (right) share the
               same gap from the viewport edge.
               #fab-bar-row-padbottom-2026-05-06 (TODO.md:932): reserve 26px at
               the bottom for #fab-version (bottom:2-12) + #perf-latency
               (bottom:14-26) so the position:fixed badges no longer overlap
               the mix-scope canvas center. Without this, the badges (z:1000)
               sat directly on top of the viz center; AC explicitly chose
               "reserve viz bottom padding" over corner-pinning because both
               bottom corners of #fab-bar-row are already occupied by
               interactive FABs (audio-fab + mic-fab on the left, note-loop
               container on the right).
               (Landscape override at css:3402-3420 inherits this padding via
               its own padding declaration; see #fab-bar-row-padbottom-landscape
               below.) */
            padding: 0 6px 26px 6px;
            flex: 1 1 auto;
            overflow: hidden;
        }
        /* page-nav removed — was hidden here for oldtime */
        /* #1189: version "ago" label at the very bottom of the footer,
           below the FAB row, inside the safe-area padding zone. */
        #fab-version {
            position: fixed;
            left: 50%;
            bottom: calc(2px + env(safe-area-inset-bottom, 0px));
            transform: translateX(-50%);
            font-size: 8px;
            font-family: -apple-system, BlinkMacSystemFont, sans-serif;
            color: rgba(200, 168, 88, 0.35);
            letter-spacing: 0.05em;
            pointer-events: none;
            white-space: nowrap;
            z-index: 1000;
        }
        /* #1657: always-visible compact latency readout. Sits just above the
           fab-version label in the footer, pinned to viewport bottom so it
           is never clipped by #fab-bar-status being hidden (landscape) or
           the perf-stats font being illegibly small. */
        #perf-latency {
            position: fixed;
            left: 50%;
            bottom: calc(14px + env(safe-area-inset-bottom, 0px));
            transform: translateX(-50%);
            font-size: 10px;
            font-family: ui-monospace, Menlo, Consolas, monospace;
            color: rgba(200, 168, 88, 0.75);
            letter-spacing: 0.02em;
            pointer-events: none;
            white-space: nowrap;
            z-index: 1000;
            /* Readability over viz without text-shadow glow (2026-04-19): use a
               semi-transparent dark pill backdrop instead. */
            background: rgba(0, 0, 0, 0.55);
            padding: 1px 6px;
            border-radius: 3px;
        }
        /* #fab-z-index-above-viz-2026-04-25: position:relative + z-index:2
           creates a stacking context so all FABs (audio-fab, mic-fab, rec-fab,
           loop FABs) render above sibling viz canvases (mix-scope, etc.) in
           every layout. User-reported (iMessage 2026-04-25): mic-fab appeared
           below footer viz in landscape. Without an explicit stacking context
           on the FAB group, the canvas paint order won (canvases create their
           own stacking layer when rendering). z-index:2 puts the group above
           anything in fab-bar without explicit positioning, while remaining
           below #fab-bar's own z-index:999 so the footer-toast / status
           overlays still float on top correctly. */
        #fab-bar-left {
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
            flex-shrink: 0;
            position: relative;
            z-index: 2;
        }
        #fab-bar-right {
            display: flex;
            align-items: center;
            gap: 8px;
            flex-shrink: 0;
            position: relative;
            z-index: 2;
        }
        /* #1649: Note loopers back in fab-bar-right (footer row), horizontal,
           anchored to the right end of the footer via fab-bar-right's flex
           position in the space-between row.
           #1651: flush with the right viewport edge — no margin/padding on
           the right, and fab-bar-row's right padding was removed. */
        #note-loop-container {
            display: flex;
            flex-direction: row;
            align-items: center;
            gap: 6px;
            margin: 0;
            padding: 0;
            pointer-events: auto;
        }
        #note-loop-container > div {
            display: flex;
            flex-direction: row;
            align-items: center;
            gap: 6px;
        }
        #note-loop-container button {
            width: 22px;
            height: 22px;
            min-width: 22px;
            font-size: 11px;
        }
        /* #footer-viz-21: mix-scope restricted to a 2:1 aspect ratio,
           centered between audio FAB (left) and note-loop container (right).
           No longer stretches full footer width. max-width: 50vw caps the
           width so on iPhone 13 mini (375px) the viz is ~187px × ~94px.
           aspect-ratio: 2/1 drives the height from the width. flex:0 1 auto
           prevents it from growing to fill remaining space (siblings still
           share the row). The internal 192×96 backing store (already 2:1) is
           preserved for jagged/pixelated CRT look via imageRendering. */
        #mix-scope {
            flex: 0 1 auto;
            width: 100%;
            max-width: 50vw;
            aspect-ratio: 2 / 1;
            height: auto;
            min-width: 0;
            margin: 0 auto;
        }
        /* 2026-04-25 CRT channel-change fade — added to #mix-scope momentarily
           when the user cycles footer viz modes (mix-tab.js pointerup). The
           brightness dip + return reads as a CRT TV channel change rather than
           the previous instant swap. Filter alone (no opacity) keeps the
           canvas backing-store rendering uninterrupted. */
        @keyframes viz-mode-crt-fade {
            0%   { filter: brightness(1); }
            18%  { filter: brightness(0.15) contrast(1.4); }
            36%  { filter: brightness(0.05) contrast(1.6); }
            70%  { filter: brightness(0.6) contrast(1.2); }
            100% { filter: brightness(1); }
        }
        #mix-scope.viz-mode-changing {
            animation: viz-mode-crt-fade 180ms ease-out;
        }
        /* --- FOOTER TOAST (#1099, polished #1101) — tasteful overlay in fab-bar --- */
        #footer-toast {
            position: fixed;
            left: 50%;
            bottom: 170px;
            transform: translateX(-50%);
            background: transparent;
            color: var(--ot-accent, #c8a858);
            border: none;
            border-radius: 8px;
            padding: 10px 20px;
            font-size: 0.9rem;
            font-weight: 500;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            letter-spacing: 0.02em;
            text-align: center;
            pointer-events: none;
            opacity: 0;
            transition: opacity 0.25s ease;
            z-index: 1001;
            white-space: nowrap;
            max-width: 80vw;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        #footer-toast.visible {
            opacity: 1;
        }

        /* --- AUDIO FAB --- */
        #audio-fab {
            width: 36px;
            height: 36px;
            border-radius: 50%;
            border: none;
            cursor: pointer;
            opacity: 0.85;
            box-shadow: 0 2px 6px rgba(0,0,0,0.4);
            font-size: 18px;
            font-weight: bold;
            line-height: 1;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 0;
            color: var(--ot-textOnAccent);
        }
        #audio-fab.running { background: var(--ot-fabGreen); }
        #audio-fab.suspended { background: var(--ot-warning); }
        #audio-fab.closed { background: var(--ot-fabRed); }

        /* #1140: explicitly kill any glow/border on the tab bar */
        .fx-tab-btn,
        .fx-tab-btn:hover,
        .fx-tab-btn:focus {
            box-shadow: none;
            outline: none;
        }
        /* #android-fix: visual :active feedback since -webkit-tap-highlight is transparent */
        .fx-tab-btn:active {
            box-shadow: none;
            outline: none;
            opacity: 0.7;
        }
        #audio-fab:active {
            opacity: 0.7;
        }
        .chord-pad:active {
            opacity: 0.85;
        }
        .mod-btn:active {
            opacity: 0.7;
        }

        /* Polish: slider label scale on drag (#1078) */
        .fx-row label,
        .fx-row .fx-val {
            transition: transform 0.12s ease-out, color 0.12s ease-out;
            transform-origin: left center;
        }
        .fx-row .fx-val {
            transform-origin: right center;
        }
        .fx-row.dragging label,
        .fx-row.dragging .fx-val {
            transform: scale(1.1);
            color: var(--ot-accent, #c8a858);
        }

        /* Double-tap slider reset: thumb + value flash (task #109).
           Applied to the whole .fx-row so both the slider thumb and the
           value label pulse together when a slider is reset to default.
           ~280ms animation, removed by JS timeout. */
        @keyframes sliderResetThumbPulse {
            0%   { transform: scale(1.0); box-shadow: 0 0 0 0 var(--thumb-color, var(--ot-accent, #c8a858)); }
            40%  { transform: scale(1.35); box-shadow: 0 0 0 6px rgba(200,168,88,0.0); }
            100% { transform: scale(1.0); box-shadow: 0 0 0 0 rgba(200,168,88,0); }
        }
        @keyframes sliderResetLabelFlash {
            0%   { transform: scale(1.0); color: var(--ot-text); }
            40%  { transform: scale(1.2);  color: var(--ot-accent, #c8a858); }
            100% { transform: scale(1.0); color: var(--ot-text); }
        }
        .fx-row.slider-reset-pulse input[type="range"]::-webkit-slider-thumb {
            animation: sliderResetThumbPulse 0.28s ease-out;
        }
        .fx-row.slider-reset-pulse input[type="range"]::-moz-range-thumb {
            animation: sliderResetThumbPulse 0.28s ease-out;
        }
        /* Label + value both flash on reset. The label + value readout are
           BOTH reset targets as of 2026-04-23 — double-tap either to reset.
           Thumb dblclick was removed. .micro-row covers the hand-rolled
           microtonal sliders that share the same reset-pulse class but use
           .micro-row wrappers. */
        .fx-row.slider-reset-pulse .fx-val,
        .fx-row.slider-reset-pulse > label,
        .micro-row.slider-reset-pulse > label,
        .micro-row.slider-reset-pulse .micro-val {
            animation: sliderResetLabelFlash 0.28s ease-out;
        }

        /* padRestEcho keyframes + .pad-rest removed (#glow-removal) */

        /* 2026-04-30 V1 triage D: keyboard-shortcuts help (#kb-help-btn +
           #kb-help-overlay + .kb-row/.kb-key/.kb-desc/.kb-close-hint rules)
           removed (cut/global-ux-drops). Touch-first family device has no
           desktop keyboard. */

        /* --- X MODE CHORD SELECTOR --- */
        #xmode-chord-selector {
            position: fixed;
            inset: 0;
            z-index: 1001;
            background: rgba(0, 0, 0, 0.88);
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 16px;
            touch-action: none;
        }
        .xcs-panel {
            position: relative;
            background: var(--ot-panel);
            border: 1px solid rgba(200, 168, 88, 0.3);
            border-radius: 12px;
            padding: 16px;
            max-width: 320px;
            width: 100%;
            max-height: 80vh;
            overflow-y: auto;
            -webkit-overflow-scrolling: touch;
        }
        .xcs-title {
            font-size: 14px;
            font-weight: 600;
            color: var(--ot-accent, #c8a858);
            text-align: center;
            margin-bottom: 12px;
        }
        .xcs-label {
            font-size: 11px;
            color: var(--ot-textGray);
            text-transform: uppercase;
            letter-spacing: 1px;
            margin: 8px 0 6px 0;
        }
        .xcs-degree-grid {
            display: grid;
            grid-template-columns: repeat(4, 1fr);
            gap: 6px;
        }
        .xcs-quality-grid {
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            gap: 6px;
        }
        /* Velocity radio row in the X chord-customizer modal.
           2026-05-01: per-slot velocity feature (feat/x-chord-per-slot-velocity).
           2026-05-04: shipped a slider with tick bar (PR #2620).
           2026-05-04: replaced slider with 3-button radio (quiet/med/loud)
                       — same .xcs-btn styling as Root + Quality grids in this
                       same panel. .xcs-vel-row reuses .xcs-quality-grid for
                       the 3-column layout. */
        .xcs-vel-row {
            margin: 4px 0 8px 0;
        }
        /* Tier-aware desc inside the xcs modal — sits above the velocity radio. */
        .xcs-desc {
            font-size: 11px;
            line-height: 1.35;
            color: var(--ot-textSoft);
            margin: -2px 0 6px 0;
            opacity: 0.85;
        }
        /* Tier-aware desc inserted above #voicing-hold-row covering Latch +
           Glide. Single-line, very compact — the play view's vertical real
           estate is tight on iPhone 13 mini. */
        .voicing-hold-desc {
            font-size: 10px;
            line-height: 1.2;
            color: var(--ot-textSoft);
            opacity: 0.75;
            text-align: center;
            margin: 1px 0 1px 0;
            padding: 0 4px;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        body.harp-mode .voicing-hold-desc {
            /* Harp mode hides Glide; the desc is mostly about Glide so hide
               it too. Latch alone has the harp-info / harp-strip context. */
            display: none;
        }
        .xcs-btn {
            background: var(--ot-panelAlt);
            border: 1px solid rgba(200, 168, 88, 0.2);
            border-radius: 6px;
            color: var(--ot-xcsBtnText);
            font-size: 13px;
            padding: 8px 4px;
            cursor: pointer;
            text-align: center;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            touch-action: manipulation;
            -webkit-tap-highlight-color: transparent;
        }
        .xcs-btn:active,
        .xcs-btn.active {
            background: var(--ot-panelHover);
            border-color: var(--ot-accent, #c8a858);
            color: var(--ot-accent, #c8a858);
        }
        .xcs-shuffle-btn {
            display: block;
            width: 100%;
            margin-top: 14px;
            padding: 10px;
            background: var(--ot-surface);
            border: 1px solid rgba(200, 168, 88, 0.15);
            border-radius: 6px;
            color: var(--ot-xcsShuffleText);
            font-size: 13px;
            cursor: pointer;
            text-align: center;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            touch-action: manipulation;
            -webkit-tap-highlight-color: transparent;
        }
        .xcs-shuffle-btn:active {
            background: var(--ot-panelAlt);
            color: var(--ot-accent, #c8a858);
        }
        .xcs-close-btn {
            position: absolute;
            top: 8px;
            right: 8px;
            background: none;
            border: none;
            color: var(--ot-xcsCloseIdle);
            font-size: 18px;
            cursor: pointer;
            padding: 4px 8px;
            line-height: 1;
            touch-action: manipulation;
            -webkit-tap-highlight-color: transparent;
        }
        .xcs-close-btn:active {
            color: var(--ot-xcsCloseActive);
        }

        /* Hidden temporarily — re-enable by removing this rule */
        #rec-fab { display: none; }

        .rec-fab {
            width: 28px;
            height: 28px;
            border-radius: 50%;
            border: none;
            background: var(--ot-fabRed);
            color: var(--ot-text);
            font-family: inherit;
            font-size: 14px;
            font-weight: bold;
            cursor: pointer;
            touch-action: manipulation;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 0;
            line-height: 1;
        }
        .rec-fab.recording {
            animation: rec-pulse 1s ease-in-out infinite;
        }
        @keyframes rec-pulse {
            0%, 100% { opacity: 1; }
            50% { opacity: 0.5; }
        }

        /* --- MIC FAB ---
           Monochrome unicode FISHEYE (U+25C9). Off = outline only
           (transparent bg, accent border). On = filled bg (phosphor /
           accent) to signal active mic-to-analyser routing.
           See js/oldtime/ui.js MIC FAB block for wiring. */
        #mic-fab {
            width: 28px;
            height: 28px;
            border-radius: 50%;
            border: 1.5px solid var(--ot-warning, #c8a858);
            background: transparent;
            color: var(--ot-warning, #c8a858);
            font-family: inherit;
            /* FISHEYE (U+25C9) optical center sits below the line-box midpoint
               in iOS SF Pro / most fonts, so flex centering alone leaves the
               dot visibly low. padding-bottom shrinks the available flex
               box so the centered glyph shifts upward to optical center. */
            font-size: 1.35rem;
            font-weight: bold;
            line-height: 1;
            cursor: pointer;
            touch-action: manipulation;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 0;
            padding-bottom: 3px;
            opacity: 0.85;
        }
        #mic-fab.active {
            background: var(--ot-warning, #c8a858);
            color: var(--ot-textOnPhosphor);
            border-color: var(--ot-warning, #c8a858);
            opacity: 1;
        }
        #mic-fab:active {
            opacity: 0.7;
        }

        .sel-btn {
            background: var(--ot-harpLine);
            border-width: 1px;
            border-style: solid;
            border-color: var(--ot-harpLine);
            color: var(--ot-textMid);
            padding: 6px 10px;
            padding-top: 7px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 0.8rem;
            font-family: inherit;
            transition: background 0.15s, color 0.15s, border-color 0.15s;
        }

        /* Rarity color coding — inactive state */
        .sel-btn[data-rarity="primary"] {
            border-color: var(--ot-depth1);
            color: var(--ot-depth1);
        }
        .sel-btn[data-rarity="secondary"] {
            border-color: var(--ot-depth2);
            color: var(--ot-depth2);
        }
        .sel-btn[data-rarity="rare"] {
            border-color: var(--ot-depth3);
            color: var(--ot-depth3);
            opacity: 0.85;
        }

        /* #1208: no flash on touch — .active class handles the selected state;
           :active pseudo just dims slightly for tactile feedback */
        .sel-btn:active {
            opacity: 0.75;
        }

        /* #1323: Active selectors revert to gold (blue reserved for sound-producing) */
        .sel-btn.active {
            background: var(--ot-depth1);
            border-color: var(--ot-depth1);
            color: var(--ot-textOnAccent);
            font-weight: bold;
        }
        .sel-btn.active[data-rarity="primary"],
        .sel-btn.active[data-rarity="secondary"],
        .sel-btn.active[data-rarity="rare"] {
            background: var(--ot-depth1);
            border-color: var(--ot-depth1);
            color: var(--ot-textOnAccent);
            opacity: 1;
        }

        /* 2026-04-30 cut/voice-models-ddsp: .sel-btn.ddsp-active highlight
           removed (DDSP voices cut per V1 triage D — nothing to highlight). */

        /* 2026-04-30 V1 triage D: #depth-selector + #depth-name-display CSS
           removed (cut/play-key-harp-drops) — Chord Depth Selector deleted. */

        /* --- PIANO + XY REGISTER STACK (#1160) ---
           The XY register pad sits on top of the piano canvas so they share
           the same vertical real estate. Piano draws underneath; XY overlay
           draws on top with low opacity so voice dots and octave labels
           remain visible through the register range band. */
        /* #1232: height matches one grid row (--pad-size) */
        /* TODO 2026-04-18 audit (TODO.md:933): bleed pattern (width:100vw +
           margin-left:calc(-50vw + 50%)) overflowed past --grid-width — siblings
           (#pad-harp-row, #mode-row, #voicing-hold-row) are all width:100%;
           max-width:var(--grid-width). Match them. iPad rule below resets
           max-width:none for the wider thumb-zone layout. */
        #piano-reg-stack {
            position: relative;
            width: 100%;
            max-width: var(--grid-width);
            height: var(--pad-size);
            flex-shrink: 0;
            margin-inline: auto;
        }
        #piano-canvas {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            z-index: 0;
        }
        #reg-xy-container {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            z-index: 1;
            padding: 0;
        }
        #reg-xy-pad {
            width: 100%;
            height: 100%;
            border-radius: 3px;
            cursor: crosshair;
            touch-action: none;
        }

        /* --- VOICING GROUP --- */
        #voicing-group {
            padding: 2px 6px;
        }

        /* --- CHORD PADS --- */
        /* 7-column grid: all columns fixed at --pad-size */
        #pad-container {
            display: grid;
            grid-template-columns: repeat(7, var(--pad-size));
            grid-template-rows: auto;
            grid-auto-rows: var(--pad-size);
            gap: var(--grid-col-gap);
            align-content: center;
            width: auto;
            max-width: var(--grid-width);
            padding: 4px 0;
            z-index: 1;
            touch-action: none;
        }
        /* ═══ HARP MODE ═══
           5-col grid (NOT 7). Mods display:none. No auto-placement
           ambiguity — 4 pads + 1 label per row fits exactly.
           Harp strip overlays externally.
        */
        /* #1316: piano spacer same height in both modes (was -4px in harp, causing pixel shift) */
        #pad-container.harp-active {
            grid-template-columns: repeat(4, var(--pad-size)) minmax(0, 1fr);
            /* Rows 1-5 = header + 4 data rows; row 6 = harp-spacer-row.
               Spacer height: var(--pad-size) - var(--grid-col-gap) so that
               adding the row-gap (var(--grid-col-gap)) before it yields exactly
               var(--pad-size) = same height as the hidden piano-reg-stack.
               This makes portrait harp play-tab total height match key mode (±2px).
               See .harp-spacer-row and #harp-spacer-row comment. */
            grid-template-rows: auto repeat(4, var(--pad-size)) calc(var(--pad-size) - var(--grid-col-gap));
            grid-auto-rows: var(--pad-size);
            max-width: calc(5 * var(--pad-size) + 4 * var(--grid-col-gap));
        }
        #pad-container.harp-active .mod-btn {
            display: none;
        }
        #pad-container.harp-active .row-label {
            grid-column: 5;
            justify-content: flex-start;
            padding-left: 4px;
            padding-right: 0;
        }
        #pad-container.harp-active .mod-corner {
            grid-column: 5;
        }
        /* #1312: harp-extra-color removed */

        .harp-header-col5 {
            display: none;
        }

        .row-label {
            display: flex;
            align-items: center;
            justify-content: flex-end;
            font-size: 0.45rem;
            color: var(--ot-muted);
            padding-right: 4px;
            text-transform: uppercase;
            max-height: var(--pad-size);
            overflow: hidden;
            letter-spacing: 0.5px;
            white-space: nowrap;
        }

        .col-label {
            display: flex;
            align-items: flex-end;
            justify-content: center;
            font-size: 0.45rem;
            color: var(--ot-muted);
            text-transform: uppercase;
            letter-spacing: 0.5px;
            padding-bottom: 2px;
            /* #1276: explicit height so header row is 48px in BOTH modes.
               In key mode, invisible B↓/B↑ (48px) drive the row height.
               In harp mode, mods are display:none so col-labels must be
               48px themselves to prevent a 34px layout shift. */
            height: var(--pad-size);
        }

        .empty-row {
            visibility: hidden;
            pointer-events: none;
        }

        /* #harp-spacer-row: portrait harp mode height equalizer.
           In key mode, piano-reg-stack (var(--pad-size) = 48px) sits above
           pad-container. In harp mode the piano is hidden, so the total
           play-tab height is 48px shorter. This spacer row compensates:
           grid-row 6 is sized at calc(var(--pad-size) - var(--grid-col-gap))
           = 44px via the explicit grid-template-rows on .harp-active.
           The row-gap (4px) before this row + 44px = 48px total addition,
           matching the piano's 48px contribution to key mode height (±2px).
           Visually invisible — no border, no background, no pointer events.
           Landscape override sets display:none to prevent layout pollution. */
        .harp-spacer-row {
            visibility: hidden;
            pointer-events: none;
        }

        .chord-pad {
            width: var(--pad-size);
            height: var(--pad-size);
            max-height: var(--pad-size);
            border-radius: 8px;
            border-width: 2px;
            border-style: solid;
            border-color: transparent;
            background: transparent;
            cursor: pointer;
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            text-align: center;
            padding: 4px;
            padding-top: 5px;
            /* 2026-05-05 (PR #2617): 0.2s → 0.1s to tighten release fadeout.
               200ms felt sluggish post-release; 100ms is at the ergonomic
               threshold for "instant" UI feedback. The active-touch IN-class
               transition (0.05s) was already snappy; the slow OFF transition
               came from THIS base rule taking over once .active-touch was
               removed. */
            transition: background-color 0.1s, border-color 0.1s, opacity 0.1s;
            position: relative;
            touch-action: none;
            user-select: none;
            -webkit-user-select: none;
        }

        .chord-pad:focus {
            outline: none;
        }

        .chord-pad[data-role="primary"] {
            background: var(--ot-surface);
            border-width: 2px;
            border-style: solid;
            border-color: var(--ot-depth1);
            color: var(--ot-depth1);
        }

        .chord-pad[data-role="secondary"] {
            background: var(--ot-surface);
            border-width: 1.5px;
            border-style: solid;
            border-color: var(--ot-depth2);
            color: var(--ot-depth2);
        }

        .chord-pad[data-role="rare"] {
            background: var(--ot-bg);
            border-width: 1px;
            border-style: solid;
            border-color: var(--ot-depth3);
            color: var(--ot-depth3);
            opacity: 0.85;
        }

        .chord-pad {
            -webkit-tap-highlight-color: transparent;
        }
        .chord-pad.active-touch {
            /* Two-class selector (.chord-pad.active-touch = 0,2,0) already beats
               role-attr base rules (.chord-pad[data-role=*] = 0,1,1) on
               specificity, so !important not needed on bg/border/opacity.
               Phosphor color override at body[data-phosphor=*] .chord-pad.active-touch
               wins on its own higher specificity (0,3,1) — color !important here
               was fighting nothing. (batch 3 of N) */
            background-color: var(--ot-ui-highlight);
            color: var(--ot-textOnAccent);
            border-color: var(--ot-ui-highlight);
            opacity: 1;
            transition: background-color 0.05s, opacity 0.05s;
        }

        /* padPulse keyframes + .pad-pulse removed (#glow-removal) */

        .chord-pad.harp-selected {
            background-color: var(--ot-ui-highlight);
            color: var(--ot-textOnAccent);
            border-color: var(--ot-ui-highlight);
            opacity: 1;
        }


        .chord-pad .roman {
            font-size: 0.85rem;
            font-weight: bold;
            line-height: 1.2;
        }

        .chord-pad .chord-name {
            font-size: 0.65rem;
            opacity: 0.8;
        }

        .chord-pad .root-note {
            display: none; /* hidden — preserved for future use */
            font-size: 0.5rem;
            opacity: 0.5;
            line-height: 1.1;
        }

        .chord-pad.empty-slot .root-note {
            display: none;
        }

        .tension-strip {
            display: none;
        }


        /* --- VOICING PRESET SELECTOR --- */
        /* 2026-04-25 voicing-bar v2 phase B (PR #2388 AC): per-button SATB-dots
           diagram. Each .sel-btn[data-voicing] now contains an inline SVG
           (.voicing-dots) showing the voicing's relative SATB pitch positions
           as 4 colored dots, plus a tiny text label underneath for legibility.
           Anchor: #voicing-bar-v2-phase-b-2026-04-25 */
        .sel-btn[data-voicing] {
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            gap: 1px;
        }
        /* 2026-04-30 voicing-buttons polish (user-requested via iMessage):
           per-button SATB-dots SVG removed — labels alone teach the voicing
           concept at a legible size. Marie-Kondo: dots were too small to read
           on iPhone 13 mini and didn't communicate enough to justify the
           tradeoff. The .voicing-label font-size now matches .sel-btn standard
           (0.55rem) so the labels read clearly in both orientations.
           Anchor: #voicing-buttons-polish-2026-04-30 */
        .sel-btn[data-voicing] .voicing-dots {
            display: none;
        }
        .sel-btn[data-voicing] .voicing-label {
            font-size: 0.55rem;
            line-height: 1;
            opacity: 1;
            pointer-events: none;
        }
        #voicing-bar {
            display: flex;
            flex-wrap: wrap;
            gap: 4px;
            width: 100%;
        }

        #voicing-bar .sel-btn {
            width: var(--pad-size);
            height: var(--pad-size);
            font-size: 0.55rem;
            padding: 0;
            padding-top: 1px;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        #voicing-bar .sel-btn.active {
            background: var(--ot-depth1);
            border-color: var(--ot-depth1);
            color: var(--ot-textOnAccent);
        }

        #perf-stats {
            font-size: 0.45rem;
            color: var(--ot-muted);
            padding: 1px 6px;
            font-family: monospace;
            line-height: 1.2;
            white-space: pre-wrap;
        }
        #perf-stats:empty { display: none; }

        /* --- FIXED GRID: 13 slots, 2 rows of 7/6 --- */
        /* Row 1: I  i  ii  iii  bIII  IV  iv          */
        /* Row 2: V  v  V7  vi   bVI   bVII             */
        /* Empty slots = intentional negative space teaching the active mode's
           shape. They share the filled-cell footprint (2px border, same size)
           so the grid reads as one coherent rectangle, but use a darker muted
           fill (--ot-empty) and lowered opacity so they clearly read "absent"
           rather than "broken / pending". No tap target. */
        .chord-pad.empty-slot {
            background: var(--ot-empty);
            border-width: 2px;
            border-style: solid;
            border-color: var(--ot-harpLine);
            opacity: 0.6;
            cursor: default;
            pointer-events: none;
        }
.chord-pad.empty-slot .roman,
        .chord-pad.empty-slot .chord-name {
            display: none;
        }

        /* Sample mode: dashed borders on chord pads */
        .sample-mode .chord-pad {
            border-style: dashed;
        }

        /* SATB voice dots on chord pads removed 2026-04-30 V1 triage D —
           overlay was already display:none, DOM creation cruft now deleted. */

        /* (reset btn and audio status merged into #audio-fab above) */

        /* --- MICROTONAL (inside FX tab) --- */

        .micro-header {
            display: flex;
            flex-wrap: wrap;
            gap: 4px 8px;
            align-items: center;
            padding: 2px 0 4px;
            border-bottom: 1px solid var(--ot-harpLine);
            margin-bottom: 2px;
        }

        .micro-context {
            font-size: 0.65rem;
            color: var(--ot-depth1);
            font-weight: bold;
        }

        .micro-presets {
            display: flex;
            flex-wrap: wrap;
            gap: 4px;
        }

        .micro-preset-btn {
            background: transparent;
            border: 1px solid var(--ot-borderMid);
            color: var(--ot-depth2);
            padding: 1px 6px;
            border-radius: 3px;
            cursor: pointer;
            font-size: 0.55rem;
            transition: color 0.15s, border-color 0.15s;
        }

        .micro-preset-btn:hover {
            color: var(--ot-depth1);
            border-color: var(--ot-depth1);
        }

        .micro-preset-btn.active {
            color: var(--ot-ui-highlight);
            border-color: var(--ot-ui-highlight);
        }

        .micro-row {
            display: flex;
            align-items: center;
            gap: 6px;
            font-size: 0.65rem;
            transition: opacity 0.2s;
            /* #1134: match .fx-row min-height so parent gap produces uniform rhythm */
            min-height: 22px;
        }

        .micro-row.inactive {
            opacity: 0.3;
        }

        .micro-row label {
            min-width: 42px;
            color: var(--ot-muted);
        }

        .micro-row.active-degree label {
            color: var(--ot-depth1);
        }

        .micro-row input[type="range"] {
            flex: 1;
            height: 12px;
            /* #thumb-contrast: see top-level input[type="range"] rule.
               Use --ot-depth1 for visibility on track in off/light themes. */
            --thumb-color: var(--ot-depth1);
        }

        .micro-row .micro-val {
            min-width: 28px;
            text-align: right;
            color: var(--ot-textSoft);
            font-size: 0.6rem;
        }

        .micro-row .micro-total {
            min-width: 36px;
            text-align: right;
            font-size: 0.6rem;
            font-weight: bold;
        }

        .micro-row .micro-total.positive { color: var(--ot-fxFilter); }
        .micro-row .micro-total.negative { color: var(--ot-fxAmp); }
        .micro-row .micro-total.zero { color: var(--ot-muted); }

        .micro-bar-wrap {
            width: 40px;
            height: 8px;
            background: var(--ot-harpLine);
            border-radius: 2px;
            position: relative;
            overflow: hidden;
            flex-shrink: 0;
        }

        .micro-bar-center {
            position: absolute;
            top: 0;
            left: 50%;
            width: 1px;
            height: 100%;
            background: var(--ot-borderMid);
        }

        .micro-bar-fill {
            position: absolute;
            top: 1px;
            height: 6px;
            border-radius: 1px;
            transition: left 0.15s, width 0.15s, background 0.15s;
        }

        /* --- SETTINGS TOGGLE --- */
        .toggle-btn {
            background: transparent;
            border-width: 1px;
            border-style: solid;
            border-color: var(--ot-borderMid);
            color: var(--ot-muted);
            padding: 4px 10px;
            padding-top: 5px;
            border-radius: 3px;
            cursor: pointer;
            font-size: 0.7rem;
            font-family: inherit;
            min-height: 36px;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        /* #1031: bump mode-row toggle buttons to match mode selector size */
        #mode-row .toggle-btn[style] {
            font-size: 0.7rem;
            min-height: 36px;
        }
        /* 2026-05-05 dead-CSS sweep (PR #2612): #mode-row .mode-square block
           removed. Class never assigned in JS or HTML; rule was a leftover
           from the #1047/#1100 mode-row sizing experiment. Current mode-row
           sizing is driven by .mode-row > .toggle-btn rules above. */

        .toggle-btn.active {
            border-color: var(--ot-ui-highlight);
            color: var(--ot-ui-highlight);
        }

        /* --- HARP STRIP --- */
        #harp-strip {
            width: 100%;
            max-width: 480px;
            height: 48px;
            border-radius: 6px;
            display: none;
            flex-shrink: 0;
            touch-action: none;
            cursor: crosshair;
        }

        #harp-strip.visible {
            display: block;
        }

        /* #1231: Vertical harp aligned to last 2 columns of the 7-col grid.
           Width = 2 × pad-size + 1 × gap = 100px at default 48px. */
        /* #1264: harp strip overlays grid cols 6-7 via absolute positioning.
           Skips the header row (48px + 4px gap = 52px from top). Doesn't
           participate in flex flow so pad grid stays full 7-col. */
        /* Harp strip: flex sibling beside the 5-col pad grid.
           Width = 2 columns. Height matches #pad-container box. */
        /* #harp-top-align-2026-05-06 (TODO line 519): pin harp TOP edge to
           pad-container TOP edge ≤2px across all 4 viewports.
           Earlier (post #2097 revert 2026-04-23) the harp was offset by
           calc(pad-size + 2×gap) ≈ 56px to align with the TONIC chord row,
           but the active TODO calls for top-edge alignment with pad-container.
           Height formula = pad-container border-box = padding-top(4px) +
           col-label row (var(--pad-size)) + row-gap(var(--grid-col-gap)) +
           4 pad rows + 3 row-gaps + padding-bottom(4px) =
           5 * var(--pad-size) + 4 * var(--grid-col-gap) + 8px.
           iPhone landscape keeps a 60px gesture-zone buffer (see
           #harp-extend-iphone-landscape-2026-04-26 below) AND pad-container
           in harp mode is also bumped 60px there so tops still align ≤2px. */
        #harp-strip.vertical {
            flex: 0 0 calc(2 * var(--pad-size) + var(--grid-col-gap));
            width: calc(2 * var(--pad-size) + var(--grid-col-gap));
            align-self: flex-start;
            min-height: 0;
            margin-top: 0;
            height: calc(5 * var(--pad-size) + 4 * var(--grid-col-gap) + 8px);
        }

        #harp-strip.vertical.visible {
            display: block;
        }

        /* 2026-04-30 V1 triage D: .slash-bass-btn rule removed with the feature
           (cut/play-key-harp-drops). */

        /* --- MODIFIER BUTTONS --- */
        .mod-btn {
            align-self: center;
            width: var(--pad-size);
            height: var(--pad-size);
            /* 2026-05-01 polish/standardize-button-borders: 2px → 1px to match
               .sel-btn / .mode-btn / .xcs-btn / .toggle-btn / .micro-preset-btn.
               Tier convention: 1px = UI selectors, 2px = instrument surfaces
               (.chord-pad family + #audio-fab). */
            border-width: 1px;
            border-style: solid;
            border-color: transparent;
            border-radius: 8px;
            background: transparent;
            color: var(--ot-text);
            font-size: 0.85rem;
            font-family: ui-monospace, monospace;
            cursor: pointer;
            touch-action: manipulation;
            user-select: none;
            -webkit-user-select: none;
            /* 2026-05-05 (PR #2617): 0.2s → 0.1s — same ergonomic-threshold
               rationale as .chord-pad. Mod-btn release fadeout was
               distractingly long at 200ms. */
            transition: border-color 0.1s, background 0.1s, color 0.1s, opacity 0.1s;
            display: flex;
            align-items: center;
            justify-content: center;
            text-align: center;
            padding-top: 1px;
        }

        .mod-btn.active {
            border-color: var(--ot-ui-highlight, var(--ot-accent));
            background: var(--ot-ui-highlight, var(--ot-accent));
            color: var(--ot-textOnAccent);
        }
        /* 2026-05-01 polish/standardize-button-borders: collapsed mod-btn
           rarity gradient (was 2/1.5/1) to a flat 1px. Visual differentiation
           between primary/secondary/rare comes from color (--ot-depth1/2/3) +
           opacity — border-width was redundant signal. */
        .mod-btn[data-role="primary"] { background: var(--ot-surface); border-width: 1px; border-style: solid; border-color: var(--ot-depth1); color: var(--ot-depth1); }
        .mod-btn[data-role="secondary"] { background: var(--ot-surface); border-width: 1px; border-style: solid; border-color: var(--ot-depth2); color: var(--ot-depth2); }
        .mod-btn[data-role="rare"] { background: var(--ot-bg); border-width: 1px; border-style: solid; border-color: var(--ot-depth3); color: var(--ot-depth3); opacity: 0.85; }
        .mod-btn.detected {
            border-color: var(--ot-depth2);
            background: var(--ot-panel);
            color: var(--ot-depth2);
        }
        .mod-btn[data-role="primary"].active,
        .mod-btn[data-role="secondary"].active,
        .mod-btn[data-role="rare"].active {
            background: var(--ot-ui-highlight, var(--ot-accent));
            border-color: var(--ot-ui-highlight, var(--ot-accent));
            color: var(--ot-textOnAccent);
            opacity: 1;
        }

        /* modPress keyframes + .pressing removed (#glow-removal) */

        /* Flex row wrapper for modifier col + pad + vertical harp side by side */
        #pad-harp-row {
            display: flex;
            flex-direction: row;
            align-items: stretch;
            justify-content: center;
            gap: 0;
            width: 100%;
            max-width: var(--grid-width);
            padding: 0;
            position: relative;
        }

        /* #1196: hide "N strings" harp info */
        #harp-info {
            display: none;
            font-family: monospace;
        }

        /* Harp flex layout: pad grid + harp strip side by side */
        #pad-harp-row.vertical-layout {
            gap: var(--grid-col-gap);
        }

        #pad-harp-row.vertical-layout #pad-container {
            flex: 1;
            width: auto;
        }

        #pad-harp-row.vertical-layout #harp-strip {
            flex: 0 0 calc(2 * var(--pad-size) + var(--grid-col-gap));
        }

        /* --- MODE TOGGLE ROW --- */
        #mode-row {
            display: flex;
            gap: var(--grid-col-gap);
            align-items: center;
            justify-content: space-between;
            padding: 2px 0;
            flex-shrink: 0;
            width: 100%;
            max-width: var(--grid-width);
            flex-wrap: wrap;
        }
        .mode-group {
            display: flex;
            gap: 2px;
            border-radius: 4px;
            background: rgba(255, 255, 255, 0.04);
            padding: 2px;
        }
        /* #1209: all play-page rows use 12px side padding to match header/mods */
        #voicing-hold-row {
            display: flex;
            align-items: center;
            gap: var(--grid-col-gap);
            padding: 2px 0;
            flex-shrink: 0;
            width: 100%;
            max-width: var(--grid-width);
            justify-content: flex-end;
        }
        #voicing-hold-row #voicing-group {
            flex: 1;
            padding: 0;
            margin-right: auto;
        }
        /* Latch/Sustain: each 48px, together = 2 grid columns (100px with gap) */
        #voicing-hold-row .sel-btn {
            width: var(--pad-size);
            height: var(--pad-size);
            font-size: 0.55rem;
            padding: 0;
            padding-top: 1px;
            display: flex;
            align-items: center;
            justify-content: center;
            flex: 0 0 var(--pad-size);
        }
        /* 2026-04-30 V1 triage D: #sustain-btn rule removed (cut/play-key-harp-drops). */
        /* Latch + Glide each fall back to the .sel-btn 1u width above. The
           split-latch design is two distinct 1u toggles sharing the original
           Latch footprint (2u + gap), not two 2u-wide twins. Harp mode hides
           Glide and stretches Latch so the row stays anchored. */
        body.harp-mode #voicing-hold-row #glide-toggle-btn {
            display: none;
        }
        body.harp-mode #voicing-hold-row #latch-btn {
            width: calc(2 * var(--pad-size) + var(--grid-col-gap));
            flex: 0 0 calc(2 * var(--pad-size) + var(--grid-col-gap));
        }

        /* #1213: mode-row padding matches other rows exactly */
        #mode-row {
            display: flex;
            gap: var(--grid-col-gap);
            justify-content: space-between;
            padding: 2px 0;
            flex-shrink: 0;
            width: 100%;
            max-width: var(--grid-width);
        }
        .mode-group-left,
        .mode-group-right {
            display: flex;
            gap: var(--grid-col-gap);
        }
        /* 2026-04-30 V1 triage D: .mode-group-shake removed with the buttons
           (cut/play-key-harp-drops). */
        /* #1206: .mode-btn — matches key selector style (inactive + active) */
        .mode-btn {
            width: 48px;
            height: 48px;
            border-radius: 4px;
            border-width: 1px;
            border-style: solid;
            border-color: var(--ot-harpLine);
            background: var(--ot-harpLine);
            color: var(--ot-textMid);
            font-size: 0.55rem;
            font-family: inherit;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 0;
            padding-top: 1px;
            transition: background 0.15s, color 0.15s, border-color 0.15s;
            -webkit-tap-highlight-color: transparent;
        }
        .mode-btn:active {
            opacity: 0.8;
        }
        .mode-btn:focus {
            outline: none;
        }
        .mode-btn.active {
            background: var(--ot-depth1);
            color: var(--ot-textOnAccent);
            border-color: var(--ot-depth1);
            font-weight: bold;
        }

        /* #1108: Short/Tall placed equidistant via flex space-between on #mode-row */
        /* Key selector active button gets a small +N/-N badge when transposed via long-press (#1107) */
        .key-shift-badge {
            position: absolute;
            top: 1px;
            right: 2px;
            font-size: 0.55rem;
            line-height: 1;
            padding: 1px 3px;
            border-radius: 3px;
            /* 2026-04-19: no text-shadow glow per user directive. Use an opaque
               dark backdrop behind the badge so it stays readable without a
               text-shadow halo. */
            background: rgba(0, 0, 0, 0.65);
            color: var(--ot-textOnAccent);
            font-weight: 700;
            pointer-events: none;
            z-index: 2;
        }
        #latch-btn.active,
        #glide-toggle-btn.active {
            background: var(--ot-depth1);
            color: var(--ot-textOnAccent);
            border-color: var(--ot-depth1);
        }
        /* 2026-04-30 V1 triage D: .chord-pad.sustained + #sustain-btn rules
           removed (cut/play-key-harp-drops). */
        #latch-btn:focus,
        .toggle-btn:focus,
        .sel-btn:focus,
        .mode-btn:focus {
            outline: none;
        }
        /* #1205: no blue highlight on touch release */
        #latch-btn {
            -webkit-tap-highlight-color: transparent;
            outline: none;
        }
        #latch-btn:active {
            opacity: 0.8;
        }


        /* #1138/#1151: main-tab-panel — both #play-tab-content and
           #effects-panel swap via the tab bar. Each panel owns its own
           internal scroll so the *body* stays short and position:fixed
           on the footer works reliably on iOS Chrome. Without this the
           FX tab's content made the document very tall and the fixed
           footer floated mid-document instead of at viewport bottom. */
        .main-tab-panel {
            display: none;
        }
        .main-tab-panel.active {
            display: flex;
            flex-direction: column;
            /* #footer-overlap-2026-04-18: scroll container ends at the footer's
               top edge PLUS an extra scroll gap. Previously hardcoded 160px,
               which missed safe-area-inset-bottom on iPhone (home indicator)
               and caused ~30px of tab content to hide behind the footer.
               Now derived from --footer-total-h (156px + safe-area) plus
               --footer-scroll-gap (16px) so the last content row never
               touches the tab bar. */
            max-height: calc(100dvh - var(--footer-total-h) - var(--footer-scroll-gap));
            overflow-y: auto;
            overflow-x: hidden;
            -webkit-overflow-scrolling: touch;
            /* #footer-overlap-2026-04-18: extra padding on the scroll container
               itself guarantees breathing room inside the scroll area even if
               max-height gets overridden. Belt-and-suspenders. */
            padding-bottom: var(--footer-scroll-gap);
            /* Respect safe-area-inset-bottom inside the scroll container too,
               for rare layouts where the max-height math is bypassed. */
            scroll-padding-bottom: var(--footer-scroll-gap);
        }
        #effects-panel {
            width: 100%;
            max-width: 600px;
            margin: 0 auto;
            /* #1670: padding-top was 8px — created a visible gap ABOVE the
               Model tab's sticky viz before PRs #1668/#1669's fixes on the
               inner tab content could take effect. The sticky viz's `top:0`
               sticks to the padding edge of the scroll container, so 8px
               padding-top here = 8px gap above viz when scrolled. Zero it
               out; the inner .fx-tab-content already has its own 10px top
               padding for non-Model tabs, and Model tab overrides to 0. */
            padding: 0 4px 16px;
            gap: 0;
        }
        #play-tab-content {
            width: 100%;
            align-items: center;
            gap: 0;
            /* #1485: extra bottom padding so the last slider/FX section
               in play-glide or play-*-fx-content doesn't sit flush against
               the scroll container's bottom edge. 24px breathing room. */
            padding-bottom: 24px;
        }
        /* 2026-05-05 dead-CSS sweep (PR #2612): #fx-close-btn +
           #fx-overlay-backdrop rule removed. The legacy close button +
           backdrop overlay were retired long ago and JS no longer queries
           them — guard rule had nothing to guard. */
        /* #1132/#1138: FX tab bar sits at the top of the sticky footer.
           Bleeds to full width by cancelling the footer's horizontal padding
           and safe-area insets so the tabs span edge to edge. */
        /* #1192: tab bar sits within the footer's padding so Play and Mix
           aren't flush against the screen edge. Consistent distribution. */
        #fx-tab-bar-host {
            width: 100%;
            max-width: none;
            background: transparent;
            border: none;
            border-bottom: 1px solid rgba(200,160,80,0.15);
            border-radius: 0;
            flex-shrink: 0;
            overflow: hidden;
            margin: 0 0 2px 0;
            padding: 0;
        }
        /* #1139/#1394: 9 equal-width tabs spanning the full footer width.
           Active tab uses subtle contrast (slightly lighter text + tinted
           background) instead of the gold underline. */
        .fx-tab-bar {
            display: flex;
            gap: 0;
            width: 100%;
            border-bottom: none;
            position: relative;
            z-index: 1;
            background: transparent;
            touch-action: none;
            overflow: hidden;
            -webkit-overflow-scrolling: auto;
        }

        .fx-tab-btn {
            flex: 1;
            height: 40px;
            padding: 0 1px;
            background: transparent;
            border: none;
            border-bottom: none;
            color: var(--ot-muted);
            font-size: 0.55rem;
            text-overflow: clip;
            white-space: nowrap;
            text-transform: uppercase;
            letter-spacing: 0;
            cursor: pointer;
            font-family: inherit;
            transition: color 0.15s, background 0.15s;
            text-align: center;
            /* #1383: optical centering — use flexbox instead of line-height */
            display: flex;
            align-items: center;
            justify-content: center;
            line-height: 1;
            padding-top: 1px;
            min-width: 0;
            overflow: hidden;
        }

        /* Hover disabled — only .active tab should brighten (#1389) */
        .fx-tab-btn.active {
            color: var(--ot-text);
            background: rgba(200, 168, 88, 0.08);
        }
        /* "Config" tab uses same font size as all other tabs now (was 1.0rem for ⚙ glyph). */
        .fx-tab-btn[data-tabid="settings"] {
            font-size: 0.55rem;
            padding-top: 1px;
        }

        .fx-tab-content {
            display: none;
            flex-direction: column;
            gap: var(--slider-row-gap);
            padding: 10px 10px 12px;
        }

        /* #1668: Model tab has a sticky viz at top — no top padding so viz hugs top edge */
        .fx-tab-content[data-tabid="model"] {
            padding-top: 0;
        }

        /* #1669: eliminate flex gap below sticky viz so next row (waveform buttons)
           sits flush against the viz. --slider-row-gap is 8px; negate via margin-bottom. */
        .fx-tab-content > #model-sticky-viz {
            margin-bottom: calc(-1 * var(--slider-row-gap));
        }

        /* model-tab-2nd-viz-row: row 2 viz (texture + engine diagram) sits
           immediately below row 1 with no gap, and is physically separate from
           row 1 so noise shimmer cannot bleed into the harmonic area. */
        .fx-tab-content > #model-viz-row-2 {
            margin-bottom: calc(-1 * var(--slider-row-gap));
        }

        #model-texture-canvas,
        #model-engine-diagram-canvas {
            display: block;
        }

        .fx-tab-content.active { display: flex; }

        /* #rhythm v2: section labels introduce a group — more air ABOVE than
           below, so the header visually belongs to the content that follows.
           Parent flex gap is 8px; add 16px margin-top so total is 24px above.
           Padding-bottom stays tight (4px) so the underline hugs the label
           and the first row in the section sits ~8px below the label. */
        .fx-section-label {
            font-size: 0.5rem;
            text-transform: uppercase;
            letter-spacing: 1px;
            color: var(--ot-muted);
            padding: 4px 0 4px 0;
            border-bottom: 1px solid var(--ot-harpLine);
            margin-top: 16px;
        }

        .fx-section-label:first-child { margin-top: 0; }
        /* Hide section labels that end up as the last child (no content after them) */
        .fx-section-label:last-child { display: none; }

        /* #rhythm: description sits under its slider/row with enough gap
           to avoid crowding the taller 44px touch-target rows (6px visual gap). */
        .param-desc {
            font-size: 0.5rem;
            opacity: 0.5;
            color: var(--ot-text);
            margin-top: -2px;
            padding: 0 var(--spacing-sub);
            line-height: 1.2;
        }

        /* desc-toggle removed — descriptions always visible */

        /* Env tab sublabel (Filter / LPG / Harp headings) → description:
           The generic .param-desc has margin-top: -2px to tighten it against
           sliders. After a .fx-section-sublabel (block div, not flex parent),
           that -2px causes a 2px overlap. Override to 4px positive gap. */
        .fx-section-sublabel + .param-desc {
            margin-top: 4px;
        }

        /* Env tab sub-sections (VCF / LPG): default hidden to prevent FOUC
           between DOM attachment and the JS applyEnvDestVisibility() pass.
           JS sets display:'' to reveal them when their destination toggle is ON. */
        #envelope-vcf-subsection,
        #envelope-lpg-subsection {
            display: none;
        }

        /* #rhythm v3 (slider↔text spacing pass): whatever follows a description
           gets 12px of breathing room. Flex gap + margin do NOT stack reliably
           in our layout (measured: margin alone determines effective spacing
           between the desc and the next sibling), so set margin-top directly
           to 12px instead of relying on "parent 8 + 4 extra" composition.
           This prevents button rows and next-slider rows from jamming against
           the description above. */
        .param-desc + .fx-row,
        .param-desc + .btn-row,
        .param-desc + canvas,
        .model-group-desc + .fx-row,
        .model-group-desc + .btn-row {
            margin-top: var(--gap-after-desc);
        }

        /* (#vca-preview / .env-viz removed 2026-04-30 V1 triage D — Envelope
           Shape canvas cut. Generic canvas.preview rule retained for any
           remaining preview canvases.) */
        canvas.preview {
            margin-bottom: var(--spacing-sub);
        }

        /* #rhythm: model-group descriptions (under button rows)
           follow the same header/desc pairing — tight under the row. */
        .model-group-desc {
            font-size: 9px;
            color: rgba(200,180,150,0.5);
            text-align: center;
            margin-top: calc(-1 * var(--spacing-sub));
            padding: 0 var(--spacing-sub);
            line-height: 1.2;
        }

        /* Active-only descriptions: hide when not the current selection.
           Used by Model tab (modelGroupDesc active-only) so only the active
           model's group description is shown.
           2026-05-05 dead-CSS sweep (PR #2612): .fx-param-desc selector
           dropped from the comma list — fxParamDesc() creates .param-desc
           (the misnamed .fx-param-desc class never made it onto a node). */
        .model-group-desc.active-only-hidden,
        .param-desc.active-only-hidden {
            display: none;
        }

        /* Per-row model description slot: sits immediately after the button
           row whose active button was clicked. Only one slot is visible at a
           time (the one whose data-row matches the active waveform's group).
           applyModelDescVisibility() toggles the inline display and text. */
        .model-desc-slot {
            font-size: 0.55rem;
            color: var(--ot-text);
            opacity: 0.7;
            text-align: center;
            padding: 2px var(--spacing-sub) 4px;
            line-height: 1.3;
            margin-top: calc(-1 * var(--spacing-sub));
        }
        .model-desc-slot[data-row-active="false"] {
            display: none;
        }

        /* Button-group description: sits under a voicing/mode/etc. selector. */
        .btn-group-desc {
            cursor: pointer;
        }

        .fx-row {
            display: flex;
            align-items: center;
            gap: var(--spacing-unit);
            font-size: 0.6rem;
            /* #1134: vertical rhythm comes from parent gap, not row padding */
            padding: 0;
            /* Apple HIG 44px minimum touch target — full row is tappable */
            min-height: 44px;
            /* #rhythm: rely on parent flex gap (8px) — no extra margin. */
            margin-bottom: 0;
        }

        .fx-row label {
            width: 55px;
            flex-shrink: 0;
            color: var(--ot-muted);
            /* Item 1 (sample tab UX sweep 2026-04-20): label is NOT part of the
               slider hit target. Without this, tapping a 0.55rem label on touch
               can fall through to the slider below due to bubbling + the
               44px min-height row. Explicitly block range-style drag semantics
               on labels so the slider's hit zone is the slider itself. */
            touch-action: manipulation;
            pointer-events: auto;
        }

        .fx-row input[type="range"] {
            flex: 1;
            min-width: 0;
            height: 6px;
            /* Item 2 (sample tab UX sweep 2026-04-20): expand vertical hit
               zone without increasing the visible 6px track height. Vertical
               padding + background-clip keep the bar thin while giving the
               finger a 28px drag strip top-to-bottom. touch-action:none stops
               the browser from scrolling the panel when a finger drags on
               the slider. */
            padding: 11px 0;
            background-clip: content-box;
            touch-action: none;
            box-sizing: content-box;
        }

        .fx-row .fx-val {
            width: 45px;
            flex-shrink: 0;
            text-align: right;
            color: var(--ot-text);
            font-size: 0.55rem;
            /* Item 1: value display is NOT part of the slider hit target. */
            touch-action: manipulation;
        }

        /* Tick-label overlay (P0 #7 driveAmount Serge zones, reusable for
           other P0 zone-tick tasks). Renders label bar ABOVE the slider
           track, aligned to the input's width via flex:1 column wrap. */
        .fx-slider-wrap {
            flex: 1;
            min-width: 0;
            display: flex;
            flex-direction: column;
            gap: 2px;
        }
        .fx-slider-wrap input[type="range"] {
            flex: 0 0 auto;
            width: 100%;
            min-width: 0;
            height: 6px;
        }
        .fx-tick-labels {
            position: relative;
            height: 10px;
            margin-bottom: -1px;
            pointer-events: none;
            font-size: 0.45rem;
            line-height: 1;
            color: var(--ot-muted);
        }
        .fx-tick-labels .fx-tick-label {
            position: absolute;
            top: 0;
            transform: translateX(-50%);
            white-space: nowrap;
            opacity: 0.75;
            letter-spacing: 0.02em;
        }
        .fx-row.fx-row-with-tick-labels {
            /* Extra vertical room so the label bar doesn't collide with the
               44px touch target. Stays centered via flex align-items:center. */
            min-height: 52px;
        }

        /* Solo-viz row (TODO.md:376) — applied to wrappers that contain a
           single canvas/SVG visualization with NO co-rendered control.
           Promotes the wrapper to full panel width and gives it a viewport-
           aware shape. The canvas INSIDE keeps its intrinsic inline size and
           is centered (never stretched — see MEMORY feedback_viz_keep_aspect_ratio).

           Sizing strategy (viewport-aware, follow-up to PR #2649):
             - Phone (max-height: 500px, e.g. iPhone 13 mini landscape 780x360):
               wrapper claims full width but caps height at 40vh (~144px on a
               360px-tall viewport), letting the row stay compact relative to
               the screen — never more than ~40% of viewport height.
             - Tablet/desktop (min-height: 501px): wrapper takes 2:1
               aspect-ratio with a sane max-height (200px) so vizzes can
               breathe without dominating tall portrait screens either.
             - Aspect-ratio is INTENTIONALLY NOT applied on phone — at 780x360
               a 2:1 wrapper would be ~300px tall (~83% of screen), violating
               iPhone-first sizing.

           Excludes (do NOT mark with this class): meters (mix-level-meter,
           rec-vu-meter), waveform editor (3.75:1, would distort), and any
           paired-viz rows in model tab (not solo). */
        .fx-row--solo-viz {
            display: flex;
            justify-content: center;
            align-items: center;
            width: 100%;
            box-sizing: border-box;
            margin: 0;
        }

        /* Phone-class viewports (iPhone 13 mini landscape canonical default):
           cap wrapper height so it never dominates the tiny screen. Aspect
           floats — canvas still centers at intrinsic size. */
        @media (max-height: 500px) {
            .fx-row--solo-viz {
                max-height: 40vh;
            }
        }

        /* Tablet+ (iPad portrait/landscape, desktop): apply the deferred 2:1
           aspect-ratio to the OUTER wrapper. The canvas inside still draws at
           intrinsic size — the wrapper's larger box just gives breathing room
           and visually balances the row with the controls below. max-height
           prevents 2:1 from over-stretching on very wide viewports.

           Inline-style display:flex on the wrapper (set by tab JS) plus a
           small intrinsic canvas (200x40) collapses height in some flex layouts;
           we use min-height as a backstop alongside aspect-ratio to guarantee
           the wrapper actually claims 2:1 vertical space (clamped by max-height). */
        @media (min-height: 501px) {
            .fx-row--solo-viz {
                aspect-ratio: 2 / 1;
                min-height: 120px;
                max-height: 200px;
            }
        }

        /* --- Z-INDEX LAYERS ---
           Header (sticky): 20
           Panels (FX/Micro): 2
           Pads: 1
           Harp strip: 1
        */

        /* --- RESPONSIVE: iPhone 13 Mini (375px) and small phones --- */
        @media (max-width: 400px) {
            body {
                padding: 4px;
            }

            #header {
                padding: 4px 0;
            }
            #key-selector .sel-btn {
                font-size: 0.75rem;
            }
            #mode-selector .sel-btn {
                font-size: 0.75rem;
            }
            /* 2026-04-30 V1 triage D: #depth-selector responsive rules removed (cut/play-key-harp-drops). */

            #pad-container {
                padding: 4px 0;
            }

            .chord-pad .roman {
                font-size: 0.75rem;
            }

            .chord-pad .chord-name {
                font-size: 0.55rem;
            }

            .chord-pad .root-note {
                font-size: 0.4rem;
            }

            .mod-btn {
                font-size: 0.75rem;
            }

            #mode-row {
                flex-wrap: wrap;
                padding: 2px 0;
                gap: 4px;
            }
            #mode-row .mode-group {
                gap: 1px;
                padding: 1px;
            }
            #mode-row .toggle-btn {
                width: auto;
                min-width: 0;
                font-size: 0.55rem;
                padding: 4px 2px;
            }

            #harp-strip {
                height: 40px;
            }

            /* Descriptions always visible (? toggle removed) */
        }

        /* Very short viewports: tighten spacing further */
        @media (max-height: 700px) {
            #header {
                padding: 2px 0;
            }

            #pad-container {
                padding: 2px 0;
            }

            #mode-row {
                padding: 1px 0;
            }
        }

        /* Performance mode removed — see git history */

        /* ═══ iPad split layout ═══
           At >=720px (iPad mini 744px portrait and up), split the chord grid
           into two thumb zones: left (mods + row labels) flush left, right
           (pads + harp) flush right, with a dead zone in the center. No
           element resizing — only spacing. */
        @media (min-width: 720px) {
            /* Unlock full width for the pad area */
            #pad-harp-row {
                max-width: none;
                justify-content: center;
            }

            /* 8-column grid: 3 left cols + flexible gap + 4 right cols */
            #pad-container:not(.harp-active) {
                grid-template-columns: repeat(3, var(--pad-size)) minmax(60px, 1fr) repeat(4, var(--pad-size));
                max-width: none;
                width: 100%;
            }

            /* Chord pads: skip the gap column (col 4), land in cols 5-8.
               data-col attribute set in ui.js marks column position 0-3. */
            #pad-container:not(.harp-active) .chord-pad[data-col="0"],
            #pad-container:not(.harp-active) .empty-slot[data-col="0"] {
                grid-column: 5 !important;
            }
            #pad-container:not(.harp-active) .chord-pad[data-col="1"],
            #pad-container:not(.harp-active) .empty-slot[data-col="1"] {
                grid-column: 6 !important;
            }
            #pad-container:not(.harp-active) .chord-pad[data-col="2"],
            #pad-container:not(.harp-active) .empty-slot[data-col="2"] {
                grid-column: 7 !important;
            }
            #pad-container:not(.harp-active) .chord-pad[data-col="3"],
            #pad-container:not(.harp-active) .empty-slot[data-col="3"] {
                grid-column: 8 !important;
            }

            /* Header col-labels: shift from cols 4-7 to cols 5-8
               (override inline grid-column set in ui.js via data-col) */
            #pad-container:not(.harp-active) .col-label[data-col="0"] {
                grid-column: 5 !important;
            }
            #pad-container:not(.harp-active) .col-label[data-col="1"] {
                grid-column: 6 !important;
            }
            #pad-container:not(.harp-active) .col-label[data-col="2"] {
                grid-column: 7 !important;
            }
            #pad-container:not(.harp-active) .col-label[data-col="3"] {
                grid-column: 8 !important;
            }

            /* Harp mode: widen the gap between pad grid and harp strip.
               On iPad, let the harp strip grow wider so it's easier to strum
               with a full hand — span up to 4 pad-columns instead of 2. */
            #pad-harp-row.vertical-layout {
                gap: calc(var(--grid-col-gap) + 40px);
            }
            #pad-harp-row.vertical-layout #harp-strip.vertical {
                flex: 0 0 calc(4 * var(--pad-size) + 3 * var(--grid-col-gap));
                width: calc(4 * var(--pad-size) + 3 * var(--grid-col-gap));
            }

            /* Header: let it breathe across the wider viewport */
            #header {
                max-width: none;
            }

            /* Mode row + voicing row: match wider layout */
            #mode-row,
            #voicing-hold-row {
                max-width: none;
            }

            /* Effects panel: wider max-width for tablet */
            #effects-panel {
                max-width: 720px;
            }

            #piano-reg-stack {
                max-width: none;
            }
        }

        /* ═══ iPad Pro thirds layout ═══
           At ≥1024px width AND ≥768px height (iPad Pro portrait + landscape).
           Ports iPhone 13 mini landscape v4 3-column thirds pattern (#2049).

           Height guard (≥768px) excludes iPad mini landscape (1133×744)
           so its existing tablet layout is preserved.

           KEY MODE: mods flush LEFT (left thumb), pads flush RIGHT (right thumb).
             #pad-container spans viewport width; row-label col (col 3 of 7)
             is 1fr so mods (cols 1-2) pin left and pads (cols 4-7) pin right.

           HARP MODE: chord selectors LEFT, harp strip RIGHT, no mods.
             #pad-harp-row 3-column grid — [pad-intrinsic] [1fr] [harp-intrinsic].

           Pads stay at base size (48px — thumb is same size on iPad as iPhone).
             Extra screen real estate → wider gaps + padding, not larger targets.
             harp strip: 5 pad-columns wide
           #ipad-pro-thirds-2026-04-18
        */
        @media (min-width: 1024px) and (min-height: 768px) {
            /* iPad Pro: keep pad size at base (48px) — thumb ergonomics identical to iPhone.
               Extra space goes into gaps and padding, not target size. */
            :root {
                --grid-col-gap: 4px;
            }

            /* Pad-harp-row: span viewport edge-to-edge so pad-container (col 1)
               touches the left edge and harp-strip (col 3) touches the right edge.
               In key mode the pad-container itself spans full width (no grid needed).
               In harp mode (#pad-harp-row.vertical-layout) this becomes a 3-col grid
               below via the .vertical-layout override. */
            #pad-harp-row {
                width: 100vw;
                max-width: 100vw;
                margin-left: calc(50% - 50vw);
                margin-right: calc(50% - 50vw);
                padding: 0;
                gap: 0;
                justify-content: stretch;
                align-items: flex-start;
            }

            /* KEY MODE: pad-container spans full viewport width.
               7-col internal grid: [mod mod row-label(1fr) pad pad pad pad].
               Mods (cols 1-2) pin to left edge, row-label (col 3) expands as 1fr,
               pads (cols 4-7) pin to right edge.
               #ipad-pro-thirds-2026-04-18 */
            #pad-harp-row #pad-container {
                flex: 0 0 auto;
                max-width: none;
            }
            #pad-container:not(.harp-active) {
                grid-template-columns: var(--pad-size) var(--pad-size) 1fr repeat(4, var(--pad-size));
                width: 100%;
                max-width: 100%;
                align-content: start;
            }
            /* data-col 0-3 map to internal grid columns 4-7 */
            #pad-container:not(.harp-active) .chord-pad[data-col="0"],
            #pad-container:not(.harp-active) .empty-slot[data-col="0"],
            #pad-container:not(.harp-active) .col-label[data-col="0"] {
                grid-column: 4 !important;
            }
            #pad-container:not(.harp-active) .chord-pad[data-col="1"],
            #pad-container:not(.harp-active) .empty-slot[data-col="1"],
            #pad-container:not(.harp-active) .col-label[data-col="1"] {
                grid-column: 5 !important;
            }
            #pad-container:not(.harp-active) .chord-pad[data-col="2"],
            #pad-container:not(.harp-active) .empty-slot[data-col="2"],
            #pad-container:not(.harp-active) .col-label[data-col="2"] {
                grid-column: 6 !important;
            }
            #pad-container:not(.harp-active) .chord-pad[data-col="3"],
            #pad-container:not(.harp-active) .empty-slot[data-col="3"],
            #pad-container:not(.harp-active) .col-label[data-col="3"] {
                grid-column: 7 !important;
            }

            /* Mod buttons: larger touch targets for iPad Pro thumb reach. */
            .mod-btn {
                font-size: 0.75rem;
            }

            /* Chord pad labels: use default sizes (48px pads, same as iPhone). */

            /* HARP MODE: #pad-harp-row becomes a 3-column grid.
               [pad-intrinsic] [1fr filler] [harp-intrinsic].
               Chord selectors (pad-container) flush-left, harp strip flush-right.
               No mods in harp mode (display:none via global rule at line ~982).
               #ipad-pro-thirds-2026-04-18 */
            #pad-harp-row.vertical-layout {
                display: grid;
                grid-template-columns: auto 1fr auto;
                gap: 0;
                justify-content: stretch;
            }
            #pad-harp-row.vertical-layout #pad-container {
                grid-column: 1;
                justify-self: start;
                flex: 0 0 auto;
                width: auto;
                max-width: none;
            }
            #pad-harp-row.vertical-layout #harp-strip.vertical,
            #pad-harp-row #harp-strip {
                grid-column: 3;
                justify-self: end;
                flex: 0 0 auto;
                /* 5 pad-columns wide — larger than iPhone (3) and previous iPad (4).
                   Comfortable thumb strum area on iPad Pro. */
                width: calc(5 * var(--pad-size) + 4 * var(--grid-col-gap));
                min-width: calc(5 * var(--pad-size) + 4 * var(--grid-col-gap));
                /* Default harp height + flex-start alignment; top/bottom match
                   #pad-container box. #harp-top-align-2026-04-19 */
                align-self: flex-start;
            }

            /* Header + footer: constrain to middle third on iPad Pro.
               The pad/harp row (above) spans edge-to-edge for thumb reach;
               header and footer carry no interactive play content so they
               sit centered in the middle column (same width as the thirds
               gap between left-thumb zone and right-thumb zone).
               #ipad-pro-middle-third-2026-04-19 */
            #header {
                max-width: calc(100vw / 3);
            }

            /* Footer (#fab-bar): override the base inset:auto 0 0 0 so the
               bar is constrained to the middle third.  Safe-area insets on
               iPad Pro are 0 so removing the env() padding is harmless. */
            #fab-bar {
                left: calc(100vw / 3);
                right: calc(100vw / 3);
                padding-left: 0;
                padding-right: 0;
                border-top-left-radius: 4px;
                border-top-right-radius: 4px;
            }

            /* Other centered rows (mode-row, voicing, piano, effects-panel)
               keep their existing max-widths and remain horizontally centered. */
        }

        /* ═══ LANDSCAPE PHONES (iPhone 13 mini 812x375 etc.) ═══
           Placed AFTER @media (min-width: 720px) because 812×375 matches both.
           Later rules win, so landscape-specific overrides take precedence.

           Design priorities:
           1. Use the full viewport — no wasted empty space.
           2. Pads visible and large (≥48px) — the main interaction surface.
           3. Harp strip visible in Short/Tall modes, fills remaining width.
           4. Key mode: mods flush LEFT (left thumb), pads flush RIGHT (right thumb).
           5. Harp mode: chord selectors flush LEFT, harp strip flush RIGHT.
           6. Key/Mode selectors compact in a top strip (horizontal, not stacked).
           7. FX panel + tab bar hidden (use portrait for FX edits).

           Layout v4 (780×360 (iPhone 13 mini landscape), 36px footer reserved):

           KEY MODE:
             ┌──────────────────────────────────────────────────────────┐
             │ Key(~150px) │  ModeRow(10btns flex-fill)  │ Mode(~150px) │  ~42px
             ├─────────────────────────────────────────────────────── ──┤
             │ [mod mod row-label ──── 1fr ────── pad pad pad pad]      │  ~295px
             │  LEFT THUMB                           RIGHT THUMB        │
             └──────────────────────────────────────────────────────────┘

           HARP MODE:
             ┌──────────────────────────────────────────────────────────┐
             │ Key(~150px) │  ModeRow(10btns flex-fill)  │ Mode(~150px) │  ~42px
             ├──────────────────────────────────────────────────────────┤
             │ chord-selectors (4×56px) │ 1fr │  harp-strip (3×56px)   │  ~295px
             │  LEFT THUMB                         RIGHT THUMB          │
             └──────────────────────────────────────────────────────────┘
                              FAB bar (centered)                         36px

           v3 changes (2026-04-18):
           - safe-area-inset-left/right on play-tab-content (notch padding)
           - top row: keys LEFT (#header display:contents → key col 1),
             mode-row btns CENTER (col 2), mode selector RIGHT (col 3)
           - footer: #fab-bar-row max-width 50vw, margin 0 auto (centered)

           v4 changes (2026-04-18):
           - key mode: #pad-container spans full viewport width; row-label
             col (col 3 of 7-col grid) becomes 1fr so mods (cols 1-2) pin
             left and pads (cols 4-7) pin right — left-thumb mods, right-thumb pads
           - harp mode: unchanged (#pad-harp-row.vertical-layout already correct)
           - #iphone-landscape-v4-2026-04-18
        */
        @media (orientation: landscape) and (max-height: 500px) {
            :root {
                /* v3 portrait-size: --pad-size matches portrait (48px) so chord pads
                   and mod buttons stay at muscle-memory size even in landscape.
                   Previously 56px which made pads oversized vs portrait habit.
                   #landscape-layout-v3-2026-04-23 */
                --pad-size: 48px;
                --grid-col-gap: 3px;
                --landscape-top-h: 42px;
                /* --landscape-footer-h set globally on :root (36px); redeclared
                   here for clarity but same value so no effective change. */
                --landscape-footer-h: 36px;
            }

            /* !important beats unconditional @supports { body { padding-bottom: ... } }
               that comes later in the stylesheet. */
            body {
                padding: 0 !important;
                height: 100dvh;
                overflow: hidden;
            }

            /* Play tab: CSS Grid — row 1 holds [header | mode-row], row 2
               holds pad-harp-row across both columns. Header takes its
               natural content width; mode-row fills the rest of row 1.
               #footer-overlap-2026-04-18: explicitly override the portrait
               .main-tab-panel.active max-height so the grid isn't clipped
               to a portrait-sized box in landscape. */
            #play-tab-content {
                display: grid;
                /* middle-third (2026-04-19): header + mode-row span full grid width
                   but self-constrain to calc(100vw/3) via max-width + margin:auto.
                   pad-harp-row still spans full width via grid-column: 1/-1.
                   Grid now has 3 rows: header strip, mode-row, pad-harp-row.
                   #iphone-landscape-middle-third-2026-04-19 */
                grid-template-columns: 1fr;
                grid-template-rows: var(--landscape-top-h) var(--landscape-top-h) 1fr;
                height: calc(100dvh - var(--landscape-footer-h));
                max-height: calc(100dvh - var(--landscape-footer-h));
                width: 100vw;
                max-width: 100vw;
                overflow: hidden;
                /* v3: safe-area padding for notch (landscape notch = left or right edge).
                   2026-04-27 v9: top padding bumped 2 → 60px so play-tab-content
                   starts BELOW the new 60px-tall #fx-tab-bar-host
                   (#landscape-tabnav-60-2026-04-27). Without this bump the
                   header (E/A/D selectors etc.) renders behind the tab bar. */
                padding: 60px env(safe-area-inset-right, 4px) 0 env(safe-area-inset-left, 4px);
                gap: 3px;
                box-sizing: border-box;
            }

            /* ── TOP CONTROLS STRIP (middle-third 2026-04-19) ──
               #header is a real flex box (was display:contents in v3) so that
               max-width can constrain it to the middle third of the viewport.
               iPad Pro pattern, ported (#2085). At 812px wide, middle third ≈ 270px.
               Header children (key-selector .selector-group + #mode-depth-group)
               are flex items; key flush-left, mode-depth flush-right within header.
               #iphone-landscape-middle-third-2026-04-19 */
            /* ── TOP CONTROLS STRIP — landscape-layout-v3-2026-04-23 ──
               Portrait-size (48px) key+mode selectors stacked vertically in the
               center third. Previously key+mode were side-by-side in a 42px-tall
               strip at 20×18px — too small for muscle-memory parity with portrait.

               New layout (512px wide or less = iPhone landscape 812×375):
                 #header flex-direction:column → KEY 3×2 on top, MODE 3×2 below.
                 Each grid: 3×48+2×3=150px wide, 2×48+1×3=99px tall.
                 Header total height: 99+3+99 = 201px.

               Mode-row: 6 buttons (Open/Poly/Duo/Mono + Short/Tall) at 48px.
               flex-wrap:wrap + max-width = 3×48+2×3 = 150px → wraps to 3×2 grid.
               Each mode-group displays contents so buttons join the flex flow.
               Mode-row height: 2×48+1×3 = 99px.

               --landscape-stack-h (second block below): 201+3+99+3 = 306px.
               Fab-bar height: 375−306 = 69px (tab:26 + scope:15 + fabs:28).

               #landscape-layout-v3-2026-04-23 */

            #header {
                display: flex;
                /* Row: KEY 3×2 left, gap, MODE 3×2 right. User 2026-04-24:
                   "both 3×2s should fit on one row with a little gap".
                   Each grid: 3×40+2×3=126px. Side-by-side w/8px gap = 260px,
                   fits inside 100vw/3 = 260px center third on iPhone 13 mini
                   landscape (780px viewport). 2026-04-30 z-index audit:
                   was 12px gap → 264px → 4px horizontal overflow flagged
                   in audit. Tightened to 8px so the header stops scroll-
                   clipping; 8px is still visually distinct.
                   Anchor: #zindex-overflow-audit-2026-04-30 */
                flex-direction: row;
                flex-wrap: nowrap;
                align-items: center;
                justify-content: center;
                grid-column: 1 / -1;
                grid-row: 1;
                max-width: calc(100vw / 3);
                width: 100%;
                margin: 0 auto;
                /* Height = single 3×2 grid = 2×40+3 = 83px. */
                height: auto;
                gap: 8px;
                overflow: visible;
            }

            /* Key selector group — column flex child, centered. */
            #header > .selector-group:first-child {
                display: flex;
                flex-direction: row;
                flex-wrap: nowrap;
                align-items: center;
                justify-content: center;
                gap: 2px;
                height: auto;
                flex: 0 0 auto;
            }

            /* Mode selector group — column flex child, centered. */
            #mode-depth-group {
                display: flex;
                flex-direction: row;
                flex-wrap: nowrap;
                align-items: center;
                justify-content: center;
                gap: 2px;
                height: auto;
                flex: 0 0 auto;
            }

            .selector-group {
                flex: 0 0 auto;
                flex-direction: row;
                gap: 2px;
            }

            /* Key selector: 3×2 grid at portrait (48px) size.
               Width: 3×48+2×3=150px, Height: 2×48+1×3=99px.
               Matches portrait layout for muscle-memory parity.
               #landscape-layout-v3-2026-04-23 */
            #key-selector {
                grid-template-columns: repeat(3, 40px);
                grid-template-rows: repeat(2, 40px);
                grid-auto-flow: row;
                gap: 3px;
                align-items: center;
                justify-content: center;
                align-content: center;
            }
            #key-selector .sel-btn {
                width: 40px;
                height: 40px;
                aspect-ratio: 1;
                min-height: 40px;
                min-width: 40px;
                font-size: 0.6rem;
                padding: 0;
                padding-top: 1px;
            }
            #key-selector .empty-key-slot { display: none; }

            /* Mode selector: 3×2 grid at portrait (48px) size.
               Same size math as #key-selector above.
               #landscape-layout-v3-2026-04-23 */
            #mode-selector {
                grid-template-columns: repeat(3, 40px);
                grid-template-rows: repeat(2, 40px);
                grid-auto-flow: row;
                gap: 3px;
                align-items: center;
                justify-content: center;
                align-content: center;
            }
            #mode-selector .sel-btn {
                width: 40px;
                height: 40px;
                aspect-ratio: 1;
                min-height: 40px;
                min-width: 40px;
                font-size: 0.45rem;
                padding: 0;
                padding-top: 1px;
            }

            /* AC-3: chord-pad col-label + row-label (left-third mod-corner
               labels like MAJOR/MINOR/INNER/OUTER and TON/SUB/DOM/COL) raised
               above middle-third content. The companion `.selector-label`
               landscape rule was removed 2026-05-04 along with its HTML —
               the KEY/MODE spans were dead DOM (selector buttons
               self-describe). #landscape-v4-labels-2026-04-24 */
            .col-label,
            .row-label {
                position: relative;
                z-index: 10;
            }

            /* User 2026-04-25: rotate TON/SUB/DOM/COL row labels in landscape
               so they read vertically — key-mode CCW (text bottom-to-top, faces
               right toward pads), harp-mode CW (top-to-bottom, faces left toward pads).
               #row-label-rotation-2026-04-25
               #label-tuck-2026-04-25: transform-origin on the pad-facing edge so
               the rotated label is flush against the adjacent chord-pad row.
               padding-right zeroed (base rule has 4px) + margin:0 to eliminate
               pre-rotation space that becomes a visual gap post-rotation. */
            #pad-container:not(.harp-active) .row-label {
                transform: rotate(-90deg);
                transform-origin: right center;
                padding-right: 0;
                margin: 0;
            }
            #pad-container.harp-active .row-label {
                transform: rotate(90deg);
                transform-origin: left center;
                padding-left: 0;
                margin: 0;
            }

            /* ── MODE ROW: 3×2 grid of 48px buttons (landscape-layout-v3).  ──
               Portrait-size mode buttons in a 3-column flex that wraps to 2 rows.
               The 6 active buttons (Open/Poly/Duo/Mono + Short/Tall) fill a
               3×48+2×3=150px wide × 2×48+1×3=99px tall box, centered in the
               middle third.
               mode-group-left/right use display:contents so the 6 buttons become
               direct flex children and wrap together as a single flow.
               (mode-group-shake removed 2026-04-30 V1 triage D — cut/play-key-harp-drops.)
               #landscape-layout-v3-2026-04-23 */
            #mode-row {
                grid-column: 1 / -1;
                grid-row: 2;
                display: flex;
                padding: 0;
                gap: 3px;
                justify-content: center;
                align-items: flex-start;
                align-content: flex-start;
                height: auto;
                width: 100%;
                /* 2026-04-27: 6 voice-mode buttons (Open/Poly/Short/Duo/Mono/
                   Tall) on ONE row in landscape — match portrait layout per
                   user request. 6 × 40 + 5 × 3 = 255px, fits inside the
                   middle-third column (calc(100vw/3) = 260px on 780-wide
                   viewport). flex-wrap disabled.
                   Was: max-width 126px + flex-wrap:wrap → 3×2 layout.
                   #mode-row-single-row-landscape-2026-04-27 */
                max-width: calc(6 * 40px + 5 * 3px);
                margin: 0 auto;
                flex-wrap: nowrap;
                overflow: visible;
            }
            /* display:contents: mode-group children become direct flex items
               on #mode-row so flex-wrap applies across both groups uniformly.
               (mode-group-shake removed 2026-04-30 V1 triage D — cut/play-key-harp-drops.) */
            #mode-row .mode-group-left,
            #mode-row .mode-group-right {
                display: contents;
            }
            #mode-row .mode-btn {
                width: 40px;
                height: 40px;
                min-width: 40px;
                min-height: 40px;
                font-size: 0.48rem;
                padding: 0;
            }

            /* ── HIDDEN (landscape only) ── */
            /* !important needed: some rules (e.g. #play-glide{display:flex} at
               line ~2808, .fx-tab-content.active{display:flex}) come later in
               the stylesheet and would otherwise win on specificity. Using
               !important here ensures landscape always wins.
               #iphone-landscape-v4-2026-04-18 */
            #piano-reg-stack { display: none !important; }
            /* AC-1: Unhide voicing-hold-row so #latch-btn can be shown.
               Position it as a fixed overlay PINNED TO VIEWPORT BOTTOM-RIGHT
               (not below the harp's old 4-row position). The harp grows down
               to fill remaining vertical space via #harp-extend-landscape-bottom-2026-04-26
               (calc(100dvh - 224px) — see line ~4673). Sustain and
               voicing-group stay hidden. Width matches harp-strip:
               3*var(--pad-size) + 2*var(--grid-col-gap).
               2026-04-26 v5: was `top: calc(12px + 4*pad+4*gap)` which
               anchored Latch mid-viewport on iPhone landscape (812×375).
               Switched to `bottom: env(safe-area-inset-bottom) + 8px` so
               Latch tucks into the bottom-right corner above the home
               indicator — matches the v4 design intent on iPad and now
               applies on iPhone landscape too.
               #landscape-v4-latch-2026-04-24 #latch-pin-bottom-2026-04-26 */
            #voicing-hold-row {
                display: flex !important;
                position: fixed !important;
                top: auto !important;
                /* Match the safe-area-inset-right that pad-harp-row inherits
                   via play-tab-content padding. Without this, position:fixed
                   bypasses the inset and the latch sits right of the harp on
                   real devices (dynamic island side in landscape).
                   #latch-center-under-harp-2026-04-24 */
                right: env(safe-area-inset-right, 4px) !important;
                left: auto !important;
                bottom: calc(env(safe-area-inset-bottom, 0px) + 8px) !important;
                width: calc(3 * var(--pad-size) + 2 * var(--grid-col-gap)) !important;
                height: auto !important;
                padding: 0 !important;
                margin: 0 !important;
                gap: 0 !important;
                z-index: 2;
                pointer-events: auto;
                justify-content: center !important;
                align-items: center !important;
            }
            /* Hide voicing presets group — only latch shows in landscape. */
            #voicing-hold-row #voicing-group {
                display: none !important;
            }
            /* 2026-04-30 V1 triage D: #sustain-btn landscape rule removed (cut/play-key-harp-drops). */
            /* Latch: 48×48 matching harp-strip column width. */
            #voicing-hold-row #latch-btn {
                width: calc(3 * var(--pad-size) + 2 * var(--grid-col-gap)) !important;
                height: var(--pad-size) !important;
                flex: none !important;
                font-size: 0.55rem;
                padding: 0;
            }
            /* #mode-name-display landscape rule removed 2026-05-04 (dead). */
            #play-glide { display: none !important; }
            /* 2026-05-05 dead-CSS sweep (PR #2612): #play-fx-content dropped
               — element never created (legacy id from pre-#1132 IA). */
            #play-key-fx-content,
            #play-harp-fx-content { display: none !important; }
            /* #fx-tab-bar-host + #effects-panel: UNHIDDEN in landscape
               (was display:none, regression from PR #2110).
               #landscape-tabs-unhide-2026-04-19
               Layout: #fx-tab-bar-host pins to the top of #fab-bar as a
               compact strip so the user can tap any tab from landscape.
               #effects-panel becomes a fixed overlay filling the middle
               third above the tab strip only while it has .active
               (non-Play tab selected). On Play tab it stays hidden
               (default .main-tab-panel rule) so the scope viz fills
               the middle column per PR #2110's design. */

            /* ── PAD + HARP ROW: row 3 (was row 2 pre-middle-third), full width. ──
               Key mode (v4): #pad-container spans full viewport width; the
               row-label column (col 3 of the 7-col internal grid) is 1fr so
               mods (cols 1-2) pin to the left edge and pads (cols 4-7) pin
               to the right edge. Left thumb hits mods, right thumb hits pads.
               Harp mode: two-thumb grid — chord selectors flush-left,
               harp strip flush-right (via .vertical-layout below).
               2026-04-19: moved to grid row 3 because header + mode-row now
               occupy separate grid rows 1 and 2 (each middle-third constrained).
               #iphone-landscape-v4-2026-04-18 #iphone-landscape-middle-third-2026-04-19 */
            #pad-harp-row {
                grid-column: 1 / -1;
                grid-row: 3;
                width: 100%;
                max-width: none;
                padding: 0;
                gap: 0;
                align-items: flex-start;
                justify-content: flex-start;
                min-height: 0;
                overflow: hidden;
            }

            /* ── TWO-THUMB GRID (harp mode, landscape phone) ──
               Same pattern as iPad Pro two-thumb layout (PR #1997):
               3-column grid on #pad-harp-row — [pad-intrinsic] [1fr] [harp-intrinsic].
               Pad-container flush to left safe-area, harp flush to right safe-area.
               Harp sized at ~3 pad-columns (narrower than iPad's 4) to fit
               the 812px viewport. Negative-margin viewport-width breakout
               handles any parent padding added in the future (defensive).
               v3: safe-area padding on left/right so pads/harp respect the notch.
               #iphone-landscape-thumbs-2026-04-18 #iphone-landscape-v3-2026-04-18 */
            #pad-harp-row.vertical-layout {
                display: grid;
                grid-template-columns: auto 1fr auto;
                width: 100vw;
                max-width: 100vw;
                margin-left: calc(50% - 50vw);
                margin-right: calc(50% - 50vw);
                /* v3: safe-area so pads/harp don't hide under the notch */
                padding-left: env(safe-area-inset-left, 0px);
                padding-right: env(safe-area-inset-right, 0px);
                gap: 0;
                justify-content: stretch;
                align-items: flex-start;
            }

            /* Pad-container: grid column 1, flush-left.
               #harp-top-align-2026-05-06 (TODO line 519): bump pad-container
               down by 60px in harp mode so its TOP aligns with #harp-strip
               .vertical TOP, which is forced down by 60px for the iOS gesture
               zone (see #iphone-top-gesture-zone-v2-2026-04-27 below).
               Without this match, pad-container.top=0 vs harp.top=60 → 60px
               misalignment on the default viewport (iPhone 13 mini landscape).
               Key-mode pad-container keeps margin-top:0 (harp not shown). */
            #pad-harp-row.vertical-layout #pad-container {
                grid-column: 1;
                justify-self: start;
                flex: 0 0 auto;
                width: auto;
                margin-top: 60px;
            }

            /* v4 key-mode spread: override iPad 8-col grid and v3 centered layout.
               7-col grid where col 3 (row-label) expands as 1fr to push
               mods (cols 1-2) to the left edge and pads (cols 4-7) to the
               right edge of the full viewport width.
               #iphone-landscape-v4-2026-04-18 */
            #pad-container:not(.harp-active) {
                grid-template-columns: var(--pad-size) var(--pad-size) 1fr repeat(4, var(--pad-size));
                /* Match harp-active row template so the col-label row sizes to
                   its 12px content rather than the 48px grid-auto-rows track.
                   User 2026-04-25: 'full height row for the major-outer label
                   row that shouldn't be ... try matching harp layout instead'. */
                grid-template-rows: auto repeat(4, var(--pad-size));
                grid-auto-rows: var(--pad-size);
                align-content: start;
                width: 100%;
                max-width: 100%;
            }
            /* Undo iPad's column-5-through-8 reflow. */
            #pad-container:not(.harp-active) .chord-pad[data-col="0"],
            #pad-container:not(.harp-active) .empty-slot[data-col="0"],
            #pad-container:not(.harp-active) .col-label[data-col="0"] {
                grid-column: 4 !important;
            }
            #pad-container:not(.harp-active) .chord-pad[data-col="1"],
            #pad-container:not(.harp-active) .empty-slot[data-col="1"],
            #pad-container:not(.harp-active) .col-label[data-col="1"] {
                grid-column: 5 !important;
            }
            #pad-container:not(.harp-active) .chord-pad[data-col="2"],
            #pad-container:not(.harp-active) .empty-slot[data-col="2"],
            #pad-container:not(.harp-active) .col-label[data-col="2"] {
                grid-column: 6 !important;
            }
            #pad-container:not(.harp-active) .chord-pad[data-col="3"],
            #pad-container:not(.harp-active) .empty-slot[data-col="3"],
            #pad-container:not(.harp-active) .col-label[data-col="3"] {
                grid-column: 7 !important;
            }

            #pad-container {
                gap: var(--grid-col-gap);
                padding: 0;
                /* v4: align-content:start so rows pack at top, no negative
                   overflow into the controls row above.
                   #iphone-landscape-v4-2026-04-18 */
                align-content: start;
                /* 2026-05-06 Pad-grid clip safety (TODO.md:935 close-out).
                   The 4-row pad grid was previously 5px shy of clipping at
                   the worst-case landscape budget — see audit math in
                   LABEL_OVERLAP_AUDIT_2026-04-18.md:123-124 (pre-pad-size:48
                   regression: 5*56+4*3 = 292px in a 297px row 2 budget).
                   With --pad-size:48 (set above at line 2509) the grid now
                   lands well inside the budget, but a font-render drift on a
                   row-label or col-label could still push the bottom row past
                   the viewport. min-height:0 lets the grid honour its parent
                   `1fr` track without the implicit `min-content` floor that
                   defeats overflow:hidden when a child reports a taller
                   intrinsic size. Pairs with the existing min-height:0 on
                   #pad-harp-row at line 2859 + overflow:hidden at 2860.
                   max-height:100% caps the box to its grid track.
                   #pad-grid-landscape-clip-safety-2026-05-06 */
                min-height: 0;
                max-height: 100%;
                overflow: hidden;
            }

            /* Col labels: tiny, so they don't eat vertical space. */
            .col-label {
                height: 12px;
                font-size: 0.4rem;
                padding: 0;
            }
            .row-label { font-size: 0.4rem; }

            /* Pad label sizes tuned for 56px pads. */
            .chord-pad { font-size: 0.6rem; }
            .chord-pad .roman { font-size: 0.7rem; }
            .chord-pad .chord-name { font-size: 0.45rem; }
            .chord-pad .root-note { font-size: 0.35rem; }
            .mod-btn { font-size: 0.6rem; }

            /* Landscape: hide harp-spacer-row — portrait-only layout equalizer.
               Landscape layout is fully space-constrained (overflow:hidden);
               the spacer would add unwanted height below the 4 data rows. */
            .harp-spacer-row {
                display: none;
            }

            /* Harp strip: grid column 3, flush-right.
               ~3 pad-columns wide (narrower than iPad's 4) to fit 812px viewport.
               Height matches #pad-container box: col-label row (12px in
               landscape — see .col-label override above) + row-gap +
               4 pad rows + 3 row-gaps. pad-container padding is 0 in landscape.
               #harp-top-align-2026-04-19 */
            #pad-harp-row.vertical-layout #harp-strip.vertical {
                grid-column: 3;
                justify-self: end;
                flex: 0 0 auto;
                width: calc(3 * var(--pad-size) + 2 * var(--grid-col-gap));
                min-width: calc(3 * var(--pad-size) + 2 * var(--grid-col-gap));
                /* 2026-04-26 v6: extend harp down to within ~16px of the
                   Latch button (which is now at viewport bottom-right via
                   #latch-pin-bottom-2026-04-26).
                   2026-04-27 v8: top buffer 32px → 60px. v7's 32px was still
                   inside the iOS swipe-down gesture zone on iPhone 13 mini —
                   user reported "top harp pad is too high and swiping down
                   on it causes system gesture issues" 2026-04-27. iOS
                   reserves up to ~50pt at the top edge of full-screen
                   landscape pages (no URL bar visible) for notification +
                   control center swipe-down. 60px gives a few px of safety
                   margin. env(safe-area-inset-top) is 0 on landscape iPhone
                   (Dynamic Island is on the side, not top) so we hardcode.
                   Tap-only UI (chord pads in left col) stays at y=15 —
                   taps don't conflict with the swipe gesture, only sustained
                   drag/swipe does.
                   Calc: 100dvh − (60 top margin + 16 gap + 48 latch + 8 latch
                   bottom margin) = 100dvh − 132px. Plus safe-area-inset-bottom
                   for devices with home indicator.
                   #harp-extend-iphone-landscape-2026-04-26
                   #iphone-top-gesture-zone-2026-04-26
                   #iphone-top-gesture-zone-v2-2026-04-27 */
                height: calc(100dvh - env(safe-area-inset-bottom, 0px) - 132px);
                max-height: calc(100dvh - env(safe-area-inset-bottom, 0px) - 132px);
                align-self: flex-start;
                margin-top: 60px !important;
            }
            #harp-strip.vertical { min-height: 100px; }

            /* ── FOOTER FAB BAR (compact, middle-third 2026-04-19) ──
               v3 (2026-04-18): #fab-bar spanned full viewport width; #fab-bar-row
               was 50vw and centered inside.
               2026-04-19 (iPad Pro pattern port, #2085): #fab-bar itself is now
               constrained to the middle third. inset:auto 0 0 0 from the base
               rule (css line 339) is overridden with left/right:calc(100vw/3)
               so the bar occupies the middle third only. Pad/harp row (above)
               still spans edge-to-edge for thumb reach.
               #iphone-landscape-v3-2026-04-18 #iphone-landscape-middle-third-2026-04-19 */
            #fab-bar {
                position: fixed !important;
                height: var(--landscape-footer-h);
                left: calc(100vw / 3);
                right: calc(100vw / 3);
                padding-left: 0;
                padding-right: 0;
                border-top-left-radius: 4px;
                border-top-right-radius: 4px;
            }
            #fab-bar-status { display: none; }
            #fab-bar-row {
                height: var(--landscape-footer-h);
                min-height: 0;
                padding: 0;
                /* 2026-04-19: parent #fab-bar is now middle-third (100vw/3).
                   Fill parent fully (no extra max-width needed since parent
                   already constrains width). */
                max-width: 100%;
                margin-left: auto;
                margin-right: auto;
                justify-content: center;
            }
            #fab-bar #audio-fab {
                width: 28px;
                height: 28px;
                font-size: 12px;
            }
            #fab-bar #mix-scope {
                /* #footer-viz-21: keep 2:1 aspect in landscape. 56×28 fits
                   the 36px compact footer with breathing room. */
                width: 56px !important;
                height: auto !important;
                aspect-ratio: 2 / 1;
                max-width: 56px;
            }
            #note-loop-container { gap: 4px; }
            #note-loop-container > div { gap: 4px; }
            #note-loop-container button {
                width: 20px;
                height: 20px;
                min-width: 20px;
                font-size: 9px;
            }
        }

        /* ═══ LANDSCAPE MIDDLE-COLUMN VERTICAL STACK (2026-04-19) ═══════════════
           Collapses the header-band + footer-band + voicing-row into a single
           vertical flex column in the middle third of the viewport. Eliminates
           the pre-existing huge vertical void (760px on iPad Pro, 295px on
           iPhone 13 mini) between header-bottom and fab-bar-top.

           Stack (top→bottom) in the middle third:
             1. Key row + Mode selector (header) at top
             2. Mode-row (Open/Poly/Duo/…/Short/Tall/Strm/Exct/Arp)
             3. Voicing + Latch + Sustain (voicing-hold-row; iPad Pro only —
                iPhone landscape has no vertical room at 375px tall)
             4. Scope viz fills remaining vertical space (flex:1)
             5. Audio FAB + note loopers row at bottom (~44px)

           Left third: #pad-container (mods+pads, key mode) or chord selectors
             (harp mode) flush LEFT. Right third: #harp-strip flush RIGHT.

           Implementation:
             • #pad-harp-row becomes position:absolute covering the full viewport.
               Its internal 3-col grid (harp mode) or internal 7-col grid (key
               mode) still positions pad-container on the LEFT and harp-strip
               on the RIGHT. pointer-events:none on the wrapper so taps pass
               through the empty middle third; re-enabled on pad-container +
               harp-strip so they remain interactive.
             • #header, #mode-row, #voicing-hold-row: constrained to
               max-width:calc(100vw/3) + margin:0 auto, stacked via flex-column
               order 1/2/3 in #play-tab-content. z-index:2 so they render above
               the absolute-positioned pad-harp-row's transparent center.
             • #fab-bar (outside #play-tab-content — remains a viewport-level
               sibling so FX tab switch doesn't hide it) uses position:fixed
               with top calculated to sit just below the header-stack's bottom.
               Its internal #fab-bar-row becomes a CSS grid where #mix-scope
               spans row 1 filling available vertical space, and the
               audio-fab + note-loopers sit in row 2 at the bottom.
             • #mix-scope aspect-ratio released + width/height:100% so it
               grows tall-narrow in the middle column while keeping its
               horizontal sweep direction (canvas backing-store stays 192×96,
               CSS stretches display — pixel density drops but sweep is intact).

           Applies to iPad Pro landscape (≥1024×768, landscape) AND iPhone
           landscape (≤500px tall, landscape). Portrait layouts untouched.
           #landscape-middle-column-2026-04-19 */

        /* iPad Pro landscape (orientation gated — portrait iPad Pro uses
           existing layout with full-width mode-row + voicing + flex flow). */
        @media (min-width: 1024px) and (min-height: 768px) and (orientation: landscape) {
            :root {
                /* Height reserved at the top of the middle column for
                   header + mode-row + voicing-hold-row + a few px gap.
                   108(header) + 52(mode-row) + 52(voicing) + 12(gap) = 224. */
                --landscape-stack-h: 224px;
            }

            #play-tab-content {
                position: relative;
                display: flex;
                flex-direction: column;
                align-items: stretch;
                height: 100dvh;
                max-height: 100dvh;
                padding: 0;
                overflow: hidden;
            }

            /* pad-harp-row: full-viewport absolute overlay, pointer-events:none
               so taps in the middle third fall through to the fab-bar + stack
               elements above. Children re-enable pointer-events to stay
               interactive on the edges. */
            #pad-harp-row {
                position: absolute;
                top: 0;
                left: 0;
                right: 0;
                height: 100dvh;
                width: 100vw;
                margin: 0;
                pointer-events: none;
                z-index: 1;
                align-items: flex-start;
            }
            #pad-harp-row #pad-container,
            #pad-harp-row #harp-strip {
                pointer-events: auto;
            }

            /* Middle-column stack (flex children, order 1/2/3). */
            #header {
                order: 1;
                max-width: calc(100vw / 3);
                width: 100%;
                margin: 0 auto;
                z-index: 2;
                position: relative;
                flex-shrink: 0;
            }
            #mode-row {
                order: 2;
                max-width: calc(100vw / 3);
                width: 100%;
                margin: 0 auto;
                z-index: 2;
                position: relative;
                flex-shrink: 0;
            }
            #voicing-hold-row {
                order: 3;
                max-width: calc(100vw / 3);
                width: 100%;
                margin: 0 auto;
                z-index: 2;
                position: relative;
                flex-shrink: 0;
                justify-content: center;
            }

            /* Landscape-only hides: piano + glide + per-mode FX strips do not
               fit the new middle-column density. FX still reachable via tab. */
            #piano-reg-stack { display: none !important; }
            #play-glide { display: none !important; }
            /* 2026-05-05 dead-CSS sweep (PR #2612): #play-fx-content dropped
               — element never created (legacy id from pre-#1132 IA). */
            #play-key-fx-content,
            #play-harp-fx-content { display: none !important; }

            /* fab-bar: fixed from --landscape-stack-h top to viewport bottom,
               middle-third width. Internal fab-bar-row is a 2-row grid with
               mix-scope on top (flex:1) and audio+loopers at bottom. */
            #fab-bar {
                position: fixed !important;
                top: var(--landscape-stack-h) !important;
                bottom: 0 !important;
                left: calc(100vw / 3) !important;
                right: calc(100vw / 3) !important;
                height: auto !important;
                max-height: none !important;
                padding-left: 0;
                padding-right: 0;
                padding-top: 0;
                padding-bottom: env(safe-area-inset-bottom, 0px);
                z-index: 3;
                overflow: hidden;
                border-top-left-radius: 6px;
                border-top-right-radius: 6px;
            }
            #fab-bar #fx-tab-bar-host {
                display: none !important;
            }
            #fab-bar-row {
                display: grid !important;
                grid-template-rows: 1fr auto;
                grid-template-columns: auto 1fr auto;
                row-gap: 4px;
                column-gap: 6px;
                width: 100%;
                height: 100%;
                min-height: 0;
                /* #fab-bar-row-padbottom-ipad-landscape-2026-05-06 (TODO.md:932):
                   bottom 26px reserves space for #fab-version + #perf-latency
                   so they don't overlap mix-scope or the FAB row. */
                padding: 4px 8px 26px;
                flex: 1 1 auto;
            }
            #fab-bar-status { display: none; }
            #fab-bar #mix-scope {
                grid-row: 1 / 2;
                grid-column: 1 / -1;
                width: 100% !important;
                height: 100% !important;
                max-width: none !important;
                aspect-ratio: auto !important;
                min-width: 0;
                margin: 0 !important;
            }
            #fab-bar #fab-bar-left {
                grid-row: 2;
                grid-column: 1;
                justify-self: start;
            }
            #fab-bar #fab-bar-right {
                grid-row: 2;
                grid-column: 3;
                justify-self: end;
            }
            #fab-bar #fab-bar-left button,
            #fab-bar #audio-fab {
                width: 32px;
                height: 32px;
            }
            #note-loop-container { gap: 4px; }
        }

        /* iPhone landscape (≤500px tall, landscape) — portrait-size button layout.
           landscape-layout-v3-2026-04-23: key+mode at 48px stacked vertically in
           header, mode-row 6 buttons wrapping to 3×2 at 48px each.
           Stack-h math (812×375):
             2px top-padding + header(key-3×2=99 + 3gap + mode-3×2=99 = 201px)
             + 3px flex-gap + mode-row(3×2 at 48px = 2×48+3gap = 99px)
             = 2 + 201 + 3 + 99 = 305px → use 306px for 1px safety.
           Fab-bar height: 375−306 = 69px (tab-strip:26 + scope:15 + fabs:28).
           voicing-hold-row stays hidden: no vertical room at 375px height after
           header(201px) + mode-row(99px) stack uses 300px of 375px. */
        @media (orientation: landscape) and (max-height: 500px) {
            :root {
                /* AC-1: tab nav moved to top (28px); base stack height increases
                   by 28px. Stack math: 28(tab) + 83(header) + 3(gap) + 83(mode) = 197 → 200.
                   #landscape-nav-top-2026-04-25 */
                --landscape-stack-h: 200px;
            }

            /* Override the earlier iphone-landscape grid layout — switch to
               flex-column with the absolute pad-harp overlay pattern.
               padding-top: 28px reserves space below the tab nav strip
               (fixed at top: 0, height: 28px) so #header starts below it.
               #landscape-nav-top-2026-04-25 */
            #play-tab-content {
                display: flex !important;
                flex-direction: column !important;
                grid-template-columns: none !important;
                grid-template-rows: none !important;
                position: relative;
                align-items: stretch;
                height: 100dvh !important;
                max-height: 100dvh !important;
                overflow: hidden;
                /* 2026-04-27 v9: 28→60 to match the new 60px-tall
                   #fx-tab-bar-host (#landscape-tabnav-60-2026-04-27). */
                padding-top: 60px !important;
            }

            #pad-harp-row {
                position: absolute !important;
                top: 0 !important;
                left: 0 !important;
                right: 0 !important;
                height: 100dvh !important;
                width: 100vw !important;
                margin: 0 !important;
                pointer-events: none;
                z-index: 1;
                grid-column: auto !important;
                grid-row: auto !important;
                /* #landscape-overflow-img3321: safe-area padding for the
                   landscape notch (left OR right depending on rotation).
                   In harp mode, .vertical-layout rule at CSS:2789 already
                   applies the same padding; this generic rule covers key
                   mode where the pad-container would otherwise hide under
                   the notch. box-sizing:border-box (set on * at CSS:103)
                   keeps the 100vw width inclusive of padding. */
                padding-left: env(safe-area-inset-left, 0px);
                padding-right: env(safe-area-inset-right, 0px);
            }
            #pad-harp-row #pad-container,
            #pad-harp-row #harp-strip {
                pointer-events: auto;
            }

            #header {
                order: 1;
                grid-column: auto !important;
                grid-row: auto !important;
                z-index: 2;
                position: relative;
                flex-shrink: 0;
            }
            #mode-row {
                order: 2;
                grid-column: auto !important;
                grid-row: auto !important;
                z-index: 2;
                position: relative;
                flex-shrink: 0;
            }

            /* ── AC-1: Tab nav fixed at top of middle third (landscape) ──
               #fx-tab-bar-host is pulled out of #fab-bar visually by overriding
               it with position:fixed top:0. DOM stays intact (still a child of
               #fab-bar) but renders as a viewport-anchored strip. z-index:6 sits
               above #effects-panel (z:4) and #fab-bar (z:3) so tabs are always
               tappable. #landscape-nav-top-2026-04-25 */
            /* 2026-04-27 v9: tab nav 60px tall in landscape (was 28). Doubles
               as iOS-gesture-zone buffer above the harp/content (harp keeps
               its own 60px margin-top regardless).
               2026-04-27 v12: full visual pass on the landscape nav bar.
                 - Font 0.52rem (8.32px) → 13px so labels are actually
                   readable. Mixed case (no `text-transform: uppercase`)
                   so "Config" / "Model" fit in the narrow ~43px-per-tab
                   slot at the larger size.
                 - Inactive tabs: 0.55 opacity (muted but not invisible),
                   no bg.
                 - Active tab: bright accent text + a 3px bottom border in
                   the accent color (var(--ot-accent)) + soft accent-tinted
                   bg. iOS-style "current" indicator.
                 - Subtle 1px divider at the bar's bottom edge so the bar
                   reads as a discrete chrome strip, not floating text.
                 - Tap feedback: brief `:active` darken via the existing
                   global `.fx-tab-btn:active` rule (line 597) — preserved.
               #landscape-tabnav-60-2026-04-27
               #landscape-tabnav-v12-2026-04-27 */
            #fx-tab-bar-host {
                position: fixed !important;
                top: 0 !important;
                left: calc(100vw / 3) !important;
                right: calc(100vw / 3) !important;
                width: auto !important;
                height: 60px !important;
                max-height: 60px !important;
                z-index: 6 !important;
                overflow: hidden !important;
                display: block !important;
                flex-shrink: 0;
                background: rgba(20, 17, 14, 0.92) !important;
                border-bottom: 1px solid rgba(200, 168, 88, 0.20) !important;
                box-sizing: border-box !important;
            }
            #fx-tab-bar-host .fx-tab-bar {
                height: 60px;
                gap: 1px;
            }
            #fx-tab-bar-host .fx-tab-btn {
                font-size: 11px;
                line-height: 1.1;
                padding: 0 2px;
                height: 60px !important;
                min-height: 60px;
                text-transform: none;
                letter-spacing: 0;
                color: var(--ot-textSoft, #888) !important;
                opacity: 0.55;
                background: transparent;
                border: none;
                border-bottom: 3px solid transparent !important;
                box-sizing: border-box;
                transition: color 0.12s, opacity 0.12s, background 0.12s, border-color 0.12s;
            }
            #fx-tab-bar-host .fx-tab-btn.active {
                color: var(--ot-accent, #c8a858) !important;
                opacity: 1 !important;
                background: rgba(200, 168, 88, 0.12);
                border-bottom-color: var(--ot-accent, #c8a858) !important;
                font-weight: 600;
            }
            #fx-tab-bar-host .fx-tab-btn[data-tabid="settings"] {
                font-size: 11px;
            }
            /* #fab-bar #fx-tab-bar-host: position:fixed above removes it from
               fab-bar's layout flow automatically. No display:none needed — the
               fixed element renders at top:0, not inside the fab-bar visual box. */

            /* fab-bar: same middle-third fixed strip but taller so the scope
               can grow. Overrides the earlier iphone-landscape #fab-bar block
               which pinned height to --landscape-footer-h. */
            #fab-bar {
                position: fixed !important;
                top: var(--landscape-stack-h) !important;
                bottom: 0 !important;
                left: calc(100vw / 3) !important;
                right: calc(100vw / 3) !important;
                height: auto !important;
                max-height: none !important;
                z-index: 3;
                padding-bottom: env(safe-area-inset-bottom, 0px);
                overflow: hidden;
            }
            #fab-bar-row {
                display: grid !important;
                grid-template-rows: minmax(0, 1fr) auto !important;
                grid-template-columns: auto 1fr auto !important;
                row-gap: 2px;
                column-gap: 4px;
                width: 100%;
                /* #fab-vertical-fit-2026-04-25: flex:1 1 0 (not auto) prevents
                   content-based expansion beyond #fab-bar's fixed height.
                   minmax(0,1fr) on the scope row allows it to shrink below
                   the canvas intrinsic size so FABs in row 2 stay in viewport.
                   overflow:hidden clips any intrinsic overflow from the canvas. */
                height: 100% !important;
                min-height: 0;
                overflow: hidden !important;
                /* #fab-bar-row-padbottom-landscape-2026-05-06 (TODO.md:932):
                   bottom 26px reserves a clear strip for #fab-version +
                   #perf-latency (position:fixed at viewport bottom, but inside
                   the middle-third strip in landscape). Without this, the FAB
                   row 2 (audio/mic/note-loop) sat at viewport y~326-358 while
                   the badges sat at y~348-358, causing the badges to overlap
                   the FABs. Pushing the FAB row up by 26px clears the badges
                   into the safe strip below the FAB row. */
                padding: 2px 4px 26px;
                flex: 1 1 0;
                max-width: 100%;
            }
            #fab-bar #mix-scope {
                grid-row: 1 / 2 !important;
                grid-column: 1 / -1 !important;
                width: 100% !important;
                /* min-height:0 allows canvas to shrink below intrinsic size
                   within the minmax(0,1fr) grid track. */
                height: 100% !important;
                min-height: 0 !important;
                max-width: none !important;
                aspect-ratio: auto !important;
                margin: 0 !important;
            }
            #fab-bar #fab-bar-left {
                grid-row: 2;
                grid-column: 1;
                justify-self: start;
            }
            #fab-bar #fab-bar-right {
                grid-row: 2;
                grid-column: 3;
                justify-self: end;
            }

            /* #effects-panel: fixed overlay in the middle third. Hidden by
               default (no .active class); the .main-tab-panel.active rule
               (css line 1818 — display:flex) surfaces it when a non-Play
               tab is selected.
               Layout (at 780×360) after 2026-04-27 v9 60px tab nav:
                   [  0 …  60] #fx-tab-bar-host (tab strip, z:6) — always visible
                   [ 60 … 360] #effects-panel overlay (Z:4)      — covers middle
                       when a non-Play tab is selected.
               top: 60px so the fixed tab strip stays tappable above it.
               2026-04-30 z-index audit: was top:28 (stale from pre-v9 when
               tab strip was 28px tall). Resulted in tab labels being
               covered by the effects-panel content top — user-flagged
               "nav bar being behind the tab content" issue.
               #landscape-tabs-unhide-2026-04-19 #landscape-nav-top-2026-04-25
               #zindex-overflow-audit-2026-04-30 */
            #effects-panel.main-tab-panel.active {
                display: flex !important;
                position: fixed !important;
                top: 60px !important;
                bottom: env(safe-area-inset-bottom, 0px) !important;
                left: calc(100vw / 3) !important;
                right: calc(100vw / 3) !important;
                /* #landscape-overflow-img3321: base rule at CSS:1861 sets
                   width:100%; combined with position:fixed + left/right the
                   resolved width became 100vw (812px on iPhone 13 mini
                   landscape), pushing Model tab content off the right edge.
                   width:auto !important defers to left/right so the panel
                   sizes to the middle third (100vw/3 ≈ 270px). */
                width: auto !important;
                max-height: none !important;
                max-width: none !important;
                margin: 0 !important;
                z-index: 4 !important;
                background: rgba(20, 17, 14, 0.97);
                border-top: 1px solid rgba(200,160,80,0.2);
                overflow-y: auto;
                overflow-x: hidden;
                padding: 4px 6px 12px;
                flex-direction: column;
            }

            /* ── AC-1: Gate header / mode-row / voicing-hold-row to Play tab ──
               #play-tab-content is always display:flex in landscape (line 3182).
               When a non-Play tab is active, #play-tab-content loses .active.
               Hide the play controls so the middle third is clear for the
               #effects-panel fixed overlay.
               #landscape-controls-into-play-tab-2026-04-25 */
            #play-tab-content:not(.active) #header,
            #play-tab-content:not(.active) #mode-row,
            #play-tab-content:not(.active) #voicing-hold-row {
                display: none !important;
            }

            /* 2026-04-27: mode-row — 6 voice-mode buttons (Open/Poly/Short/
               Duo/Mono/Tall) on ONE row in landscape, matching portrait.
               Was split into 2×2 + 1×2 (135px wide, 83px tall) per the
               2026-04-25 #landscape-controls-into-play-tab block.
               User feedback 2026-04-27: "the key and harp mode voicing
               buttons (open to tall) go on one row, just like portrait".
               6 × 40px + 5 × 3px = 255px wide, 40px tall. Fits inside
               middle third (calc(100vw/3) = 260px on 780-wide viewport).
               #mode-row-single-row-landscape-2026-04-27 */
            #mode-row {
                flex-wrap: nowrap !important;
                flex-direction: row !important;
                max-width: calc(6 * 40px + 5 * 3px) !important;
                gap: 3px !important;
                align-items: center !important;
                justify-content: center !important;
            }
            /* mode-group-left/right use display:contents so the 6 buttons
               become direct flex children of #mode-row and lay out left-to-
               right in one nowrap row. */
            #mode-row .mode-group-left,
            #mode-row .mode-group-right {
                display: contents !important;
            }
            /* 2026-04-30 V1 triage D: #mode-row .mode-group-shake landscape
               rule removed with the buttons (cut/play-key-harp-drops). */

            /* ── 2026-04-25 voicing-bar v2 phase A — UNIFIED voicing-bar layout
               in landscape across BOTH modes (was harp-only).
               body.harp-mode is toggled by setAppMode() in ui.js.
               body.play-tab-active is toggled by showMainTab() in ui.js.

               Old behavior (deprecated): voicing-bar visible only when both
               harp-mode AND play-tab-active were true. Key-mode users in
               landscape had no way to switch voicings.

               New behavior: voicing-bar visible whenever play-tab-active.
               Mode-specific voicing visibility (HARP_VISIBLE_VOICINGS gating)
               is handled in JS via applyVoicingBarHarpFilter() — DATA layer,
               not CSS layer.

               Layout: 18 buttons × 22px wrap to two rows in the calc(100vw/3)
               middle-third width (~11 per row).
               Stack math: 28(tab) + 83(header) + 3(gap) + 57(voicing-margin)
                         + 83(mode) = 254px → 257px for 3px safety.
               #voicing-bar-v2-phase-a-2026-04-25

               2026-04-30 z-index audit: scoped to :not(.harp-mode) so the JS
               inline-style display:none (set by setAppMode in ui.js when
               MODE_VOICINGS[preset] === null for harp short/tall) wins.
               Without this exclusion the !important display:flex was forcing
               all 18 voicing buttons to render in 5 rows, vertically
               overflowing into mode-row. Voicing is forced in harp mode
               (cluster/sparse per MODE_PRESETS), so the bar serves no purpose
               there and would only add noise + overlap.
               Anchor: #zindex-overflow-audit-2026-04-30 */
            body.play-tab-active:not(.harp-mode) #voicing-hold-row #voicing-group {
                display: flex !important;
                position: fixed !important;
                /* 2026-04-30 fix: header in landscape is 87px tall (was
                   stale 83 in the comment) and starts at y=60 (28px tab
                   strip above). Header bottom = 147; voicing-bar must sit
                   below that. Set top:150 for a 3px clearance gap.
                   Anchor: #landscape-voicing-overlap-fix-2026-04-30 */
                top: 150px !important;
                left: calc(100vw / 3) !important;
                right: calc(100vw / 3) !important;
                bottom: auto !important;
                width: calc(100vw / 3) !important;
                height: auto !important;
                overflow: hidden !important;
                flex-wrap: wrap !important;
                align-items: flex-start !important;
                align-content: flex-start !important;
                justify-content: flex-start !important;
                padding: 4px !important;
                gap: 2px !important;
                z-index: 5 !important;
                pointer-events: auto !important;
                box-sizing: border-box !important;
            }
            /* 2026-04-30 voicing-buttons polish: button size grew from 22×22
               with 0.36rem labels (undecipherable on iPhone 13 mini) to 60×24
               with 0.55rem labels (matches .sel-btn standard). Up to 4
               voicings visible per mode (MODE_VOICINGS in ui.js) so 4×60px +
               3×2px gap + 8px pad = 254px fits comfortably in calc(100vw/3) =
               260px on iPhone 13 mini. flex-direction: column on parent
               #voicing-group ensures voicing-bar always sits ABOVE any other
               children that may be added in the future (currently the only
               child is #voicing-bar; column declaration is self-documenting).
               Anchor: #voicing-buttons-polish-2026-04-30 */
            body.play-tab-active:not(.harp-mode) #voicing-hold-row #voicing-group {
                flex-direction: column !important;
            }
            body.play-tab-active:not(.harp-mode) #voicing-hold-row #voicing-group .sel-btn {
                width: 60px !important;
                height: 24px !important;
                min-width: 60px !important;
                min-height: 24px !important;
                font-size: 0.55rem !important;
                padding: 0 !important;
                flex: 0 0 auto !important;
            }
            body.play-tab-active:not(.harp-mode) #voicing-hold-row #voicing-group #voicing-bar {
                display: flex !important;
                flex-wrap: wrap !important;
                gap: 2px !important;
                align-content: flex-start !important;
                width: 100% !important;
            }
            /* Push mode-row down by voicing-bar height to clear Row B in key
               modes (harp-mode hides voicing-bar entirely — JS sets display:none
               on #voicing-group via setAppMode in ui.js when MODE_VOICINGS[preset]
               is null). Single row × 24px + 2×4px pad = 32px + 3px safety = 35px.
               (Was 57px for old 2-row × 22px layout.)
               2026-04-30 z-index audit: scoped to :not(.harp-mode) — without
               the voicing-bar present in harp mode, mode-row no longer needs
               the 35px push, and the harp can use its full vertical space.
               Anchor: #zindex-overflow-audit-2026-04-30 */
            body.play-tab-active:not(.harp-mode) #mode-row {
                margin-top: 35px !important;
            }
            /* Expand --landscape-stack-h on BOTH modes to include voicing row.
               Was 257px for old 2-row × 22px voicing. Now single row × 24px
               saves ~22px. 28(tab) + 83(header) + 3(gap) + 35(voicing-margin)
               + 83(mode) = 232px. */
            body.play-tab-active {
                --landscape-stack-h: 232px;
            }
        }

        /* #footer-overlap-2026-04-18: the @supports override below was
           previously hardcoding `calc(156px + env(safe-area-inset-bottom))`
           which drifted from the 160px constant in .main-tab-panel.active.
           Now body's own padding-bottom uses --footer-total-h (which already
           includes env(safe-area-inset-bottom, 0px) — browsers without env()
           support fall through to 0 automatically). The block is kept as a
           no-op safety net for browsers that support env(); it references
           the same variable so it can never drift again. */
        @supports (padding-bottom: env(safe-area-inset-bottom)) {
            body {
                padding-bottom: var(--footer-total-h);
            }
        }
        #waveform-canvas { cursor: crosshair; }

        /* Sample mode indicator on chord pads */
        .chord-pad.sample-mode::after {
            content: '♪';
            position: absolute;
            top: 2px;
            right: 3px;
            font-size: 0.5rem;
            opacity: 0.5;
            color: var(--ot-cat-spatial);
        }

        /* Piano canvas in sample mode: show pointer cursor for base pitch tapping */
        #piano-canvas.sample-mode-active {
            cursor: crosshair;
        }

        /* 2026-05-05 dead-CSS sweep (PR #2612): .session-stats-modal +
           backdrop/panel/title/row/buttons removed. The Shift+S session-stats
           overlay was cut 2026-04-30 (V1 triage D, ui.js:5268 comment); the
           CSS hung around as orphan rules. tests.js:16011 already asserts
           the modal element is absent. */

/* Play-tab glide sliders — match .fx-tab-content styling */
#play-glide {
    display: none !important;
}

/* #1653: Play-tab FX host containers span full 7-column grid width
   so assigned FX sections (sliders, button rows) are visually wider
   and readable. Centered to match the pad grid above. */
#play-key-fx-content,
#play-harp-fx-content {
    width: 100%;
    max-width: var(--grid-width);
    margin: 0 auto;
    box-sizing: border-box;
}

/* Mobile UI: larger touch targets + readability on iPhone (iPhone 13 and similar).
   Includes iPhone landscape (780×360 iPhone 13 mini canonical viewport) via the
   second media-query clause so .mode-btn / #latch-btn keep 44px touch targets
   when device is rotated. */
@media (max-width: 430px), (orientation: landscape) and (max-height: 500px) {
    /* Footer perf stats contrast bump */
    #perf-stats {
        font-size: 0.55rem;
    }

    /* Mode buttons — readable text + 44px min touch target */
    .mode-btn {
        font-size: 0.65rem;
        min-height: 44px;
    }

    /* Latch — ensure 44px min touch target.
       (Sustain button removed 2026-04-30 V1 triage D — cut/play-key-harp-drops.) */
    #latch-btn {
        min-height: 44px;
        min-width: 44px;
        font-size: 0.65rem;
    }
}

/* ---------------------------------------------------------------------------
   CRT Phosphor App-Wide Theming (#phosphor-app-theme)
   ---------------------------------------------------------------------------
   When the Settings tab's CRT Phosphor picker is set to a color (not 'Off'),
   the body receives a `data-phosphor="green|amber|red|grey"` attribute and the
   ENTIRE app chrome (pads, mod buttons, tab bar, sliders, text, borders,
   accents) retunes to match the phosphor color palette. When the picker is
   'Off', the attribute is removed and the app returns to its default theme.

   Intensity mapping (per phosphor palette trace color):
     - primary accent       = trace at ~0.9 (brightest)
     - secondary accent     = trace at ~0.6
     - rare accent          = trace at ~0.4
     - borders              = dim
     - text                 = trace scaled
     - bg                   = palette.bg (near-black tinted)

   Hex trace values (from engine.js CRT_PALETTES):
     green: #33ff33  amber: #ffaa00  red: #ff3333  grey: #cccccc

   IMPORTANT: do NOT add --ot-warning to any body[data-phosphor=*] block below.
   --ot-warning is a hardcoded amber (#c8a858) used exclusively for warning-tier
   surfaces (#mic-fab, #audio-fab.suspended, .recording-indicator). It must stay
   visually distinct from the user's chosen UI highlight even under phosphor themes.
   See :root --ot-warning declaration and phosphor-exceptions.js for rationale.
   -------------------------------------------------------------------------- */

body[data-phosphor="green"] {
    --ot-bg: #050a05;
    --ot-panel: #081208;
    --ot-surface: #0a1a0a;
    --ot-text: var(--ot-green-bright);
    --ot-textMid: #28cc28;
    --ot-textSoft: var(--ot-green-soft);
    --ot-muted: #2a9a2a;
    --ot-accent: #33ff33;
    --ot-depth1: var(--ot-green-bright);
    --ot-depth2: var(--ot-green-soft);
    --ot-depth3: #157015;
    --ot-empty: #030803;
    --ot-harpLine: #0f3a0f;
    --ot-borderMid: #2a702a;
    --ot-microBreakdown: #0a1a0a;
    --ot-fxShape: var(--ot-green-soft);
    --ot-fxFilter: #28cc28;
    --ot-fxAmp: var(--ot-green-bright);
    --ot-fxLpg: var(--ot-green-soft);
    --ot-fxDelay: #157015;
    --ot-fxReverb: #1a801a;
    --ot-fxGlide: #28cc28;
    --ot-fxSpatial: var(--ot-green-soft);
    --ot-fabGreen: var(--ot-green-soft);
    --ot-fabRed: #ff3333;
    --ot-cat-shape: #33ff33;
    --ot-cat-filter: var(--ot-green-bright);
    --ot-cat-amp: var(--ot-green-bright);
    --ot-cat-lpg: #28cc28;
    --ot-cat-pitch: #28cc28;
    --ot-cat-tuning: var(--ot-green-soft);
    --ot-cat-delay: #157015;
    --ot-cat-reverb: #1a801a;
    --ot-cat-spatial: var(--ot-green-bright);
    --ot-infoCbw: var(--ot-green-soft);
    --ot-infoCbwParallel: #1a801a;
    --ot-panelAlt: #0f2a0f;
    --ot-panelHover: #183018;
    --ot-textOnAccent: #000000;
}

body[data-phosphor="amber"] {
    --ot-bg: #0a0805;
    --ot-panel: #120e08;
    --ot-surface: #1a140a;
    --ot-text: var(--ot-amber-bright);
    --ot-textMid: var(--ot-amber-mid);
    --ot-textSoft: var(--ot-amber-soft);
    --ot-muted: #aa6e00;
    --ot-accent: #ffaa00;
    --ot-depth1: var(--ot-amber-bright);
    --ot-depth2: var(--ot-amber-soft);
    --ot-depth3: #705000;
    --ot-empty: #080503;
    --ot-harpLine: #3a2a0f;
    --ot-borderMid: #704f00;
    --ot-microBreakdown: #1a140a;
    --ot-fxShape: var(--ot-amber-soft);
    --ot-fxFilter: var(--ot-amber-mid);
    --ot-fxAmp: var(--ot-amber-bright);
    --ot-fxLpg: var(--ot-amber-soft);
    --ot-fxDelay: #705000;
    --ot-fxReverb: #805500;
    --ot-fxGlide: var(--ot-amber-mid);
    --ot-fxSpatial: var(--ot-amber-soft);
    --ot-fabGreen: var(--ot-amber-mid);
    --ot-fabRed: #ff3333;
    --ot-cat-shape: #ffaa00;
    --ot-cat-filter: var(--ot-amber-bright);
    --ot-cat-amp: var(--ot-amber-bright);
    --ot-cat-lpg: var(--ot-amber-mid);
    --ot-cat-pitch: var(--ot-amber-mid);
    --ot-cat-tuning: var(--ot-amber-soft);
    --ot-cat-delay: #705000;
    --ot-cat-reverb: #805500;
    --ot-cat-spatial: var(--ot-amber-bright);
    --ot-infoCbw: var(--ot-amber-soft);
    --ot-infoCbwParallel: #805500;
    --ot-panelAlt: #2a1e0f;
    --ot-panelHover: #382810;
    --ot-textOnAccent: #000000;
}

body[data-phosphor="red"] {
    --ot-bg: #0a0505;
    --ot-panel: #120808;
    --ot-surface: #1a0a0a;
    --ot-text: var(--ot-red-bright);
    --ot-textMid: #cc2828;
    --ot-textSoft: var(--ot-red-soft);
    --ot-muted: #801a1a;
    --ot-accent: #ff3333;
    --ot-depth1: var(--ot-red-bright);
    --ot-depth2: var(--ot-red-soft);
    --ot-depth3: #701515;
    --ot-empty: #080303;
    --ot-harpLine: #3a0f0f;
    --ot-borderMid: #702a2a;
    --ot-microBreakdown: #1a0a0a;
    --ot-fxShape: var(--ot-red-soft);
    --ot-fxFilter: #cc2828;
    --ot-fxAmp: var(--ot-red-bright);
    --ot-fxLpg: var(--ot-red-soft);
    --ot-fxDelay: #701515;
    --ot-fxReverb: #801a1a;
    --ot-fxGlide: #cc2828;
    --ot-fxSpatial: var(--ot-red-soft);
    --ot-fabGreen: #2ee62e;
    --ot-fabRed: #ff3333;
    --ot-cat-shape: #ff3333;
    --ot-cat-filter: var(--ot-red-bright);
    --ot-cat-amp: var(--ot-red-bright);
    --ot-cat-lpg: #cc2828;
    --ot-cat-pitch: #cc2828;
    --ot-cat-tuning: var(--ot-red-soft);
    --ot-cat-delay: #701515;
    --ot-cat-reverb: #801a1a;
    --ot-cat-spatial: var(--ot-red-bright);
    --ot-infoCbw: var(--ot-red-soft);
    --ot-infoCbwParallel: #801a1a;
    --ot-panelAlt: #2a0f0f;
    --ot-panelHover: #381818;
    --ot-textOnAccent: #ffffff;
}

body[data-phosphor="grey"] {
    --ot-bg: #080808;
    --ot-panel: #101010;
    --ot-surface: #181818;
    --ot-text: #b8b8b8;
    --ot-textMid: #a0a0a0;
    --ot-textSoft: #808080;
    --ot-muted: #606060;
    --ot-accent: #cccccc;
    --ot-depth1: #b8b8b8;
    --ot-depth2: #808080;
    --ot-depth3: #505050;
    --ot-empty: #050505;
    --ot-harpLine: #282828;
    --ot-borderMid: #585858;
    --ot-microBreakdown: #181818;
    --ot-fxShape: #808080;
    --ot-fxFilter: #a0a0a0;
    --ot-fxAmp: #b8b8b8;
    --ot-fxLpg: #808080;
    --ot-fxDelay: #505050;
    --ot-fxReverb: #606060;
    --ot-fxGlide: #a0a0a0;
    --ot-fxSpatial: #808080;
    --ot-fabGreen: #808080;
    --ot-fabRed: #e0e0e0;
    --ot-cat-shape: #cccccc;
    --ot-cat-filter: #b8b8b8;
    --ot-cat-amp: #b8b8b8;
    --ot-cat-lpg: #a0a0a0;
    --ot-cat-pitch: #a0a0a0;
    --ot-cat-tuning: #808080;
    --ot-cat-delay: #505050;
    --ot-cat-reverb: #606060;
    --ot-cat-spatial: #b8b8b8;
    --ot-infoCbw: #808080;
    --ot-infoCbwParallel: #606060;
    --ot-panelAlt: #282828;
    --ot-panelHover: #383838;
    --ot-textOnAccent: #000000;
}

body[data-phosphor="light"] {
    --ot-bg: #f5f0e8;
    --ot-panel: #ece5d4;
    --ot-surface: #e0d8c0;
    --ot-text: #1a1208;
    --ot-textMid: #4a3a20;
    --ot-textSoft: #8a7a60;
    --ot-muted: #806050;
    --ot-accent: #6a4a10;
    --ot-depth1: #4a3a20;
    --ot-depth2: #8a7a60;
    --ot-depth3: #aaa090;
    --ot-empty: #ede8de;
    --ot-harpLine: #c8baa0;
    --ot-borderMid: #c8baa0;
    --ot-microBreakdown: #e0d8c0;
    --ot-fxShape: #8a7a60;
    --ot-fxFilter: #4a3a20;
    --ot-fxAmp: #1a1208;
    --ot-fxLpg: #8a7a60;
    --ot-fxDelay: #aaa090;
    --ot-fxReverb: #6a4a10;
    --ot-fxGlide: #4a3a20;
    --ot-fxSpatial: #8a7a60;
    --ot-fabGreen: #2a6a2a;
    --ot-fabRed: #aa2222;
    --ot-cat-shape: #6a8a60;
    --ot-cat-filter: #4a6a70;
    --ot-cat-amp: #8a5030;
    --ot-cat-lpg: #8a3050;
    --ot-cat-pitch: #6a4a70;
    --ot-cat-tuning: #3a6a60;
    --ot-cat-delay: #3a5080;
    --ot-cat-reverb: #504080;
    --ot-cat-spatial: #6a6010;
    --ot-infoCbw: #5a6070;
    --ot-infoCbwParallel: #3a5860;
    --ot-panelAlt: #d8d0b8;
    --ot-panelHover: #c8c0a0;
    --ot-textOnAccent: #f0efe8;
}

body[data-phosphor="off-light"] {
    --ot-bg: #f8f8f8;
    --ot-panel: #efefef;
    --ot-surface: #e5e5e5;
    --ot-text: #222222;
    --ot-textMid: #444444;
    --ot-textSoft: #777777;
    --ot-muted: #6e6e6e;
    --ot-accent: #333333;
    --ot-depth1: #444444;
    --ot-depth2: #777777;
    --ot-depth3: #999999;
    --ot-empty: #f0f0f0;
    --ot-harpLine: #c0c0c0;
    --ot-borderMid: #c0c0c0;
    --ot-microBreakdown: #e5e5e5;
    --ot-fxShape: #777777;
    --ot-fxFilter: #444444;
    --ot-fxAmp: #222222;
    --ot-fxLpg: #777777;
    --ot-fxDelay: #999999;
    --ot-fxReverb: #333333;
    --ot-fxGlide: #444444;
    --ot-fxSpatial: #777777;
    --ot-fabGreen: #2a6a2a;
    --ot-fabRed: #aa2222;
    --ot-cat-shape: #447744;
    --ot-cat-filter: #336677;
    --ot-cat-amp: #774422;
    --ot-cat-lpg: #774455;
    --ot-cat-pitch: #664488;
    --ot-cat-tuning: #336655;
    --ot-cat-delay: #335577;
    --ot-cat-reverb: #443377;
    --ot-cat-spatial: #665522;
    --ot-infoCbw: #445566;
    --ot-infoCbwParallel: #336655;
    --ot-panelAlt: #d8d8d8;
    --ot-panelHover: #c8c8c8;
    --ot-textOnAccent: #f8f8f8;
}

/* ---------------------------------------------------------------------------
   UI Scanlines Overlay (#ui-scanlines-overlay)
   ---------------------------------------------------------------------------
   Full-screen fixed overlay that paints the CRT scanline pattern across the
   ENTIRE app UI when phosphor is on AND uiScanlinesEnabled is true.
   Matches the viz-canvas scanline style (single-pass rgba(0,0,0,0.12) every
   3rd row, per crt-viz-helpers.js + PR #1956). See engine.js uiScanlinesEnabled
   and tabs/settings-tab.js for the toggle.

   Visibility gate:
     - Hidden by default (no phosphor attr on body).
     - Hidden when body[data-ui-scanlines="off"] even if phosphor is on.
     - Visible when body has data-phosphor attr (non-off) AND data-ui-scanlines
       is unset/"on".
   -------------------------------------------------------------------------- */
#ui-scanlines-overlay {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 9999;
    display: none;
    /* Repeating scanline pattern: 1px of rgba(0,0,0,0.12) every 3px (1px dark
       + 2px transparent). Matches crtDrawScanlines() in crt-viz-helpers.js.
       Fixed attachment so the pattern doesn't drift when the page scrolls.
       Use background-size to anchor the pattern so it tiles consistently. */
    background-image: repeating-linear-gradient(
        to bottom,
        rgba(0, 0, 0, 0.12) 0,
        rgba(0, 0, 0, 0.12) 1px,
        transparent 1px,
        transparent 3px
    );
    background-attachment: fixed;
}
/* Show overlay when phosphor is active (any non-off color) AND the UI scanlines
   toggle is not explicitly off. */
body[data-phosphor] #ui-scanlines-overlay {
    display: block;
}
body[data-ui-scanlines="off"] #ui-scanlines-overlay {
    display: none;
}

/* ---------------------------------------------------------------------------
   Strict monochrome enforcement (#phosphor-strict-mono)
   ---------------------------------------------------------------------------
   When body[data-phosphor] is set, ABSOLUTELY no color except black and the
   phosphor trace/palette should appear in app chrome. Active button text that
   would normally render white on a bright accent background is swapped to
   black (#000) for maximum contrast within the monochrome palette.

   Scope: only matches under body[data-phosphor] so the default brown/gold
   theme (phosphor off) is untouched.
   -------------------------------------------------------------------------- */

/* Active selector buttons: black text on bright phosphor accent.
   Scoped to DARK phosphor themes only — light/off-light use dark accents
   (depth1 is near-black) so they need white/light text from --ot-textOnAccent.
   A-1 contrast fix: black-on-dark-brown under light was CR 1.13 (WCAG fail). */
body[data-phosphor="green"] .sel-btn.active,
body[data-phosphor="green"] .sel-btn.active[data-rarity="primary"],
body[data-phosphor="green"] .sel-btn.active[data-rarity="secondary"],
body[data-phosphor="green"] .sel-btn.active[data-rarity="rare"],
body[data-phosphor="green"] #voicing-bar .sel-btn.active,
body[data-phosphor="green"] .mode-btn.active,
body[data-phosphor="green"] #latch-btn.active,
body[data-phosphor="green"] .mod-btn.active,
body[data-phosphor="green"] .mod-btn[data-role="primary"].active,
body[data-phosphor="green"] .mod-btn[data-role="secondary"].active,
body[data-phosphor="green"] .mod-btn[data-role="rare"].active,
body[data-phosphor="amber"] .sel-btn.active,
body[data-phosphor="amber"] .sel-btn.active[data-rarity="primary"],
body[data-phosphor="amber"] .sel-btn.active[data-rarity="secondary"],
body[data-phosphor="amber"] .sel-btn.active[data-rarity="rare"],
body[data-phosphor="amber"] #voicing-bar .sel-btn.active,
body[data-phosphor="amber"] .mode-btn.active,
body[data-phosphor="amber"] #latch-btn.active,
body[data-phosphor="amber"] .mod-btn.active,
body[data-phosphor="amber"] .mod-btn[data-role="primary"].active,
body[data-phosphor="amber"] .mod-btn[data-role="secondary"].active,
body[data-phosphor="amber"] .mod-btn[data-role="rare"].active,
body[data-phosphor="red"] .sel-btn.active,
body[data-phosphor="red"] .sel-btn.active[data-rarity="primary"],
body[data-phosphor="red"] .sel-btn.active[data-rarity="secondary"],
body[data-phosphor="red"] .sel-btn.active[data-rarity="rare"],
body[data-phosphor="red"] #voicing-bar .sel-btn.active,
body[data-phosphor="red"] .mode-btn.active,
body[data-phosphor="red"] #latch-btn.active,
body[data-phosphor="red"] .mod-btn.active,
body[data-phosphor="red"] .mod-btn[data-role="primary"].active,
body[data-phosphor="red"] .mod-btn[data-role="secondary"].active,
body[data-phosphor="red"] .mod-btn[data-role="rare"].active,
body[data-phosphor="blue"] .sel-btn.active,
body[data-phosphor="blue"] .sel-btn.active[data-rarity="primary"],
body[data-phosphor="blue"] .sel-btn.active[data-rarity="secondary"],
body[data-phosphor="blue"] .sel-btn.active[data-rarity="rare"],
body[data-phosphor="blue"] #voicing-bar .sel-btn.active,
body[data-phosphor="blue"] .mode-btn.active,
body[data-phosphor="blue"] #latch-btn.active,
body[data-phosphor="blue"] .mod-btn.active,
body[data-phosphor="blue"] .mod-btn[data-role="primary"].active,
body[data-phosphor="blue"] .mod-btn[data-role="secondary"].active,
body[data-phosphor="blue"] .mod-btn[data-role="rare"].active,
body[data-phosphor="white"] .sel-btn.active,
body[data-phosphor="white"] .sel-btn.active[data-rarity="primary"],
body[data-phosphor="white"] .sel-btn.active[data-rarity="secondary"],
body[data-phosphor="white"] .sel-btn.active[data-rarity="rare"],
body[data-phosphor="white"] #voicing-bar .sel-btn.active,
body[data-phosphor="white"] .mode-btn.active,
body[data-phosphor="white"] #latch-btn.active,
body[data-phosphor="white"] .mod-btn.active,
body[data-phosphor="white"] .mod-btn[data-role="primary"].active,
body[data-phosphor="white"] .mod-btn[data-role="secondary"].active,
body[data-phosphor="white"] .mod-btn[data-role="rare"].active,
body[data-phosphor="grey"] .sel-btn.active,
body[data-phosphor="grey"] .sel-btn.active[data-rarity="primary"],
body[data-phosphor="grey"] .sel-btn.active[data-rarity="secondary"],
body[data-phosphor="grey"] .sel-btn.active[data-rarity="rare"],
body[data-phosphor="grey"] #voicing-bar .sel-btn.active,
body[data-phosphor="grey"] .mode-btn.active,
body[data-phosphor="grey"] #latch-btn.active,
body[data-phosphor="grey"] .mod-btn.active,
body[data-phosphor="grey"] .mod-btn[data-role="primary"].active,
body[data-phosphor="grey"] .mod-btn[data-role="secondary"].active,
body[data-phosphor="grey"] .mod-btn[data-role="rare"].active {
    color: var(--ot-textOnPhosphor);
}

/* Light + off-light phosphor themes: active buttons keep --ot-textOnAccent
   (near-white) since depth1 is dark brown/grey — black text would be invisible. */
body[data-phosphor="light"] .sel-btn.active,
body[data-phosphor="light"] .sel-btn.active[data-rarity="primary"],
body[data-phosphor="light"] .sel-btn.active[data-rarity="secondary"],
body[data-phosphor="light"] .sel-btn.active[data-rarity="rare"],
body[data-phosphor="light"] #voicing-bar .sel-btn.active,
body[data-phosphor="light"] .mode-btn.active,
body[data-phosphor="light"] #latch-btn.active,
body[data-phosphor="light"] .mod-btn.active,
body[data-phosphor="light"] .mod-btn[data-role="primary"].active,
body[data-phosphor="light"] .mod-btn[data-role="secondary"].active,
body[data-phosphor="light"] .mod-btn[data-role="rare"].active,
body[data-phosphor="off-light"] .sel-btn.active,
body[data-phosphor="off-light"] .sel-btn.active[data-rarity="primary"],
body[data-phosphor="off-light"] .sel-btn.active[data-rarity="secondary"],
body[data-phosphor="off-light"] .sel-btn.active[data-rarity="rare"],
body[data-phosphor="off-light"] #voicing-bar .sel-btn.active,
body[data-phosphor="off-light"] .mode-btn.active,
body[data-phosphor="off-light"] #latch-btn.active,
body[data-phosphor="off-light"] .mod-btn.active,
body[data-phosphor="off-light"] .mod-btn[data-role="primary"].active,
body[data-phosphor="off-light"] .mod-btn[data-role="secondary"].active,
body[data-phosphor="off-light"] .mod-btn[data-role="rare"].active {
    color: var(--ot-textOnAccent);
}

/* Chord pad active/selected: black text on bright phosphor accent fill.
   Dark themes only — light/off-light accent is dark, needs light text. */
body[data-phosphor="green"] .chord-pad.active-touch,
body[data-phosphor="green"] .chord-pad.harp-selected,
body[data-phosphor="amber"] .chord-pad.active-touch,
body[data-phosphor="amber"] .chord-pad.harp-selected,
body[data-phosphor="red"] .chord-pad.active-touch,
body[data-phosphor="red"] .chord-pad.harp-selected,
body[data-phosphor="blue"] .chord-pad.active-touch,
body[data-phosphor="blue"] .chord-pad.harp-selected,
body[data-phosphor="white"] .chord-pad.active-touch,
body[data-phosphor="white"] .chord-pad.harp-selected,
body[data-phosphor="grey"] .chord-pad.active-touch,
body[data-phosphor="grey"] .chord-pad.harp-selected {
    color: #000000 !important;
}

/* Key-shift badge (long-press semitone nudge) — in phosphor mode, flip the
   badge text to black and use a light backdrop so it matches the phosphor
   palette without leaking non-phosphor color. No text-shadow glow
   (user directive 2026-04-19). */
body[data-phosphor] .key-shift-badge {
    color: var(--ot-textOnPhosphor);
    background: rgba(255, 255, 255, 0.75);
}

/* Audio FAB icon — the FAB itself keeps its semantic bg color (green/orange/
   red state indicator), but the inside glyph should render in the phosphor
   palette's darkest tone so it's readable without introducing non-phosphor
   white. Under phosphor mode, pick black for the glyph. */
body[data-phosphor] #audio-fab {
    color: var(--ot-textOnPhosphor);
}

/* Perf-stats failure indicators — the inline <span style="color:#ff4040"> etc.
   added by updatePerfStats() in ui.js render literal red/orange, which breaks
   monochrome. Remap them to the phosphor accent (bright palette tone) via
   attribute selectors. The !important is required because the spans set
   inline style="color:..." which would otherwise win specificity. */
body[data-phosphor] #perf-stats span[style*="color:#ff4040"],
body[data-phosphor] #perf-stats span[style*="color: #ff4040"],
body[data-phosphor] #perf-stats span[style*="color:#ffa020"],
body[data-phosphor] #perf-stats span[style*="color: #ffa020"] {
    color: var(--ot-accent) !important;
}

/* Footer toast CRT override — cream/gold hue leaks under phosphor themes */
body[data-phosphor] #footer-toast {
    color: var(--ot-accent);
}

/* Sample-mode ♪ indicator CRT override — gold hue leak */
body[data-phosphor] .chord-pad.sample-mode::after {
    color: var(--ot-accent);
}


/* ---------------------------------------------------------------------------
   CRT Phosphor 3-Scope Theming (Phase A) (#phosphor-3scope)
   ---------------------------------------------------------------------------
   Three independent body[data-phosphor-*] attribute selectors for scoped CRT
   phosphor control:

     data-phosphor-nav      — navigation chrome (tabs, buttons, labels, sliders, text)
     data-phosphor-playable — playable canvases ONLY (piano viz + harp strum)
     data-phosphor-viz      — all other visualizations (scope, spectrum, etc.)

   Phase A: Nav rules reproduce the existing body[data-phosphor] theme.
   Playable + Viz rules are empty stubs — canvas wiring is Phase B.

   #131 rename 2026-04-19: data-phosphor-playable → data-phosphor-playable.
   The scope was formerly "Instrument" and covered piano+harp+slider-thumbs.
   It now covers ONLY piano/harp canvases — thumbs moved to Nav (UI chrome).

   Values: green | amber | red | grey | light | off-light
   'off' is handled by removing the attribute entirely (no rule needed).

   IMPORTANT: do NOT add --ot-warning to any body[data-phosphor-nav=*] block below.
   --ot-warning is a hardcoded amber (#c8a858) used exclusively for warning-tier
   surfaces (#mic-fab, #audio-fab.suspended, .recording-indicator). It must stay
   visually distinct from the user's chosen UI highlight even under phosphor themes.
   See :root --ot-warning declaration and phosphor-exceptions.js for rationale.
   -------------------------------------------------------------------------- */

/* --- NAV: green --- */
body[data-phosphor-nav="green"] {
    --ot-bg: #050a05;
    --ot-panel: #081208;
    --ot-surface: #0a1a0a;
    --ot-text: #2ee62e;
    --ot-textMid: #28cc28;
    --ot-textSoft: #1fa01f;
    --ot-muted: #2a9a2a;
    --ot-accent: #33ff33;
    --ot-depth1: #2ee62e;
    --ot-depth2: #1fa01f;
    --ot-depth3: #157015;
    --ot-empty: #030803;
    --ot-harpLine: #0f3a0f;
    --ot-borderMid: #2a702a;
    --ot-microBreakdown: #0a1a0a;
    --ot-fxShape: #1fa01f;
    --ot-fxFilter: #28cc28;
    --ot-fxAmp: #2ee62e;
    --ot-fxLpg: #1fa01f;
    --ot-fxDelay: #157015;
    --ot-fxReverb: #1a801a;
    --ot-fxGlide: #28cc28;
    --ot-fxSpatial: #1fa01f;
    --ot-fabGreen: #1fa01f;
    --ot-fabRed: #ff3333;
}

/* --- NAV: amber --- */
body[data-phosphor-nav="amber"] {
    --ot-bg: #0a0805;
    --ot-panel: #120e08;
    --ot-surface: #1a140a;
    --ot-text: #e69900;
    --ot-textMid: #cc8800;
    --ot-textSoft: #a07000;
    --ot-muted: #aa6e00;
    --ot-accent: #ffaa00;
    --ot-depth1: #e69900;
    --ot-depth2: #a07000;
    --ot-depth3: #704a00;
    --ot-empty: #080500;
    --ot-harpLine: #3a2a0f;
    --ot-borderMid: #7a5a00;
    --ot-microBreakdown: #1a140a;
    --ot-fxShape: #a07000;
    --ot-fxFilter: #cc8800;
    --ot-fxAmp: #e69900;
    --ot-fxLpg: #a07000;
    --ot-fxDelay: #704a00;
    --ot-fxReverb: #805500;
    --ot-fxGlide: #cc8800;
    --ot-fxSpatial: #a07000;
    --ot-fabGreen: #cc8800;
    --ot-fabRed: #ff3333;
}

/* --- NAV: red --- */
body[data-phosphor-nav="red"] {
    --ot-bg: #0a0505;
    --ot-panel: #120808;
    --ot-surface: #1a0a0a;
    --ot-text: #e62e2e;
    --ot-textMid: #cc2828;
    --ot-textSoft: #a01f1f;
    --ot-muted: #801a1a;
    --ot-accent: #ff3333;
    --ot-depth1: #e62e2e;
    --ot-depth2: #a01f1f;
    --ot-depth3: #701515;
    --ot-empty: #080303;
    --ot-harpLine: #3a0f0f;
    --ot-borderMid: #702a2a;
    --ot-microBreakdown: #1a0a0a;
    --ot-fxShape: #a01f1f;
    --ot-fxFilter: #cc2828;
    --ot-fxAmp: #e62e2e;
    --ot-fxLpg: #a01f1f;
    --ot-fxDelay: #701515;
    --ot-fxReverb: #801a1a;
    --ot-fxGlide: #cc2828;
    --ot-fxSpatial: #a01f1f;
    --ot-fabGreen: #2ee62e;
    --ot-fabRed: #ff3333;
}

/* --- NAV: grey --- */
body[data-phosphor-nav="grey"] {
    --ot-bg: #080808;
    --ot-panel: #101010;
    --ot-surface: #181818;
    --ot-text: #b8b8b8;
    --ot-textMid: #a0a0a0;
    --ot-textSoft: #808080;
    --ot-muted: #606060;
    --ot-accent: #cccccc;
    --ot-depth1: #b8b8b8;
    --ot-depth2: #808080;
    --ot-depth3: #505050;
    --ot-empty: #050505;
    --ot-harpLine: #282828;
    --ot-borderMid: #585858;
    --ot-microBreakdown: #181818;
    --ot-fxShape: #808080;
    --ot-fxFilter: #a0a0a0;
    --ot-fxAmp: #b8b8b8;
    --ot-fxLpg: #808080;
    --ot-fxDelay: #505050;
    --ot-fxReverb: #606060;
    --ot-fxGlide: #a0a0a0;
    --ot-fxSpatial: #808080;
    --ot-fabGreen: #808080;
    --ot-fabRed: #e0e0e0;
}

/* --- NAV: light --- warm cream panel on a light background */
body[data-phosphor-nav="light"] {
    --ot-bg: #f5f0e8;
    --ot-panel: #ede8dc;
    --ot-surface: #e8e0d0;
    --ot-text: #1a1208;
    --ot-textMid: #2e2010;
    --ot-textSoft: #50401a;
    --ot-muted: #7a5848;
    --ot-accent: #1a1208;
    --ot-depth1: #1a1208;
    --ot-depth2: #50401a;
    --ot-depth3: #8a7a60;
    --ot-empty: #f8f4ec;
    --ot-harpLine: #d8d0b8;
    --ot-borderMid: #b8a880;
    --ot-microBreakdown: #e8e0d0;
    --ot-fxShape: #50401a;
    --ot-fxFilter: #3a2a10;
    --ot-fxAmp: #2e2010;
    --ot-fxLpg: #50401a;
    --ot-fxDelay: #8a7a60;
    --ot-fxReverb: #6a5a40;
    --ot-fxGlide: #3a2a10;
    --ot-fxSpatial: #50401a;
    --ot-fabGreen: #2a7a2a;
    --ot-fabRed: #cc2222;
}

/* --- NAV: off-light --- plain light panel, no tinting */
body[data-phosphor-nav="off-light"] {
    --ot-bg: #f8f8f8;
    --ot-panel: #f0f0f0;
    --ot-surface: #e8e8e8;
    --ot-text: #222222;
    --ot-textMid: #444444;
    --ot-textSoft: #666666;
    --ot-muted: #6e6e6e;
    --ot-accent: #222222;
    --ot-depth1: #222222;
    --ot-depth2: #555555;
    --ot-depth3: #888888;
    --ot-empty: #fafafa;
    --ot-harpLine: #d8d8d8;
    --ot-borderMid: #bbbbbb;
    --ot-microBreakdown: #e8e8e8;
    --ot-fxShape: #555555;
    --ot-fxFilter: #444444;
    --ot-fxAmp: #333333;
    --ot-fxLpg: #555555;
    --ot-fxDelay: #777777;
    --ot-fxReverb: #666666;
    --ot-fxGlide: #444444;
    --ot-fxSpatial: #555555;
    --ot-fabGreen: #2a7a2a;
    --ot-fabRed: #cc2222;
}

/* --- PLAYABLE scope — piano viz + harp strip canvases only (#131 rename) --- */
/* Canvas draw functions read crtGetPalette('playable') → these CSS vars */
body[data-phosphor-playable="green"] {
    --ot-playable-trace: #33ff33;
    --ot-playable-glow: #00cc00;
    --ot-playable-dim: #006600;
    --ot-playable-bg: #050a05;
    --ot-playable-accent: #33ff33;
}
body[data-phosphor-playable="amber"] {
    --ot-playable-trace: var(--ot-crtAmberTrace);
    --ot-playable-glow: var(--ot-crtAmberGlow);
    --ot-playable-dim: var(--ot-crtAmberDim);
    --ot-playable-bg: #0a0805;
    --ot-playable-accent: var(--ot-crtAmberTrace);
}
body[data-phosphor-playable="red"] {
    --ot-playable-trace: var(--ot-crtRedTrace);
    --ot-playable-glow: var(--ot-crtRedGlow);
    --ot-playable-dim: var(--ot-crtRedDim);
    --ot-playable-bg: #0a0505;
    --ot-playable-accent: var(--ot-crtRedTrace);
}
body[data-phosphor-playable="grey"] {
    --ot-playable-trace: #cccccc;
    --ot-playable-glow: #888888;
    --ot-playable-dim: #444444;
    --ot-playable-bg: #080808;
    --ot-playable-accent: #cccccc;
}
body[data-phosphor-playable="light"] {
    --ot-playable-trace: #1a1208;
    --ot-playable-glow: #3a2a10;
    --ot-playable-dim: #8a7a60;
    --ot-playable-bg: #f5f0e8;
    --ot-playable-accent: #1a1208;
}
body[data-phosphor-playable="off-light"] {
    --ot-playable-trace: #222222;
    --ot-playable-glow: #555555;
    --ot-playable-dim: #aaaaaa;
    --ot-playable-bg: #f8f8f8;
    --ot-playable-accent: #222222;
}

/* --- VIZ scope — all canvas visualizations: scope, spectrum, filter, delay, etc. --- */
/* Canvas draw functions read crtGetPalette('viz') → these CSS vars */
body[data-phosphor-viz="green"] {
    --ot-viz-trace: #33ff33;
    --ot-viz-glow: #00cc00;
    --ot-viz-dim: #006600;
    --ot-viz-bg: #050a05;
    --ot-viz-accent: #33ff33;
}
body[data-phosphor-viz="amber"] {
    --ot-viz-trace: var(--ot-crtAmberTrace);
    --ot-viz-glow: var(--ot-crtAmberGlow);
    --ot-viz-dim: var(--ot-crtAmberDim);
    --ot-viz-bg: #0a0805;
    --ot-viz-accent: var(--ot-crtAmberTrace);
}
body[data-phosphor-viz="red"] {
    --ot-viz-trace: var(--ot-crtRedTrace);
    --ot-viz-glow: var(--ot-crtRedGlow);
    --ot-viz-dim: var(--ot-crtRedDim);
    --ot-viz-bg: #0a0505;
    --ot-viz-accent: var(--ot-crtRedTrace);
}
body[data-phosphor-viz="grey"] {
    --ot-viz-trace: #cccccc;
    --ot-viz-glow: #888888;
    --ot-viz-dim: #444444;
    --ot-viz-bg: #080808;
    --ot-viz-accent: #cccccc;
}
body[data-phosphor-viz="light"] {
    --ot-viz-trace: #1a1208;
    --ot-viz-glow: #3a2a10;
    --ot-viz-dim: #8a7a60;
    --ot-viz-bg: #f5f0e8;
    --ot-viz-accent: #1a1208;
}
body[data-phosphor-viz="off-light"] {
    --ot-viz-trace: #222222;
    --ot-viz-glow: #555555;
    --ot-viz-dim: #aaaaaa;
    --ot-viz-bg: #f8f8f8;
    --ot-viz-accent: #222222;
}

/* ---------------------------------------------------------------------------
   Phosphor sweep v2 — CRT leak fixes (#phosphor-sweep-v2)
   Fixes 7 hardcoded-color leaks catalogued in PLAYWRIGHT_CRT_LEAK_AUDIT_2026-04-19.md.
   All rules use body[data-phosphor] scope so the default brown/gold theme is untouched.
   --------------------------------------------------------------------------- */

/* P0 — #fab-bar: warm bg + gold border override */
body[data-phosphor] #fab-bar {
    background: var(--ot-bg);
    border-top-color: var(--ot-borderMid);
}

/* P0 — .fx-tab-btn.active: hardcoded gold tint override */
body[data-phosphor] .fx-tab-btn.active {
    background: rgba(0, 0, 0, 0);
    background-color: color-mix(in srgb, var(--ot-accent) 8%, transparent);
}

/* P1 — #fx-tab-bar-host: gold border override */
body[data-phosphor] #fx-tab-bar-host {
    border-bottom-color: var(--ot-borderMid);
}

/* P1 — .fx-section-label: reactive phosphor color (overrides baked inline style).
   data-label-color is now set by fxSectionLabel() helper so CSS can safely override. */
body[data-phosphor] .fx-section-label {
    color: var(--ot-accent) !important;
}

/* WCAG AA fix (heading-color audit 2026-04-19): .fx-section-sublabel (Filter /
   LPG / Harp in env + sub tabs) has inline style.color = categoryColor(...),
   which bakes the phosphor trace at tab-build time. Under light / off-light
   themes the trace is near-white on a light bg (#33ff33 on #f5f0e8 → 1.19),
   or stale from a prior green session. Override inline color with !important
   using --ot-accent (#6a4a10 on light → 7.1 contrast; #333333 on off-light
   → 12.6 contrast). Also handle the nav-scope attribute so the rule matches
   whether the user flipped theme via legacy _applyCrtPalette or via the
   3-scope _applyCrtPaletteScoped API. */
body[data-phosphor="light"] .fx-section-sublabel,
body[data-phosphor="off-light"] .fx-section-sublabel,
body[data-phosphor-nav="light"] .fx-section-sublabel,
body[data-phosphor-nav="off-light"] .fx-section-sublabel {
    color: var(--ot-accent) !important;
}

/* P2 — Contrast fix: sel-btn rarity colors on dark harpLine background.
   Under phosphor mode, --ot-depth2/3 are too dark against --ot-harpLine bg
   (e.g. green: depth3=#157015 on harpLine=#0f3a0f → 2.1:1, fails WCAG AA).
   Remap inactive rarity button text to --ot-text/textMid for ≥4.5:1 contrast. */
body[data-phosphor] .sel-btn[data-rarity="secondary"] {
    color: var(--ot-textMid);
    border-color: var(--ot-textMid);
}
body[data-phosphor] .sel-btn[data-rarity="rare"] {
    color: var(--ot-text);
    border-color: var(--ot-textSoft);
    opacity: 1;
}
body[data-phosphor] .sel-btn[data-rarity="primary"] {
    color: var(--ot-text);
    border-color: var(--ot-text);
}
/* Same for data-phosphor-nav (3-scope system) */
body[data-phosphor-nav] .sel-btn[data-rarity="secondary"] {
    color: var(--ot-textMid);
    border-color: var(--ot-textMid);
}
body[data-phosphor-nav] .sel-btn[data-rarity="rare"] {
    color: var(--ot-text);
    border-color: var(--ot-textSoft);
    opacity: 1;
}
body[data-phosphor-nav] .sel-btn[data-rarity="primary"] {
    color: var(--ot-text);
    border-color: var(--ot-text);
}

/* 2026-04-30 V1 triage D: phosphor overrides for #kb-help-overlay removed
   along with the overlay (cut/global-ux-drops). */

/* ============================================================
   V1 Scope Triage UI
   ============================================================ */

/* Section header rows */
.v1-triage-section {
    flex-shrink: 0;
}

/* Decision buttons: touch-target sizing */
.v1-dec-btn {
    touch-action: manipulation;
}

/* Landscape: wider name column */
@media (orientation: landscape) {
    #v1-triage-rows .v1-triage-section {
        flex-wrap: nowrap;
    }
}

/* Below 360px: stack bulk buttons */
@media (max-width: 360px) {
    .v1-triage-filter-bar {
        flex-direction: column;
        align-items: flex-start;
    }
}

/* ─────────────────────────────────────────────────────────────────────
   Harp strip: enlarge BOTTOM in landscape on tablet-class viewports.
   User-requested 2026-04-26 — currently the harp ends mid-screen with
   significant unused space below it; extend down so each of the 4
   string rows gets more touch area. Top stays where it is (margin-top
   inherited from base rule). 80px bottom buffer leaves room for the
   Latch button + safe-area-inset-bottom.

   Scope: landscape orientation + min-width 720px AND min-height 500px.
   The min-height gate excludes iPhone landscape (812×375) where the
   viewport is too short for the harp to grow without underrunning the
   base 204px height. Applies to iPad mini (1112×834), iPad Pro
   landscape (1366×1024), iPad split-screen layouts (≥500px tall), etc.
   #harp-extend-landscape-bottom-2026-04-26
   ──────────────────────────────────────────────────────────────────── */
@media (orientation: landscape) and (min-width: 720px) and (min-height: 500px) {
    /* 2026-04-26 v4: maximize harp height. v3 left Latch in flex flow
       below the harp, capping harp at ~321px on 563px viewports. User
       wanted harp bottom "near to the bottom of the viewport". v4
       absolute-positions Latch at the bottom-right of the viewport so
       it no longer occupies space in the harp's flex column. Harp
       deduction 224px = harp top offset (≈160) + Latch reserved zone
       (48 latch + 8 safe + 8 gap above latch). Harp.bottom now sits
       just above Latch.top with no overlap.
       Number of harp pads stays the same — bar count is driven by
       chord notes, not canvas height. */
    #pad-harp-row.vertical-layout #harp-strip.vertical {
        height: calc(100dvh - 224px) !important;
        max-height: calc(100dvh - 224px) !important;
    }
    /* Latch button: absolute-position at bottom-right of viewport so it
       no longer occupies space in the harp's flex column. Width matches
       the harp strip column width so it visually anchors below the harp. */
    #voicing-hold-row #latch-btn {
        position: fixed !important;
        right: env(safe-area-inset-right, 4px) !important;
        bottom: calc(env(safe-area-inset-bottom, 0px) + 8px) !important;
        z-index: 5 !important;
    }
}
