  /* ══════════════════════════════════════════════════════════════════════════
     tokens.css — design tokens for mubone
     ──────────────────────────────────────────────────────────────────────────
     Single source of truth for colours, typography, spacing, radius, motion and
     control dimensions. style.css should reference these via var(--name) rather
     than hardcoding values.

     ── The 2026-08-29 pass ──────────────────────────────────────────────────
     Everything below is derived from ONE place: SPHERE_PALETTE in state.js.
     The sphere has warm ivory for the horizon, ash for structure, steel blue
     for the sky and terracotta for the earth — a palette with a point of view.
     The chrome did not speak it. It spoke neutral grey, plus a set of accents
     assembled from four unrelated palettes (a 90s teal, a neon red-orange, a
     UI-kit violet and pure screen-green), plus a Material rainbow for the
     commit slots. Three colour systems running in parallel is why the app read
     as *chrome wrapped around an artwork* rather than as one instrument.

     So: the substrate is warm now, the accents are one family at matched
     lightness, and the brightest text in the interface is literally the
     horizon's own colour. Two scales that were silently NON-MONOTONIC — the
     text ramp and the font sizes — were re-cut so their names can be trusted.

     Naming conventions:
       --bg-*       surfaces (opaque)
       --surface-*  translucent warm-white overlays (layered over bg)
       --text-*     text colours — strictly descending
       --accent-*   semantic accents — match the interaction model
                    (lock / action / sensor / sweep / danger / warn / mic)
       --border-*   border lines (translucent warm white)
       --fs-*       font size scale
       --ls-*       letter-spacing scale
       --sp-*       spacing scale
       --r-*        border-radius scale
       --m-* --ease-*  motion
       --slider-*   slider dimensions
       --focus-*    focus ring
     ══════════════════════════════════════════════════════════════════════════ */

  :root {
    /* ── The rem base ────────────────────────────────────────────────────
       Everything in the app is sized off this, so it is the single lever that
       grows the whole instrument together. It lived in THREE places — a CSS
       rule, `BASE_FONT_PX` in ui-viz.js, and a bare literal in index.html's
       pre-paint boot script under a "keep in sync" comment — which is why
       raising the CSS one on its own did nothing: both JS copies write an
       inline font-size that outranks the stylesheet. Now they read this.
       uiScale multiplies it; this is the 1.0 value. */
    --ui-base-px: 16;

    /* ── Surfaces (opaque backgrounds) ────────────────────────────────────
       Warm-shifted near-blacks, hue ~40°, chroma ~0.005. The old ramp was
       neutral (#161616 / #262626) sitting against a canvas whose grid is warm
       ivory, and that mismatch framed the sphere like a browser window.

       They are also much DARKER than they were. --bg-app used to be #161616,
       which reads as a visible grey band across the top and bottom of the
       stage; at #0b0a09 the bars sit one step above the canvas and a single
       hairline does the separating, so the instrument is one continuous dark
       field with the sphere burning a hole in the middle of it. */
    --bg-app:        #0b0a09;   /* body, bars, modals, dropdowns, scrollbars */
    --bg-deep:       #070706;   /* envelope-waveform, deeper wells           */
    --bg-panel:      #121010;   /* preset-dropdown                           */
    --bg-canvas:     #000000;   /* canvas-wrapper, viz background            */
    --bg-tooltip:    rgba(14, 12, 10, 0.93);  /* learn-tooltip backdrop      */

    /* ── Translucent overlays (layer over --bg-app / card bg) ─────────────
       Warm white — rgb(255,240,224) — not pure white. A pure-white hairline
       over a warm black always reads faintly blue, which is why so much
       "minimal dark UI" ends up cold by accident. */
    --surface-0:     rgba(255,240,224,0.030);  /* numbox focus tint          */
    --surface-1:     rgba(255,240,224,0.050);  /* session-btn resting        */
    --surface-2:     rgba(255,240,224,0.065);  /* device-header, grain-select*/
    --surface-3:     rgba(255,240,224,0.085);  /* factory-reset-btn resting  */
    --surface-4:     rgba(255,240,224,0.110);  /* session-btn hover, slider  */
    --surface-5:     rgba(255,240,224,0.140);  /* top-bar-btn active         */
    --surface-6:     rgba(255,240,224,0.170);  /* factory-reset hover        */
    --surface-7:     rgba(255,240,224,0.200);  /* morph-slider peak          */

    /* ── Text ──────────────────────────────────────────────────────────────
       ONE monotonic warm ramp, twelve steps, each strictly darker than the
       last. The old scale was not ordered — --text-light (#eaeaea) was
       brighter than --text-primary (#c8c8c8) which was brighter than
       --text-bright (#d0d0d0) — so "brighter" and "dimmer" in the names could
       not be trusted, and hierarchy drifted wherever someone guessed.

       The top of the ramp is the horizon: --text-light is exactly
       SPHERE_PALETTE.horizon, so the brightest text in the interface is
       the same colour as the equator.

       ── Contrast, and the trap in "warm it up" (Ek, 2026-08-29) ──────────
       The first cut of this ramp lost contrast at EVERY step — an average of
       -1.3, worst -2.45 — even though the background got darker at the same
       time, which should have helped. Warming a grey lowers its luminance:
       green carries ~72% of perceived brightness and a warm grey has less of
       it, so "same value, warmer hue" is silently darker. Warmth has to be
       paid for by lifting lightness, and it was not.

       So every step is now SOLVED for a contrast ratio against --bg-app
       rather than picked by eye, and every one beats what it replaced. The
       ratios are in the comments; if you change a value, re-check it.

       The bottom three steps are marks, not words. Small uppercase type at
       --ls-widest is the hardest case there is — thin strokes, wide gaps —
       so a caption belongs at --text-secondary or --text-tertiary, never at
       --text-dim or below. Getting that wrong is what made the first cut
       read as "way too low" even where the ramp itself was defensible. */
    --text-highlight:#faf7f4;   /* modal headings, the one true white    18.5:1 */
    --text-light:    #e4ddd0;   /* == SPHERE_PALETTE.horizon             14.7:1 */
    --text-emphasis: #dbd7d1;   /* numbox focus, option text             13.8:1 */
    --text-bright:   #cec9c2;   /* session-btn hover                     12.0:1 */
    --text-primary:  #c5c0b9;   /* body, most buttons                    10.9:1 */
    --text-hover:    #b8b2aa;   /* slider-thumb hover, seg-btn hover      9.4:1 */
    --text-secondary:#aba59d;   /* eyebrow captions, group labels         8.1:1 */
    --text-subtle:   #9d978e;   /* chrome glyphs, secondary labels        6.8:1 */
    --text-tertiary: #938d83;   /* meter captions, resting tiles          6.0:1 */
    --text-muted:    #857e74;   /* empty states, placeholder copy         4.9:1 */
    --text-dim:      #776f66;   /* the quietest text allowed to be READ   4.0:1 */
    --text-dimmer:   #6a6259;   /* hairline-adjacent marks, not words     3.3:1 */
    --text-faint:    #5c564c;   /* disabled marks only, never a word      2.7:1 */

    /* Dark chrome surfaces (less common than --bg-* but still recurring)    */
    --chrome-bg-raised:   #181513;  /* device raised tile, table backdrop    */
    --chrome-bg-hover:    #211d19;  /* mic/mute btn hover                    */
    --chrome-bg-option:   #16130f;  /* select option                         */
    --chrome-bg-idle:     #100e0c;  /* status-strip idle                     */
    --chrome-bg-well:     #151210;  /* sample-slot, as-btn, io-monitor tile  */
    --chrome-bg-sunk:     #0a0908;  /* deepest well — as-rms-row backdrop    */
    --chrome-bg-flat:     #0c0b09;  /* flat dark panel — loops, readouts     */
    --chrome-border-dim:  #2b2521;  /* subtle warm border                    */
    --chrome-border-dark: #201b18;  /* darker separator                      */

    /* ── Accents (semantic — match interaction model) ──────────────────────
       Rebuilt 2026-08-29 as ONE family at matched lightness (~L*72) and
       modest chroma, derived from the sphere. The previous set was four
       different palettes — #7abcbc, #f26415, #b07ee8, #76ff03 — and side by
       side they never resolved into a system: the green in particular was
       pure screen-green, the loudest object anywhere in the app.

       Ember is the terracotta south pushed hot. Lock is the steel north.
       Sweep is a sage rather than a highlighter. */
    /* THE TEAL IS DELIBERATE, and it is the palette's only cool hue. It earns
       that by meaning what a warm hue cannot: HELD BY SOMETHING ELSE — a
       locked parameter, a sensor feed you are not driving, the mic's ready
       state. Warm means you are acting; cool means something else has it.
       If you find a teal in a warm interface, this is the one that is a
       decision. The survivors were #6abca0, #5aba80, #e8c840 and #505050,
       and all four are gone. */
    --accent-lock:   #7fa8ae;   /* steel  — held, frozen, learn mode         */
    --accent-action: #e07b3c;   /* ember  — do it, now                       */
    --accent-sensor: #a793c0;   /* dusty violet — the body is driving it     */
    --accent-sweep:  #9db87e;   /* sage   — it worked                        */
    --accent-danger: #cc6a55;   /* brick  — it will not come back            */
    --accent-warn:   #c99552;   /* ochre  — watch this                       */
    --accent-violet-soft: #9a8fb8;  /* preset-dropdown-save hover            */

    /* Status / legend palette — secondary to --accent-*. Informational
       colour (categorical labels, status strips, diagnostics) rather than
       interactive colour. One step lighter and lower in chroma than the
       accents, so a category colour never competes with a control that is
       actually lit. */
    --status-info:     #93b3c6;   /* steel  — active/selected in mapping     */
    --status-error:    #d48770;   /* brick  — delete/remove action           */
    --status-warn:     #d6ab6b;   /* ochre  — MIDI output, warning           */
    --status-accent:   #c994ae;   /* rose   — sequencer pulse, step trigger  */
    --status-ok:       #a3bc8b;   /* sage   — OSC output, sensor positive    */

    /* THE PALETTE LEGEND (docs/PALETTE-GUI.md § 6). One line under each tile:
       what fires it, in which gesture, and whether that gesture is delayed.
       Three sources are told apart by colour and nothing else, so the values
       are the spec's own and live here rather than as literals in style.css:
       a KEY reads at full brightness (--text-bright), a BUTTON ember, a MIDI
       note grey; the gesture word is the same grey as MIDI, one step quieter
       than its source; and the delay mark is amber, because it is a warning
       about timing and not a name. */
    --leg-button:      #dd9a6a;   /* ember  — an instrument button           */
    --leg-midi:        #9d978e;   /* grey   — a MIDI note, and every gesture */
    --leg-delay:       #c9a06a;   /* amber  — this tap waits the ×2 window   */

    /* ── Engine hues: one hue per engine, hue is identity (PALETTE-GUI § 11.2–11.3) ── */
    /* SIX QUADRANTS (PALETTE-GUI § 11.2, Ek 2026-09-12): the three dusty
    mid-tones inside a 50° arc are six hues that clear 5:1 on the bed. Ember
    owns 25–40° (--accent-action, --leg-button), so grain is the one warm
    family. Pins are not an engine — bone is what engines commit into. */
    --eng-source: #4aa3e8;   /* azure       — the only blue: what the brush inks from */
    /* THE EYE IS WHITE (Ek, 2026-09-22: "use white as the lens colour not this
       green"). It reads the others rather than being one of them, so it takes
       no hue at all — the app's own warm white, reused from --text-light rather
       than stated again, because a pure white over a warm black reads blue (see
       the note at the top of this file). It sits one step brighter than
       --eng-pins' bone, which is the nearest thing to it. */
    --eng-lens:   var(--text-light);   /* warm white — how the cursor READS */
    --eng-tape:   #f2569e;   /* hot pink    — recordings that play whole */
    --eng-grain:  #e8a030;   /* gold        — grains from a buffer; dots' own colour */
    --eng-erase:  #be7ace;   /* orchid      — material removed (Ek, 2026-09-13)    */
    --eng-pins:   #cfc7bc;   /* bone        — what the engines commit into */
    --eng-none:   #857f76;   /* warm ash    — no engine                */

    /* Mic states — a brick family. Fire-engine red was the only pure hue in
       the interface and it sat in the top bar for the whole performance. */
    --mic-idle-bg:     #33211b;
    --mic-idle-border: #503329;
    --mic-hover-bg:    #5e362a;
    --mic-hover-border:#7c4736;
    --mic-live-bg:     #b8452c;
    --mic-live-border: #d25e3e;
    --mic-ready:       #c4705a;

    /* Accent-tint backgrounds (for active/flashing button states)           */
    --tint-lock-soft:     rgba(127,168,174,0.08);
    --tint-lock-medium:   rgba(127,168,174,0.15);
    --tint-lock-border:   rgba(127,168,174,0.30);
    --tint-lock-strong:   rgba(127,168,174,0.50);

    /* Tint RGB matches --accent-action: #e07b3c = rgb(224, 123, 60). */
    --tint-action-soft:   rgba(224,123,60,0.10);
    --tint-action-medium: rgba(224,123,60,0.16);
    --tint-action-border: rgba(224,123,60,0.32);
    --tint-action-strong: rgba(224,123,60,0.55);

    --tint-sensor-soft:   rgba(167,147,192,0.12);
    --tint-sensor-medium: rgba(167,147,192,0.16);

    --tint-sweep-soft:    rgba(157,184,126,0.10);
    --tint-sweep-medium:  rgba(157,184,126,0.16);
    --tint-sweep-border:  rgba(157,184,126,0.36);

    --tint-danger-soft:   rgba(204,106,85,0.07);
    --tint-danger-border: rgba(204,106,85,0.30);

    /* ── Borders (translucent warm white) ────────────────────────────────── */
    --border-faint:    rgba(255,240,224,0.055);
    --border-subtle:   rgba(255,240,224,0.080);
    --border-base:     rgba(255,240,224,0.100);
    --border-soft:     rgba(255,240,224,0.130);
    --border-medium:   rgba(255,240,224,0.170);
    --border-hover:    rgba(255,240,224,0.240);  /* hover borders on inputs  */
    --border-emphasis: rgba(255,240,224,0.280);
    --border-focus:    rgba(255,240,224,0.320);  /* focused input borders    */
    --border-strong:   rgba(255,240,224,0.380);

    /* ── Typography ────────────────────────────────────────────────────────
       Urbanist for the whole UI — body, display and numeric readouts alike —
       with `font-variant-numeric: tabular-nums` on readouts so columns align
       without a monospace face. Inter and Roboto Mono are not loaded. */
    --font-sans:    'Urbanist', 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;
    --font-mono:    'Urbanist', 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;
    --font-display: 'Urbanist', 'Inter', 'Helvetica Neue', sans-serif;

    /* ── Size scale ────────────────────────────────────────────────────────
       FIVE real steps at a ~1.13 ratio — tight, because this is a dense
       instrument, but not as tight as the first cut, which ran the whole
       instrument at 8-14px and had the workhorse label at 10.9. Lifted 12%
       on 2026-08-29 (Ek: "the text overall can afford to be bigger"), on top
       of the rem base going 15 → 16 in style.css — so type grew ~19% while
       the boxes around it grew ~7%, and the text now fills its chrome rather
       than rattling around in it — with the twelve historical names collapsed onto them.

       There used to be twelve distinct values, and they were not ordered:
       --fs-xxs (0.62) was LARGER than --fs-2xs (0.6), and --fs-xs (0.68)
       larger than --fs-body (0.65). Values that close are indistinguishable
       on screen, so twelve names were really about five sizes plus seven
       arbitrary aliases — and any two elements meant to match could silently
       differ by half a pixel. Aliases are kept so nothing has to be renamed;
       they now point at real steps. */
    --fs-nano:    0.64rem;   /* tiny dots / sub-label meta (rare)          */
    --fs-micro:   0.69rem;   /* osc indicator, commit dots, tiny legends   */
    --fs-eyebrow: 0.69rem;   /* THE uppercase micro-label — see --ls-widest*/
    --fs-xxs:     0.69rem;
    --fs-2xs:     0.69rem;
    --fs-body:    0.78rem;   /* workhorse body text inside panels          */
    --fs-meta:    0.78rem;   /* secondary meta, hints                      */
    --fs-xs:      0.88rem;   /* session-btn, grain-select, tooltip body    */
    --fs-control: 0.88rem;   /* the standard control label                 */
    --fs-sm:      0.88rem;
    --fs-base:    0.88rem;
    --fs-md:      0.98rem;   /* primary buttons, live-count                */
    --fs-lg:      0.98rem;
    --fs-readout: 0.98rem;   /* readouts, section-title                    */
    --fs-xl:      1.12rem;   /* device labels, section headers             */

    /* ── Letter-spacing ────────────────────────────────────────────────────
       Tracking follows size, always: small type needs air between letters or
       it turns into texture, large type needs less or it falls apart. One
       rule, so a label's tracking is never a decision.

       --ls-widest is the instrument's engraved-lettering setting: it belongs
       with --fs-eyebrow, and the two together are what SENSOR, IN, GATE, DRY
       and every other footer caption are made of. */
    --ls-tight:     0.01em;
    --ls-normal:    0.02em;
    --ls-wide:      0.04em;
    --ls-wider:     0.06em;
    --ls-uppercase: 0.10em;
    --ls-display:   0.14em;
    --ls-widest:    0.18em;   /* eyebrow / engraved lettering              */
    --ls-eyebrow:   0.18em;   /* alias — pairs with --fs-eyebrow           */

    /* ── Spacing ───────────────────────────────────────────────────────────
       A real doubling scale. The old one was 0.1 / 0.15 / 0.25 / 0.3 / 0.4 /
       0.55 / 0.7 / 1 / 1.2rem — nine steps with no interval, four of them
       within 2px of a neighbour. A scale like that cannot be used
       deliberately: two values that are almost the same get chosen by feel,
       and the result is that nothing shares a rhythm with anything else,
       which is most of what "unpolished" actually is.

       Still rem-based, so the existing uiScale zoom keeps working. */
    --sp-0:  0.125rem;   /*   2px */
    --sp-1:  0.1875rem;  /*   3px */
    --sp-2:  0.25rem;    /*   4px */
    --sp-3:  0.375rem;   /*   6px */
    --sp-4:  0.5rem;     /*   8px */
    --sp-5:  0.75rem;    /*  12px */
    --sp-6:  1rem;       /*  16px */
    --sp-7:  1.5rem;     /*  24px */
    --sp-8:  2rem;       /*  32px */

    /* A SEAM, not a spacing step. Abutting objects — meter channels, tile
       strips, the lens bar — are meant to read as ONE object with a hairline
       in it, so the value is a border's width and does not scale with the
       rhythm. Global rather than per-component because seven unrelated
       components want the same hairline and a seam that differs between them
       is just a thin gap. Never use as padding. */
    --seam: 1px;

    /* ── Border radius ─────────────────────────────────────────────────────
       Three steps. It was 0 / 2 / 3 / 5 / 20, and the 20 is a consumer-app
       radius that fights every 2px control standing next to it. */
    --r-0: 0;        /* sharp — slider tracks, flush edges                 */
    --r-1: 2px;      /* controls                                          */
    --r-2: 3px;      /* grouped controls, segmented sets                  */
    --r-3: 4px;      /* icon buttons, tooltips                            */
    --r-hand: 10px;  /* an object you grab with a whole hand rather than a
                        fingertip — the palette today, and nothing else until
                        something else earns it                            */
    --r-card: 12px;  /* the one hero surface                              */

    /* ── Motion ────────────────────────────────────────────────────────────
       There was no motion system: every transition ran the browser default
       `ease`, which starts slow — and on a control that is being PLAYED that
       reads as lag. Two curves and three durations is the whole thing.
       Things you press answer immediately and settle; things that change
       state move decisively; glows decay slowly enough to be seen after the
       hand has already moved on.

       Only transform, opacity, colour and shadow should be animated — never
       anything that triggers layout. This is a performance instrument and
       the grain scheduler shares the main thread. */
    --ease-out:  cubic-bezier(0.16, 0.84, 0.34, 1);
    --ease-snap: cubic-bezier(0.30, 0.00, 0.00, 1);
    --m-touch: 90ms;    /* hover / press — must feel like contact          */
    --m-state: 190ms;   /* armed, on, open                                 */
    --m-decay: 480ms;   /* glows and flashes fading out                    */

    /* Legacy duration names, re-pointed at the scale above. */
    --trans-fast:  0.09s;
    --trans-quick: 0.09s;
    --trans-base:  0.13s;
    --trans-slow:  0.32s;

    /* ── Control dimensions ────────────────────────────────────────────────
       Ableton-style thin sliders. Hover grows the thumb slightly for
       feedback without shifting layout (only vertical height changes). */
    --slider-track-h:        2px;
    --slider-track-h-morph:  3px;      /* morph-slider is visually weightier */
    --slider-thumb-w:        2px;
    --slider-thumb-w-morph:  4px;
    --slider-thumb-h:        12px;
    --slider-thumb-h-hover:  14px;
    --slider-thumb-h-morph-hover: 16px;

    /* Checkbox — native, ember-accented */
    --checkbox-size: 14px;
    --checkbox-accent: var(--accent-action);

    /* ── Focus ring ────────────────────────────────────────────────────── */
    --focus-ring-color:  rgba(224,123,60,0.55);  /* ember                   */
    --focus-ring-width:  1px;
    --focus-ring-offset: 1px;
  }
