/* AURACLE — a full instrument: menu bar, bank, staged views, keyboard dock.
   Two-phosphor language: green = audio, amber = the model's mind.

   The token layer below is load-bearing and has three laws:

   1. TEXT TIER vs STROKE TIER. Each phosphor exists twice. `--phos-*-dim` is
      the *text* tier and clears 4.5:1 on the rack; `--phos-*-deep` is the
      *stroke* tier for wire glow and jack rings, where contrast rules don't
      apply and the darker value looks right. One token cannot do both jobs —
      trying to made every dimmed label in the app fail AA, including the
      lineage log, which is the copy that explains what evolution actually did.
   2. TRACKING LAW. Uppercase gets tracking. Lowercase and mono get none.
   3. NO OFF-SYSTEM COLOUR. Every accent is signal-green or mind-amber. Slate
      blues and pinks read as "generic dark app" and dissolve the phosphor law. */

/* Self-hosted so the identity isn't a coin flip on the user's OS — see
   fonts/README.md for why the old local-only stack silently failed. */
@font-face {
  font-family: "Jost";
  src: url("fonts/jost-var.woff2") format("woff2-variations");
  font-weight: 300 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "IBM Plex Mono";
  src: url("fonts/plexmono-400.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
}
@font-face {
  font-family: "IBM Plex Mono";
  src: url("fonts/plexmono-600.woff2") format("woff2");
  font-weight: 600;
  font-display: swap;
}
@font-face {
  font-family: "Newsreader";
  src: url("fonts/newsreader-it.woff2") format("woff2-variations");
  font-weight: 300 600;
  font-style: italic;
  font-display: swap;
}

:root {
  --rack: #0c0d10;          /* the void behind the rack */
  --panel: #171a1f;         /* graphite faceplate */
  --panel-hi: #1d2127;      /* raised surface */
  --bezel: #07080a;         /* screen bezels */
  --hairline: #292e36;

  /* silk — warm silkscreen ink */
  --silk: #d9d4c8;          /* 13.1:1 on rack */
  --silk-dim: #9a958a;      /*  5.9:1 — all dimmed TEXT */
  --silk-mute: #6f6c63;     /*  non-text only: hairlines, disabled glyphs */

  /* green phosphor — sound */
  --phos-a: #8ef0b1;        /* 14.1:1 */
  --phos-a-dim: #63a97c;    /*  5.2:1 — knob values, readouts */
  --phos-a-deep: #3d6a4d;   /*  strokes and fills only */

  /* amber phosphor — the model's mind */
  --phos-b: #ffb454;        /* 11.0:1 */
  --phos-b-dim: #b8823c;    /*  5.4:1 — lineage log, notes */
  --phos-b-deep: #7a5526;   /*  strokes and fills only */

  --led-red: #ff5747;

  --font-silk: "Jost", "Futura", "Avenir Next", system-ui, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  --font-voice: "Newsreader", Georgia, "Times New Roman", serif;

  /* type scale — six roles, 10px floor. Nothing on an instrument panel
     should be 6.5px. */
  --t-micro: 10px;   /* mono — knob values, jack labels */
  --t-label: 11px;   /* silk caps — panel labels */
  --t-body: 13px;    /* readouts, hints */
  --t-lead: 15px;    /* the model speaking */
  --t-title: 17px;   /* view + module titles, counters */
  --t-hero: 22px;    /* wordmark */

  /* The rack draws through a camera the player aims (0.3×–2.5×, fitted to the
     frame on load and capped at 2.2× there), so its type floor has to hold at
     the *worst legible* case — call it zoom 1, a small window, detail full.
     These are the rendered sizes there; everything above 1× is a bonus. They
     are separate tokens rather than reused DOM ones because the module
     geometry (MOD_W / COL_W / KNOB_ROW in main.js) is sized around them. */
  /* 9px, deliberately one under the DOM floor, and the exception is argued
     rather than hidden: this tier carries mono *numerals* (`1.3 ms`,
     `−22.7 dB`) with uniform advance widths, read as values rather than prose.
     Note the rack is NOT always magnified — a patch of any size fits at 1× or
     below, and the player can zoom further out than that, so treat 9px as the
     real rendered size and not a best case. `.jack text` shares this token for
     `in`/`out`, which are port symbols from a closed set of two rather than
     words. The 10px floor stands for everything else. */
  --t-rack-micro: 9px;
  --t-rack-label: 10px;
  --t-rack-value: 11px;
  --t-rack-title: 13px;

  /* spacing scale */
  --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 24px; --s6: 32px;

  /* light comes from 315° everywhere */
  --lift: inset 1px 1px 0 rgba(255, 255, 255, 0.08);
  --cast: 0 3px 8px rgba(0, 0, 0, 0.5);

  /* one surface + one shadow for every floating panel — three popovers had
     three backgrounds, two border colours and three shadows */
  --surface-pop: linear-gradient(180deg, #1e232a, #14161a);
  --shadow-pop: var(--lift), 0 14px 34px rgba(0, 0, 0, 0.62);

  /* fixed chrome heights, so nothing hand-tunes an offset to them */
  --menubar-h: 44px;
  --keybar-h: 128px;

  /* Never let the UA leak someone else's design system: dark form controls,
     amber selection instead of system blue. */
  color-scheme: dark;
}

::selection { background: rgba(255, 180, 84, 0.3); color: var(--silk); }

.bank-list, .nb-body, .ctx-menu, .tray-items {
  scrollbar-width: thin;
  scrollbar-color: var(--hairline) transparent;
}
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-thumb {
  background: #2c313a;
  border-radius: 999px;
  border: 2px solid transparent;
  background-clip: padding-box;
}
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-corner, ::-webkit-scrollbar-track-piece { background: transparent; }

* { box-sizing: border-box; margin: 0; padding: 0; }

html, body { height: 100%; overflow: hidden; }

body {
  background: var(--rack);
  color: var(--silk);
  font-family: var(--font-silk);
  font-size: var(--t-body);
  -webkit-font-smoothing: antialiased;
}

/* Form controls do not inherit type. Without this, every button, input and
   select in the app renders in the UA default — which was 284 elements in
   Arial, including all 200 bank stars. */
button, input, select, textarea { font: inherit; color: inherit; }

.mono { font-family: var(--font-mono); letter-spacing: 0; }
.hidden { display: none !important; }
/* Sentence-case text takes no tracking (law 2). */
.silk-dim { color: var(--silk-dim); font-weight: 400; text-transform: none; }

.panel-label {
  font-size: var(--t-label);
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
}
.panel-label.amber { color: var(--phos-b); }

/* ===================== app frame ===================== */
.app {
  height: 100vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

/* ---- menu bar ---- */
.menubar {
  position: relative;
  display: flex;
  align-items: center;
  gap: 22px;
  padding: 8px 16px;
  border-bottom: 1px solid var(--hairline);
  background: linear-gradient(180deg, #1c2026, #14161a);
}
/* Rack ears carry screws at their ends, not a rail of sensor dust across the
   full width — masked to the two ends where the "ears" would be. */
.menubar::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(circle 2.6px at 10px 50%, #000 0 2.2px, #30343c 2.6px, transparent 3.4px);
  background-size: 44px 100%;
  -webkit-mask: linear-gradient(90deg, #000 0 60px, transparent 100px calc(100% - 100px), #000 calc(100% - 60px));
  mask: linear-gradient(90deg, #000 0 60px, transparent 100px calc(100% - 100px), #000 calc(100% - 60px));
}
.wordmark {
  font-size: var(--t-hero);
  font-weight: 500;
  letter-spacing: 0.3em;
  color: var(--silk);
  display: flex;
  align-items: baseline;
}
/* The final E is the "model is thinking" indicator — one mark, two jobs.
   At rest it takes the *text* tier, not the stroke tier: it is a letter of the
   wordmark before it is an indicator, so it has to stay readable. */
.wordmark b {
  font-weight: 500;
  color: var(--phos-b-dim);
  transition: color 260ms ease, text-shadow 260ms ease;
}
.wordmark b.thinking {
  color: var(--phos-b);
  text-shadow: 0 0 14px rgba(255, 180, 84, 0.75);
  animation: listen 1.1s ease-in-out infinite;
}
@keyframes listen { 50% { opacity: 0.55; } }
.viewtabs { display: flex; gap: 6px; }
.viewtab {
  font-family: var(--font-silk);
  font-size: var(--t-label);
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--silk-dim);
  background: var(--bezel);
  border: 1px solid var(--hairline);
  border-radius: 4px;
  padding: 7px 18px;
  cursor: pointer;
}
.viewtab.active {
  color: var(--phos-a);
  border-color: var(--phos-a-dim);
  box-shadow: 0 0 10px rgba(142,240,177,0.15), inset 0 0 8px rgba(142,240,177,0.07);
}
.menubar-right { margin-left: auto; display: flex; align-items: center; gap: 14px; }
/* The menubar should lead with what the machine has learned, not with file
   management — so the counters carry title weight and the amber of the mind. */
.counter { font-family: var(--font-mono); font-size: var(--t-body); font-weight: 600; color: var(--phos-b); }
.counter-label { color: var(--silk-dim); font-family: var(--font-silk); font-size: var(--t-micro); letter-spacing: 0.16em; text-transform: uppercase; margin-right: 5px; }

/* Same physics as .hw-btn at reduced amplitude — a tool, not a web chip.
   Without the cast and the depress, PLAY read as a web app with a synth
   pasted in the middle. */
.util-btn {
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--silk);
  background: linear-gradient(180deg, #232830, #181b21);
  border: 1px solid #000;
  border-radius: 3px;
  padding: 6px 10px;
  cursor: pointer;
  box-shadow: var(--lift), 0 2px 0 #0a0b0d;
  transition: transform 60ms ease, box-shadow 60ms ease;
}
.util-btn:hover { border-color: var(--silk-dim); }
.util-btn:active {
  transform: translateY(1px);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.util-btn.amber { color: var(--phos-b); border-color: var(--phos-b-dim); }
.util-btn.amber:hover { box-shadow: 0 0 10px rgba(255,180,84,0.25); }
.util-btn:disabled { opacity: 0.4; cursor: default; }
.util-btn.lit {
  color: var(--phos-b);
  border-color: var(--phos-b);
  box-shadow: 0 0 10px rgba(255,180,84,0.3), inset 0 0 6px rgba(255,180,84,0.12);
}

.led-block { display: flex; align-items: center; gap: 6px; font-size: var(--t-micro); letter-spacing: 0.18em; text-transform: uppercase; color: var(--silk-dim); }
.led {
  width: 8px; height: 8px;
  border-radius: 50%;
  background: #2a1512;
  box-shadow: inset 0 0 2px #000;
}
.led.on {
  background: var(--led-red);
  box-shadow: 0 0 8px var(--led-red), 0 0 2px var(--led-red);
  animation: led-pulse 900ms ease-in-out infinite;
}
@keyframes led-pulse { 50% { opacity: 0.55; } }

/* ---- deck: bank + stage ---- */
.deck {
  flex: 1;
  display: flex;
  min-height: 0;
}

.bank {
  position: relative; /* containing block for .bank-tour, which anchors to
                         the rail it describes rather than to the page */
  width: 252px;
  flex: 0 0 252px;
  display: flex;
  flex-direction: column;
  border-right: 1px solid var(--hairline);
  background: linear-gradient(180deg, #14171b, #101216);
  min-height: 0;
}
.bank-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: nowrap;
  white-space: nowrap;
  padding: 10px 12px;
  border-bottom: 1px solid var(--hairline);
}
.bank-head .silk-dim { font-size: var(--t-micro); }
/* Three banks: presets, the evolving pool, and what you kept. */
.bank-filters {
  display: flex;
  gap: 3px;
  padding: 6px 8px;
  border-bottom: 1px solid var(--hairline);
  /* Five chips needed 240px of a 251px rail with nowrap, and the rail narrows
     to 200px below 1080px — one more word anywhere and they overflowed. Three
     chips wrap cleanly, but left to their natural widths they wrapped 1+2 at
     200px with 87px of dead space beside the first one; sharing the row keeps
     the grouping stable at both widths. */
  flex-wrap: wrap;
}
.bank-filters .bf {
  flex: 1 1 auto;
  justify-content: center;
}
.bank-filters .bf {
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--silk-dim);
  background: var(--bezel);
  border: 1px solid var(--hairline);
  border-radius: 999px;
  padding: 3px 8px;
  cursor: pointer;
  white-space: nowrap;
  display: inline-flex;
  align-items: center;
  gap: 5px;
}
.bank-filters .bf:hover { color: var(--silk); border-color: var(--silk-dim); }
.bank-filters .bf.active {
  color: var(--phos-b);
  border-color: var(--phos-b-dim);
  background: rgba(255, 180, 84, 0.07);
}
/* How many are in a bank you are not currently looking at. */
.bank-filters .bf-n {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  font-weight: 400;
  letter-spacing: 0;
  color: var(--silk-dim);
}
.bank-filters .bf.active .bf-n { color: var(--phos-b-dim); }

.bank-list {
  flex: 1;
  overflow-y: auto;
  padding: 6px;
  display: flex;
  flex-direction: column;
  gap: 3px;
}
.bank-item {
  border: 1px solid transparent;
  border-radius: 5px;
  background: var(--bezel);
  padding: 5px 8px 6px;
  cursor: pointer;
  /* A 2px rail carries row state, so selection no longer depends on noticing
     a 1px border change on a dark background. */
  border-left: 2px solid transparent;
}
.bank-item:hover { border-color: var(--hairline); border-left-color: var(--silk-dim); }
.bank-item.live {
  border-color: var(--phos-a-dim);
  border-left-color: var(--phos-a);
  box-shadow: 0 0 8px rgba(142,240,177,0.12);
}
.bank-item.kbd { border-color: var(--silk-dim); }
/* Saved: the user's own organisation, so silk rather than either phosphor —
   green is audio, amber is the model, and this is neither. It gets a real
   rail and a tint, not a hairline: "which of these did I decide to keep" has
   to be answerable by scanning the column, without hovering anything. */
.bank-item.saved { border-left-color: var(--silk); }
.bank-item .bi-top {
  display: flex;
  align-items: center;
  gap: 6px;
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: var(--silk);
}
.bank-item .bi-origin { font-size: var(--t-micro); }
.bank-item .bi-origin.prior { color: var(--silk-dim); }
.bank-item .bi-origin.refined { color: var(--phos-b); }
.bank-item .bi-origin.edited { color: var(--phos-a); }
.bank-item .bi-origin.preset { color: var(--phos-a-dim); }
/* Just born this generation. */
.bank-item.fresh .bi-origin.refined { text-shadow: 0 0 7px rgba(255,180,84,0.8); }

/* The prediction meter is a full-width underline on the row's bottom edge.
   At 54px, 1% was 0.54px — the number beside it existed because the bar could
   not draw its own data. The band is now the *fill* and the mean is a tick
   inside it: more ink where the model is less certain reads as fog, which is
   what uncertainty should look like. Absolutely positioned so it costs the row
   no height at all. */
.bank-item { position: relative; }
.bank-item .bi-u {
  position: absolute;
  left: 8px; right: 8px; bottom: 3px;
  height: 3px;
  border-radius: 2px;
  background: #1a1410;
  overflow: hidden;
}
.bank-item .bi-u b {
  position: absolute;
  top: 0;
  height: 100%;
  background: var(--phos-b-deep);
  border-radius: 2px;
  pointer-events: none;
}
.bank-item .bi-u i {
  position: absolute;
  top: 0;
  width: 2px;
  height: 100%;
  margin-left: -1px;
  background: var(--phos-b);
  pointer-events: none;
}
/* No fit yet: an explicitly hatched empty track, not a zero. */
.bank-item .bi-u.nofit {
  background: repeating-linear-gradient(45deg, #241b12 0 3px, #3a2c1a 3px 6px);
}
/* Sits with the name, where it is scannable down the column. Its width is
   reserved before the first fit so rows do not shift sideways when the model
   finally has an opinion. */
.bank-item .bi-pct {
  margin-left: auto;
  flex: 0 0 auto;
  min-width: 26px;
  text-align: right;
  font-size: var(--t-micro);
  color: var(--phos-b-dim);
}

.bank-item .bi-row { display: flex; align-items: center; gap: 2px; margin-top: 3px; }
.bank-item .bi-row button {
  background: none;
  border: none;
  cursor: pointer;
  color: var(--silk-dim);
  font-size: var(--t-body);
  /* WCAG 2.2 §2.5.8 wants 24×24. These measured 15×19 (and ▶ measured 12×19)
     — the smallest targets in the app, on the control that writes to the
     taste log, where an off-by-one star is a permanent wrong answer. */
  min-width: 24px;
  height: 24px;
  padding: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
}
.bank-item .bi-row button:hover { color: var(--silk); }
/* Each of these must out-specify `.bank-item .bi-row button` above (0,2,1) or
   its font-size, padding and tracking are silently discarded — which is how
   the save and cut labels ended up running into each other. */
.bank-item .bi-row button.star { min-width: 22px; }
.bank-item .bi-row button.star.lit { color: var(--phos-b); text-shadow: 0 0 6px rgba(255,180,84,0.6); }
/* Save is the user's own channel: silk, not a phosphor.
   A word, not a glyph. It shipped for one round as `▣` — borrowed from the
   rack's module lock, internally consistent and completely undecodable to
   anyone who had not read the source. The first person to see it asked what
   the little square was. The row has room for four letters. */
.bank-item .bi-row button.bi-save {
  margin-left: auto;
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--silk-dim);
  padding: 0 4px;
}
.bank-item .bi-row button.bi-save.on { color: var(--silk); }
.bank-item .bi-row button.bi-save:hover { color: var(--silk); }
/* 40 red cuts were the loudest thing in the app — the highest-chroma colour,
   spent on a destructive action, forty times down the leftmost column. The
   action exists on approach; the colour arrives on the control itself.
   `visibility` rather than opacity alone: at `opacity: 0` this stayed fully
   clickable, so the destructive control was invisible and live at once. */
.bank-item .bi-row button.bi-kill {
  color: var(--silk-dim);
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  padding: 0 4px;
  opacity: 0;
  visibility: hidden;
  transition: opacity 120ms ease;
}
.bank-item:hover .bi-kill,
.bank-item:focus-within .bi-kill,
.bank-item.kbd .bi-kill { opacity: 1; visibility: visible; }
.bank-item .bi-row button.bi-kill:hover { color: var(--led-red); }
/* 200 identical dead stars said nothing. Unrated rows read as names; the
   stars appear on approach or once they carry a rating. */
.bank-item .stars { display: inline-flex; opacity: 0.28; transition: opacity 120ms ease; }
.bank-item:hover .stars,
.bank-item:focus-within .stars,
.bank-item.kbd .stars,
.bank-item.live .stars,
.bank-item .stars:has(.star.lit) { opacity: 1; }

/* ---- the preset library, browsed in place ---- */
.pb-cat {
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  font-weight: 700;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--silk-dim);
  padding: 10px 4px 3px;
}
.preset-item .pb-blurb {
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--t-label);
  color: var(--silk-dim);
  line-height: 1.4;
  margin-top: 1px;
}
.preset-item .pb-sig {
  margin-left: auto;
  font-size: var(--t-micro);
  color: var(--phos-a-dim);
}
.preset-item .pb-in {
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--phos-a-dim);
}
.preset-item.in-bank { border-left-color: var(--phos-a-dim); }
.preset-item.kbd { border-color: var(--phos-a); box-shadow: 0 0 0 1px var(--phos-a-deep) inset; }

.bench-empty { color: var(--silk-dim); font-size: var(--t-body); font-style: italic; font-family: var(--font-voice); padding: var(--s3) var(--s2); }

/* ---- stage + views ---- */
.stage { flex: 1; min-width: 0; position: relative; }
.view {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  padding: 12px 14px;
  gap: 10px;
  min-height: 0;
  overflow: hidden;
}
.stage-toolbar {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
}
/* Four ranked groups with a 20px gutter between them (§10), not one run of
   equal pills. The gutter is what does the ranking work — inside a group the
   buttons touch, so they read as one control with several positions. */
.rack-tools { margin-left: auto; display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
.seg { display: flex; align-items: center; }
.seg > * { display: flex; }
/* A segmented control: shared border, square inner corners, no gaps. */
.seg .seg-item {
  border-radius: 0;
  margin-left: -1px;
  white-space: nowrap;
}
.seg > :first-child .seg-item, .seg > .seg-item:first-child {
  border-top-left-radius: 3px; border-bottom-left-radius: 3px; margin-left: 0;
}
.seg > :last-child .seg-item, .seg > .seg-item:last-child {
  border-top-right-radius: 3px; border-bottom-right-radius: 3px;
}
/* The teach group's checkbox is a label, not a button: give it the same
   height and the same well so the pair reads as one control. */
.seg-teach .toggle.seg-item {
  border: 1px solid var(--hairline);
  background: var(--bezel);
  padding: 4px var(--s2);
  height: 24px;
}
.seg .seg-item:hover:not(:disabled) { position: relative; z-index: 1; }
/* Locks are a preparation, not an act: 90% and quiet, so they sit below the
   teaching pair and far below EVOLVE without needing to be hidden. */
.seg-locks .seg-item {
  font-size: calc(var(--t-micro) * 0.9);
  padding: 5px 8px;
  color: var(--silk-mute);
}
.seg-locks .seg-item:hover:not(:disabled) { color: var(--silk); }
/* The only filled amber button in the view. Everything else in this row is a
   dark pill; this is the act the whole app exists to perform. */
.evolve-slot { margin-left: auto; }
#rack-evolve:not(:disabled) {
  color: #1a1204;
  background: linear-gradient(180deg, #ffc978, var(--phos-b) 62%, #d18f2c);
  border-color: #6b4a12;
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.35), 0 3px 0 #3a2708, 0 6px 14px rgba(255,180,84,0.18);
}
#rack-evolve:not(:disabled):hover { filter: brightness(1.06); }
.tool-sep { width: 1px; height: 18px; background: var(--hairline); margin: 0 2px; }
.toggle {
  display: flex; align-items: center; gap: 6px;
  font-size: var(--t-micro); letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--silk-dim); cursor: pointer;
}
/* The checkbox is drawn, not borrowed: a bezel square that lights amber —
   this is the model's own channel, after all. */
.toggle input[type="checkbox"] {
  appearance: none;
  -webkit-appearance: none;
  width: 14px;
  height: 14px;
  margin: 0;
  border: 1px solid var(--hairline);
  border-radius: 3px;
  background: var(--bezel);
  box-shadow: inset 0 1px 2px #000;
  cursor: pointer;
  position: relative;
}
.toggle input[type="checkbox"]:hover { border-color: var(--silk-dim); }
.toggle input[type="checkbox"]:checked {
  border-color: var(--phos-b);
  background: rgba(255, 180, 84, 0.1);
  box-shadow: inset 0 0 6px rgba(255, 180, 84, 0.2);
}
.toggle input[type="checkbox"]:checked::after {
  content: "✓";
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  font-size: 10px;
  color: var(--phos-b);
  text-shadow: 0 0 6px rgba(255, 180, 84, 0.6);
}

/* ---- workbench rack ---- */
/* The frame is the fixed object; the viewport inside it is what the camera
   looks through. Anything parented to the frame (scope, minimap, empty
   state) is guaranteed not to move when the patch pans. */
.rack-frame {
  position: relative;
  flex: 1;
  min-height: 0;
  display: flex;
}
.rack-scroll {
  position: relative;
  touch-action: none;
  flex: 1;
  min-height: 0;
  min-width: 0;
  /* Not `auto`. Scrollbars were the only way out of a patch that had grown
     past the frame, and they could only ever reach content the baked-in
     magnification had already decided to draw. The viewBox is the scroller
     now, and it goes both ways. */
  overflow: hidden;
  border-radius: 6px;
  border: 1px solid var(--hairline);
  /* The dot grid moved into the SVG so it parallaxes with the patch instead
     of staying nailed to the frame — a ground plane you can't move over is
     not a ground plane, it is wallpaper. */
  background: linear-gradient(180deg, #0b0c0f, #0e1013);
  box-shadow: inset 0 0 22px rgba(0,0,0,0.85), inset 0 0 2px 1px #000;
}
/* A hard cut mid-plate says "the window ends here"; a fade says "there is
   more this way". Applied only on the side that actually has content past
   it, so a fitted patch keeps its full contrast right to the hairline. */
.rack-scroll.faded {
  -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 var(--fade-l, 0px),
    #000 calc(100% - var(--fade-r, 0px)), transparent 100%);
  mask-image: linear-gradient(90deg, transparent 0, #000 var(--fade-l, 0px),
    #000 calc(100% - var(--fade-r, 0px)), transparent 100%);
}
.rack-scroll.grabbing { cursor: grabbing; }
.rack-scroll.grabbing * { cursor: grabbing !important; }
/* Freeform: the faceplate says it can be picked up, and the controls on it
   keep saying they are controls. The plate layer is the only thing under the
   pointer on bare panel — knobs, jacks and the ⋯/▢ well all sit in the
   control layer above it and set their own cursors — so this one selector is
   the whole affordance. */
.rack-scroll.freeform:not(.placing) .rack-plates g[data-mid] { cursor: grab; }
.rack-scroll.moving-plate, .rack-scroll.moving-plate * { cursor: grabbing !important; }
#rack-svg { display: block; width: 100%; height: 100%; }
/* The ground plane and the pool of light under the patch. Both are drawn in
   rack coordinates, which is the whole point: they are the parallax
   reference that tells you the canvas moved. */
.rack-dots { fill: url(#dotGrid); pointer-events: none; }
.rack-pool { pointer-events: none; }

/* ---- minimap ---- */
.rack-map {
  position: absolute;
  left: 10px; bottom: 10px;
  width: 172px; height: 116px;
  z-index: 6;
  border-radius: 5px;
  border: 1px solid var(--hairline);
  background: rgba(6, 8, 10, 0.82);
  box-shadow: 0 6px 18px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.04);
  cursor: crosshair;
  touch-action: none;
}
.rack-map.hidden { display: none; }
.mm-node { fill: rgba(142,240,177,0.34); }
.mm-node.mod { fill: rgba(255,180,84,0.34); }
.mm-node.amp { fill: rgba(142,240,177,0.7); }
.mm-view { fill: rgba(255,255,255,0.05); stroke: var(--phos-b); stroke-width: 1.2; }
/* Bookmarks. Amber, because a bookmark is a decision about where to look and
   the amber channel is where this instrument keeps decisions — and filled
   rather than outlined so a pip reads as a marker on the map rather than as a
   second, smaller viewport rect. Drawn under `.mm-view`, which is the one
   thing on the map that must never be obscured. */
.mm-pip {
  fill: rgba(255, 180, 84, 0.9);
  stroke: rgba(10, 8, 5, 0.85);
  stroke-width: 1;
}
.mm-pip-n {
  font-family: var(--font-mono);
  font-size: 7px;
  font-weight: 600;
  fill: #120c04;
  text-anchor: middle;
  pointer-events: none;
}
/* A control that does not apply right now, holding its slot.
   `display: none` on a button inside a horizontal group moves every button to
   its right — and the layout-mode toggle was to its right, so entering
   freeform slid the toggle ~100 px under the pointer that had just pressed it
   and a second press fired *apply grid*, which rewrites every position (m6).
   The fixed-slot rule is already how the evolve callout keeps its row; this is
   the same rule for the same reason. */
.tt.held { visibility: hidden; }

/* The frame's own recovery hint (M3(e)). Centred rather than cornered: it is
   about the whole view, not about anything under it, and at the zoom that
   summons it there is nothing under it anyway. */
.fit-hint {
  position: absolute;
  left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  z-index: 7;
  display: flex; align-items: center; gap: 10px;
  padding: 9px 10px 9px 14px;
  border: 1px solid var(--hairline);
  border-radius: 6px;
  background: var(--surface-pop);
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6);
  font-family: var(--font-voice); font-style: italic;
  font-size: var(--t-body);
  color: var(--silk-dim);
  pointer-events: auto;
}
.fit-hint.hidden { display: none; }

.rack-empty {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  color: var(--silk-dim);
  font-family: var(--font-voice); font-style: italic; font-size: var(--t-lead);
  padding: 0 60px; text-align: center;
}

/* svg module styling */
.mod-plate { fill: url(#plateGrad); stroke: #2c313a; stroke-width: 1; }
.mod-plate.modside { stroke: #57431f; }
.mod-plate.locked { stroke: var(--phos-b); stroke-dasharray: 4 3; }
/* The opt-in belief tint. A stroke rather than a fill, and inset rather than
   on the plate's own edge, so it can never be confused with the dashed amber
   `locked` outline — one says "the model leans this way about this family",
   the other says "you pinned this", and they are routinely on at once.
   `stroke-opacity` is written per plate: it carries the model's certainty. */
.belief-edge { fill: none; stroke-width: 2; pointer-events: none; }
.belief-edge.pos { stroke: var(--phos-b); }
.belief-edge.neg { stroke: var(--led-red); }

/* ---- the per-port probe (WS-9) ---- */
/* A CRT window in the same idiom as the big scope's bezel, clipped to a
   module's out ○ by a short lead. Nothing here is interactive except the
   tooltip, so the whole group is pointer-transparent apart from the bezel
   itself, which has to be hoverable for the title to be readable. */
.port-trace { pointer-events: none; }
.pt-lead { fill: none; stroke: var(--phos-a-deep); stroke-width: 1.4; }
.pt-bezel {
  fill: var(--bezel);
  stroke: var(--hairline);
  stroke-width: 1;
  pointer-events: auto;
}
.pt-zero { stroke: rgba(142, 240, 177, 0.16); stroke-width: 0.8; }
.pt-wave { fill: var(--phos-a); fill-opacity: 0.82; stroke: none; }
.pt-msg {
  font-family: var(--font-mono);
  font-size: 8px;
  fill: var(--silk-mute);
  pointer-events: none;
}
/* Stale: the patch has moved and the render on screen is of the one before
   it. Dimmed rather than emptied — a picture of a moment ago is worth more
   than a blank box, and the tooltip says which it is. */
.port-trace.stale { opacity: 0.45; }
/* Hierarchy by ratio, not by a pixel. The three pieces of text on a plate had
   one size between them and the brightest of them was the one that never
   changed — so the plate read as a caption block rather than as a control
   surface. Now: the name is large and quiet (silkscreen is *printed*, and
   printed things are the calmest thing on any panel), the knob label is small
   and quieter still, and the readout — the only text on the plate that ever
   moves — owns the contrast. */
.mod-title {
  font-family: var(--font-silk);
  font-size: var(--t-rack-title);
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  fill: var(--silk-mute);
}
/* A one-knob plate is 96 units wide and gives up ~44 of them to the control
   well. 13px there means condensing the glyphs, and a squeezed name reads as
   a rendering fault; one step down reads as a smaller panel, which it is. */
.mod-title.narrow { font-size: 11px; letter-spacing: 0.12em; }
.mod-title.modside { fill: var(--phos-b); }
/* The masthead rule. One hairline is the difference between a name floating
   on a plate and a name that the knob grid hangs from. */
/* Not `--hairline`: that token is tuned against the app's near-black panels
   and disappears entirely on the plate gradient, which is two stops lighter.
   A rule you cannot see is a rule that is not there. */
.plate-rule { fill: rgba(255,255,255,0.09); pointer-events: none; }
.knob-label {
  font-family: var(--font-silk);
  font-size: var(--t-rack-label);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  fill: var(--silk-mute);
  text-anchor: middle;
}
/* Silkscreen is printed on the panel; it is not a control and must never take
   a press. It never did anything with one either — the label and the readout
   sit in the knob's group but outside its hit circle, so a press on "71%" was
   simply swallowed — and now that the faceplate under them is draggable in
   freeform, swallowed means the plate does not move. */
.mod-title, .knob-label, .knob-value, .plate-hint { pointer-events: none; }
/* Green is reserved for things that carry signal — cables, jacks, arcs, the
   scope trace. A readout carries a *report*, so it is neutral at rest and
   borrows the phosphor for the ~400ms after it changes, which is the only
   moment it is about the sound. `--silk-dim` is brighter than the
   `--silk-mute` above it, which is what makes it the brightest text here. */
.knob-value {
  font-family: var(--font-mono);
  font-size: var(--t-rack-value);
  fill: var(--silk-dim);
  text-anchor: middle;
  transition: fill 380ms ease-out;
}
.knob-value.flashing { fill: var(--phos-a); transition: none; }
.knob-body { fill: url(#knobGrad); stroke: #000; stroke-width: 1; cursor: ns-resize; }
/* A knob's face is bigger than its body: the ticks, the travel track and the
   value arc all ride outside r, and all of them read as part of the control.
   None of them may intercept the press — an unfilled ring still hit-tests on
   its stroke, and a press that lands on decoration is a knob that doesn't
   turn. `.knob-hit` is the real target and covers the whole face. */
.knob-hit { fill: transparent; cursor: ns-resize; }
.knob-ticks, .knob-track, .knob-arc { pointer-events: none; }
.knob-ring { fill: none; stroke: #343a44; stroke-width: 1; }
.knob-ind { stroke: var(--phos-a); stroke-width: 2; stroke-linecap: round; pointer-events: none; }
.knob-ind.modside { stroke: var(--phos-b); }
.knob-locked-halo { fill: none; stroke: var(--phos-b); stroke-width: 1.2; stroke-dasharray: 2 2.5; pointer-events: none; }
.enum-body { fill: #0a0c0e; stroke: #2c313a; stroke-width: 1; cursor: pointer; }
.enum-text {
  font-family: var(--font-mono); font-size: var(--t-rack-label); fill: var(--phos-a);
  text-anchor: middle; pointer-events: none;
}
.lock-dot { cursor: pointer; }
.lock-dot circle { fill: #1a1410; stroke: #3a2f1d; stroke-width: 1; }
.lock-dot.on circle { fill: var(--phos-b); stroke: var(--phos-b); filter: drop-shadow(0 0 3px rgba(255,180,84,0.8)); }
.mod-lock { font-family: var(--font-mono); font-size: var(--t-rack-label); fill: var(--silk-dim); cursor: pointer; }
.mod-lock.on { fill: var(--phos-b); }
/* Cables run *over* the faceplates now (see .rack-wires in main.js): under
   them, a run that crossed a module simply vanished and reappeared — the graph
   asserted a connection and then hid the evidence. Front-panel cables are
   physically true for this instrument, and they only read that way with two
   things: a casing, and a shadow.

   The casing is the cable's jacket — background-coloured and wider than the
   ink, so a crossing cuts a channel through the plate instead of tinting it.
   The shadow is on the whole layer at the same 315° offset as .mod-plate's, so
   a cable casts onto the panel it lies on and the two agree about the lamp. */
.wire { fill: none; stroke-linecap: round; pointer-events: none; }
/* The stroke is set per cable in JS (the five-stop green ramp, and the level
   at that point in the chain); this is the fallback for anything drawn
   outside `buildRack` — the drag band, the duel minis' cutouts. */
.wire.audio { stroke: rgba(142,240,177,0.72); stroke-width: 2.2; }
/* The casing was an opaque near-black, which worked over a black void and
   became a visible dark stripe the moment the ground plane arrived under it.
   Translucent black sits on whatever it is over — panel, dots, pool — and
   still separates two crossing cables, which is the whole job. */
.wire.audio-case { stroke: rgba(0,0,0,0.5); stroke-width: 5.5; }
.wire.mod {
  stroke: rgba(255,180,84,0.62); stroke-width: 1.8; stroke-dasharray: 6 4;
  filter: drop-shadow(0 0 3px rgba(255,180,84,0.35));
}
.wire.mod-case { stroke: rgba(0,0,0,0.5); stroke-width: 4.5; }
.rack-wires { filter: drop-shadow(1px 3px 3px rgba(0,0,0,0.55)); }
.port { fill: #0a0b0d; stroke: #3a3f48; stroke-width: 1; }

/* ---- cable terminations ----
   The plug in the socket. A barrel, a knurled collar, and the strain-relief
   boot the lead emerges from — which is the point: a cable that starts at a
   coordinate is a graph edge, and a cable that starts in a boot is a patch
   lead. Everything here is inert; the jack's own hit circle is above it. */
.plug, .plug * { pointer-events: none; }
.plug-barrel { fill: url(#jackNut); stroke: rgba(0,0,0,0.7); stroke-width: 0.8; }
.plug-collar { fill: rgba(0,0,0,0.35); }
.plug-boot { fill: #14171c; stroke: rgba(0,0,0,0.6); stroke-width: 0.6; }
/* A bare plug: a link arriving mid-CV-chain, where there is no patch point to
   draw a socket for. */
.jack.bare { cursor: default; }

/* ---- the mod-slot housing ----
   Same silhouette empty or filled, always: occupancy is carried by the
   stroke, never by whether the element is there. That is what makes "this
   module can be modulated, here" a fact you can read at rest on every plate
   instead of one you learn by accident after cabling one. */
.mod-tab {
  fill: rgba(10,7,3,0.55);
  stroke: var(--phos-b-deep);
  stroke-width: 1;
  stroke-dasharray: 3 2.5;
  pointer-events: none;
}
.mod-tab.filled { fill: rgba(24,16,5,0.75); stroke-dasharray: none; }
.mod-tab-name {
  font-family: var(--font-silk);
  font-size: var(--t-rack-label);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  fill: var(--phos-b);
  opacity: 0.45;
  pointer-events: none;
}
/* Occupied, not shouting. The destination name is silkscreen either way —
   what changed is the stroke around it and the cable in it — and at full
   amber it out-ranked the knob readouts, which are the one thing on the plate
   that is allowed to be the brightest. */
.mod-tab-name.filled { opacity: 0.8; }

/* ---- the control well ----
   A recessed pocket, so the two structure verbs read as controls that live
   somewhere rather than as glyphs that happen to be printed there. It lifts
   as the hand approaches the plate — `.plate-hot` is set on both the plate
   group and the control group in JS, because they are in different layers and
   no CSS selector spans them. */
.ctrl-well {
  fill: rgba(0,0,0,0.25);
  stroke: var(--hairline);
  stroke-width: 1;
  pointer-events: none;
  transition: fill 140ms ease, stroke 140ms ease;
}
.plate-hot .ctrl-well { fill: rgba(255,255,255,0.04); stroke: var(--silk-mute); }
/* "What have I pinned?" is asked before every ⚡, so a locked plate answers it
   without being hovered, poked or focused. */
.ctrl-well.locked { fill: rgba(255,180,84,0.16); stroke: var(--phos-b); }
.plate-hot .mod-menu-btn, .plate-hot .mod-lock { fill: var(--silk); }

/* Plates and cables on their way out (see the motion system in main.js). They
   are clones of what was on screen a frame ago, stripped of every attribute
   anything queries by, so they are pictures rather than modules — and the
   `*` here is belt to that braces: a ghost must never be able to take a click,
   a cable drop or a hover away from the plate that replaced it, and a class
   selector beats the `pointer-events` presentation attribute a child might
   otherwise win with. */
.rack-exit, .rack-exit * { pointer-events: none; }

/* ---- EVOLVE view ---- */
.duel-row {
  flex: 1;
  min-height: 0;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--s3);
}
.duel-card {
  display: flex;
  flex-direction: column;
  gap: 8px;
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 12px;
  min-height: 0;
  cursor: pointer;
  background:
    repeating-linear-gradient(115deg, rgba(255,255,255,0.012) 0 1px, transparent 1px 3px),
    linear-gradient(180deg, var(--panel-hi), var(--panel) 60%);
}
.duel-card.live-sel { border-color: var(--phos-a-dim); box-shadow: 0 0 12px rgba(142,240,177,0.12); }
.duel-hint { font-size: var(--t-micro); }
.panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
}
.inspect-btn {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: var(--silk-dim);
  background: none;
  border: 1px solid var(--hairline);
  border-radius: 3px;
  padding: 3px 8px;
  cursor: pointer;
}
.inspect-btn:hover { color: var(--phos-a); border-color: var(--phos-a-dim); }

.scope, .crt {
  display: block;
  width: 100%;
  border-radius: 4px;
  background: radial-gradient(ellipse at 50% 40%, #10160f 0%, var(--bezel) 85%);
  box-shadow:
    inset 0 0 18px rgba(0,0,0,0.9),
    inset 0 0 2px 1px #000,
    0 1px 0 rgba(255,255,255,0.04);
}
.scope { flex: 1; min-height: 90px; }
.readout {
  font-size: var(--t-micro);
  line-height: 1.5;
  color: var(--phos-a-dim);
  min-height: 2.9em;
  max-height: 2.9em;
  overflow: hidden;
  word-break: break-all;
}
.duel-controls { display: flex; gap: 10px; }
.duel-mid {
  flex: 0 0 auto;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: var(--s5);
  padding: var(--s2) var(--s3);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  background:
    repeating-linear-gradient(115deg, rgba(255,255,255,0.012) 0 1px, transparent 1px 3px),
    linear-gradient(180deg, var(--panel-hi), var(--panel) 60%);
  transition: border-color 200ms ease, box-shadow 200ms ease;
}
.duel-mid.learning {
  border-color: var(--phos-b);
  box-shadow: 0 0 24px rgba(255,180,84,0.22), inset 0 0 18px rgba(255,180,84,0.07);
}
.prompt {
  text-align: center;
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--t-lead);
  line-height: 1.6;
  color: var(--silk-dim);
}

.hw-btn {
  flex: 1;
  font-family: var(--font-silk);
  font-size: var(--t-label);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--silk);
  padding: 10px 8px;
  border-radius: 5px;
  border: 1px solid #000;
  background: linear-gradient(180deg, #2a2f37, #1a1d23);
  box-shadow:
    var(--lift),
    0 3px 0 #0a0b0d,
    0 5px 8px rgba(0,0,0,0.5);
  cursor: pointer;
  transition: transform 60ms ease, box-shadow 60ms ease;
}
.hw-btn:active {
  transform: translateY(2px);
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.06), 0 1px 0 #0a0b0d;
}
.hw-btn:focus-visible { outline: 2px solid var(--phos-a); outline-offset: 2px; }
.hw-btn kbd {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: var(--silk-dim);
  border: 1px solid var(--hairline);
  border-radius: 3px;
  padding: 1px 4px;
  margin-left: 6px;
}
.hw-btn.play { color: var(--phos-a); }
.hw-btn.play.playing { box-shadow: inset 0 0 12px rgba(142,240,177,0.25), 0 3px 0 #0a0b0d; }
.hw-btn.choose { color: var(--phos-b); }
.hw-btn.small { flex: none; font-size: var(--t-micro); padding: 8px 12px; color: var(--phos-b); }
.hw-btn:disabled { opacity: 0.45; cursor: default; }

.lineage-strip {
  flex: 0 0 auto;
  border-top: 1px solid var(--hairline);
  padding-top: 8px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.lineage-body { display: flex; gap: 16px; align-items: stretch; }
#lineage-spark {
  flex: 0 0 240px;
  height: 66px;
  border-radius: 4px;
  background: radial-gradient(ellipse at 50% 35%, #171107 0%, var(--bezel) 85%);
  box-shadow: inset 0 0 14px rgba(0,0,0,0.85);
}
.lineage-log {
  flex: 1;
  font-size: var(--t-body);
  line-height: 1.65;
  color: var(--phos-b-dim);
  overflow: hidden;
  max-height: 66px;
}
.lineage-log b { color: var(--phos-b); font-weight: 400; }
.lineage-log .gen-tag { color: var(--silk-dim); margin-right: 6px; }

/* ---- TASTE view ---- */
.tabs { display: flex; gap: 4px; }
.tab {
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--silk-dim);
  background: var(--bezel);
  border: 1px solid var(--hairline);
  border-radius: 3px;
  padding: 4px 12px;
  cursor: pointer;
}
.tab.active {
  color: var(--phos-b);
  border-color: var(--phos-b-dim);
  box-shadow: 0 0 8px rgba(255,180,84,0.18), inset 0 0 6px rgba(255,180,84,0.08);
}
.caption {
  flex: 1 1 300px;
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--t-body);
  line-height: 1.45;
  color: var(--silk-dim);
}
.crt-wrap { flex: 1; min-height: 0; }
.crt {
  width: 100%;
  height: 100%;
  background: radial-gradient(ellipse at 50% 35%, #171107 0%, var(--bezel) 85%);
}

/* ===================== keyboard dock ===================== */
.keybar {
  flex: 0 0 128px;
  display: flex;
  align-items: stretch;
  gap: 12px;
  padding: 10px 14px;
  border-top: 1px solid var(--hairline);
  background:
    radial-gradient(circle 2.6px at 10px 8px, #000 0 2.2px, #30343c 2.6px, transparent 3.4px),
    linear-gradient(180deg, #1a1d23, #121419);
  background-size: 44px 16px, 100% 100%;
  background-repeat: repeat-x, no-repeat;
}
.key-side {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: flex-start;
  gap: var(--s2);
  flex: 0 0 auto;
}
/* Five columns, two rows: octave −/label/+, panic and the tall toggle above;
   hold, uni, arp and the width selector below. A sixth control would be a
   third row, and a third row does not fit a 128px dock. */
.ctl-grid { display: grid; grid-template-columns: repeat(5, auto); gap: var(--s1); align-items: center; }
.util-btn.sq { width: 30px; padding: 6px 0; text-align: center; position: relative; }
/* The keycap that drives the control, visible without hover. 9px is the
   rack-micro tier: a single glyph from a closed set, not prose. */
.util-btn.sq[data-kbd]::after {
  content: attr(data-kbd);
  position: absolute;
  right: 2px;
  bottom: 0;
  font-family: var(--font-mono);
  font-size: var(--t-rack-micro);
  color: var(--silk-mute);
  text-transform: none;
}
#oct-label { grid-column: span 1; justify-self: center; }
.key-side.right { align-items: flex-end; }
.oct-ctl { display: flex; align-items: center; gap: 8px; }
#oct-label { font-size: var(--t-micro); color: var(--phos-a); min-width: 44px; text-align: center; }
.live-label { font-size: var(--t-micro); color: var(--phos-a-dim); max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#vol { accent-color: var(--phos-a); width: 110px; }

.piano {
  flex: 1;
  position: relative;
  display: flex;
  border-radius: 5px;
  overflow: hidden;
  border: 1px solid #000;
  box-shadow: inset 0 2px 8px rgba(0,0,0,0.7);
  touch-action: none;
}
/* Nothing you play should highlight. `user-select: none` alone left the keys
   selectable in every WebKit before 17.4 — which is most iPads in use — so a
   glissando dragged a blue selection across the hint letters and left the
   caret parked on the keybed. It is set on the whole dock, not just the keys:
   a drag that *starts* on the keybar background and crosses the keys selects
   just as readily. Text you might legitimately want — the patch name you are
   renaming, anything typed — opts back in below.

   Deliberately not applied app-wide: readouts, the lineage log and the help
   card are prose, and a synth is not a reason to make prose uncopyable. */
.keybar, .piano, .rack-scroll, .tray, .bank-item, .stage-toolbar, .duel-controls {
  -webkit-user-select: none;
  user-select: none;
}
input, textarea, .bi-rename {
  -webkit-user-select: text;
  user-select: text;
}
/* The felt strip above the keybed — the one detail every real keyboard has. */
.piano::before {
  content: "";
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 3px;
  z-index: 3;
  background: linear-gradient(180deg, var(--phos-a-deep), #000);
  pointer-events: none;
}
.pkey {
  position: relative;
  flex: 1;
  background: linear-gradient(180deg, #e8e4da 0%, #cfcabd 88%, #b7b2a5 100%);
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.35);
  border-right: 1px solid #55524a;
  border-radius: 0 0 3px 3px;
  cursor: pointer;
}
.pkey:last-child { border-right: none; }
.pkey { transition: background 140ms ease-out; }
.pkey.down { background: linear-gradient(180deg, #9be8b6 0%, #6fcf95 100%); transition: background 30ms linear; }
.pkey .hint {
  position: absolute;
  bottom: 5px;
  left: 50%;
  transform: translateX(-50%);
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: #4a473f;
  pointer-events: none;
}
.pkey.down .hint { color: #14532b; }
.bkey {
  position: absolute;
  top: 0;
  width: 55%;
  height: 58%;
  left: 70%;
  z-index: 2;
  background: linear-gradient(180deg, #23262c 0%, #0c0d10 90%);
  border: 1px solid #000;
  border-top: none;
  border-radius: 0 0 3px 3px;
  cursor: pointer;
  box-shadow: 0 3px 5px rgba(0,0,0,0.6);
}
.bkey { transition: background 140ms ease-out; }
.bkey.down { background: linear-gradient(180deg, #2f7a4d 0%, #174a2c 90%); transition: background 30ms linear; }
.bkey .hint {
  position: absolute;
  bottom: 4px;
  left: 50%;
  transform: translateX(-50%);
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: var(--silk-dim);
  pointer-events: none;
}
/* Accidentals group like a real keybed — C#/D# converge, F#/G#/A# spread —
   but gently: pushed to true acoustic offsets, the outer accidentals
   overhung E and B so far their heads read squashed. */
.bkey.pc1  { left: 64%; }
.bkey.pc3  { left: 74%; }
.bkey.pc6  { left: 60%; }
.bkey.pc8  { left: 69%; }
.bkey.pc10 { left: 78%; }
/* Every C carries its octave label so the keybed is readable at a glance. */
.pkey .hint b { font-weight: 600; color: #3a372f; }
/* The span the computer keys reach — warm ivory against the plain keys'
   cooler bone, strong enough to read across the room. No `filter` here: a
   filter creates a stacking context per key, which drops the overhanging
   black keys behind their neighbours. */
.pkey.mapped { background: linear-gradient(180deg, #f6efdc 0%, #e2d9c2 88%, #c9c0a9 100%); }
.pkey:not(.mapped) { background: linear-gradient(180deg, #d8d4ca 0%, #bfbaae 88%, #a7a296 100%); }
/* A pressed key ALWAYS shows green — declared after .mapped so the press
   wins the cascade. Without this, mapped keys pressed from the ASDF row
   gave no visual at all and the keybed read as mis-mapped. */
.pkey.down, .pkey.mapped.down {
  background: linear-gradient(180deg, #9be8b6 0%, #6fcf95 100%);
  transition: background 30ms linear;
}

/* ===================== boot overlay ===================== */
.boot {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(12, 13, 16, 0.96);
}
.panel {
  position: relative;
  border: 1px solid var(--hairline);
  border-radius: 6px;
  padding: 18px;
  background:
    repeating-linear-gradient(115deg, rgba(255,255,255,0.012) 0 1px, transparent 1px 3px),
    linear-gradient(180deg, var(--panel-hi), var(--panel) 60%);
  box-shadow: var(--lift), 0 10px 24px rgba(0,0,0,0.45);
}
.screws i {
  position: absolute;
  width: 9px; height: 9px;
  border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, #4a505b, #14161a 70%);
  box-shadow: inset 0 0 2px #000, 0 1px 0 rgba(255,255,255,0.06);
}
.screws i::after {
  content: "";
  position: absolute;
  left: 1px; right: 1px; top: 4px;
  height: 1px;
  background: #0a0b0d;
  transform: rotate(28deg);
}
.screws i:nth-child(1) { top: 6px; left: 6px; }
.screws i:nth-child(2) { top: 6px; right: 6px; }
.screws i:nth-child(3) { bottom: 6px; left: 6px; }
.screws i:nth-child(4) { bottom: 6px; right: 6px; }
.boot-card { width: min(520px, 92vw); text-align: center; padding: 34px 30px 28px; }
.boot-title { font-size: var(--t-lead); font-weight: 700; letter-spacing: 0.3em; }
.boot-sub { margin-top: 8px; font-size: var(--t-body); color: var(--silk-dim); letter-spacing: 0.06em; }
.boot-meter {
  margin: 22px auto 10px;
  height: 10px;
  border-radius: 5px;
  background: var(--bezel);
  box-shadow: inset 0 1px 3px #000;
  overflow: hidden;
}
.boot-fill {
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, var(--phos-a-dim), var(--phos-a));
  box-shadow: 0 0 10px rgba(142, 240, 177, 0.55);
  transition: width 300ms ease;
}
.boot-status { font-size: var(--t-label); color: var(--phos-a); }

/* ---- bank names + presets ---- */
/* The name is the row's headline — a musician scans names, not signatures. */
.bank-item .bi-name {
  font-family: var(--font-silk);
  font-size: var(--t-body);
  font-weight: 500;
  color: var(--silk);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  flex: 1;
  min-width: 0;
  cursor: text;
}
.bank-item .bi-name.custom { color: var(--phos-b); }
.bank-item .bi-top .bi-name { flex: 0 1 auto; }
.bank-item .bi-id { font-size: var(--t-micro); color: var(--silk-dim); }
.bank-item input.bi-rename {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  flex: 1;
  min-width: 0;
  color: var(--phos-b);
  background: var(--bezel);
  border: 1px solid var(--phos-b-dim);
  border-radius: 3px;
  padding: 1px 4px;
  outline: none;
}
.bank-head { position: relative; gap: 8px; }
.bank-head .util-btn { padding: 4px 8px; font-size: var(--t-micro); }
/* ---- the three banks ---- */
.bank-head .bank-tour-btn {
  margin-left: auto;
  width: 20px; height: 20px;
  border-radius: 50%;
  border: 1px solid var(--hairline);
  background: var(--bezel);
  color: var(--silk-dim);
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  line-height: 1;
  cursor: pointer;
  flex: 0 0 auto;
}
.bank-head .bank-tour-btn:hover { color: var(--phos-b); border-color: var(--phos-b-dim); }

/* What you are looking at, said in place. */
.bank-note {
  padding: 6px 10px 7px;
  border-bottom: 1px solid var(--hairline);
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--t-label);
  line-height: 1.45;
  color: var(--silk-dim);
}
.bank-note b { font-style: normal; font-weight: 600; color: var(--silk); }
.bank-note .note-more {
  background: none; border: none; padding: 0;
  margin-left: 2px;
  font-family: var(--font-silk);
  font-style: normal;
  font-size: var(--t-micro);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--phos-b-dim);
  cursor: pointer;
}
.bank-note .note-more:hover { color: var(--phos-b); }

/* The walkthrough sits over the rail it is describing. */
.bank-tour {
  position: absolute;
  left: 8px; right: 8px; bottom: 8px;
  /* Capped: it used to take 35% of the list at 252px and 50% at 200px — a
     coach mark covering the thing it is pointing at. */
  max-height: 46%;
  overflow-y: auto;
  z-index: 40;
  border: 1px solid var(--phos-b-dim);
  border-radius: 6px;
  background: var(--surface-pop);
  box-shadow: var(--shadow-pop);
  padding: 10px 12px 10px;
}
.bank-tour .tour-step {
  font-size: var(--t-micro);
  color: var(--phos-b-dim);
  letter-spacing: 0.1em;
}
.bank-tour .tour-title {
  font-family: var(--font-silk);
  font-size: var(--t-body);
  font-weight: 600;
  color: var(--phos-b);
  margin: 2px 0 5px;
}
.bank-tour .tour-body {
  font-family: var(--font-voice);
  font-size: var(--t-label);
  line-height: 1.5;
  color: var(--silk);
}
.bank-tour .tour-body b { font-family: var(--font-silk); font-weight: 600; }
.bank-tour .tour-nav {
  display: flex;
  gap: 6px;
  margin-top: 10px;
  align-items: center;
}
.bank-tour .tour-nav #tour-next { margin-left: auto; }
.bank-tour .tour-nav button:disabled { opacity: 0.35; cursor: default; }
/* The chip the current step is about. */
/* The catalogue's walkthrough reuses the bank tour's panel wholesale — same
   component, same rules, anchored to its own rail. Only the highlight differs,
   because what it points at is a group or a row rather than a chip. */
.nb-tour { left: 10px; right: 10px; max-height: 52%; }
.nb-tour-lit {
  outline: 2px solid var(--phos-b);
  outline-offset: 3px;
  border-radius: 5px;
}
.nb-head .bank-tour-btn {
  margin-left: 0;
  /* `.bank-tour-btn` sets a width but not a basis, and this header is a
     space-between flex row — so the circle was shrinking to 11px and going
     oval. The bank's own head gets away with it because it has slack. */
  flex: 0 0 20px;
}

.bank-filters .bf.tour-lit {
  border-color: var(--phos-b);
  box-shadow: 0 0 0 2px rgba(255, 180, 84, 0.25);
}

/* ---- duel card flip ---- */
.mini-rack {
  flex: 1;
  min-height: 90px;
  position: relative;
  border-radius: 4px;
  background: linear-gradient(180deg, #0b0c0f, #0e1013);
  box-shadow: inset 0 0 18px rgba(0,0,0,0.85), inset 0 0 2px 1px #000;
  overflow: hidden;
}
.mini-rack svg { display: block; width: 100%; height: 100%; }
.mini-rack .promote {
  position: absolute;
  right: 8px;
  bottom: 8px;
}

/* ---- structural-edit context menu ---- */
.ctx-menu {
  position: fixed;
  z-index: 60;
  min-width: 190px;
  max-height: 70vh;
  overflow-y: auto;
  border: 1px solid var(--hairline);
  border-radius: 6px;
  background: var(--surface-pop);
  box-shadow: var(--shadow-pop);
  padding: var(--s1);
}
.ctx-menu .cm-head {
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  font-weight: 700;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--silk-dim);
  padding: 6px 8px 3px;
}
.ctx-menu .cm-item {
  display: block;
  width: 100%;
  text-align: left;
  /* Sentence-case silk like every other popover item — mono is for values,
     not commands. */
  font-family: var(--font-silk);
  font-size: var(--t-body);
  color: var(--silk);
  background: none;
  border: none;
  border-radius: 4px;
  padding: 7px 10px;
  cursor: pointer;
}
.ctx-menu .cm-item:hover { background: rgba(142,240,177,0.09); color: var(--phos-a); }
.ctx-menu .cm-item.danger:hover { background: rgba(255,87,71,0.12); color: var(--led-red); }
.mod-menu-btn { font-family: var(--font-mono); font-size: var(--t-rack-label); fill: var(--silk-dim); cursor: pointer; }
.mod-menu-btn:hover { fill: var(--phos-a); }

/* ---- play canvas + node bank ---- */
/* The rail runs to the window edge, mirroring .bank on the left. Two rails of
   the same material in two different form factors read as an accident. */
.play-body { flex: 1; min-height: 0; display: flex; gap: 10px; margin-right: -14px; }
.play-canvas { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.play-canvas .rack-frame { flex: 1; }

/* ================= the node bank — the instrument's catalogue =================
   It was the only panel in the app made of nothing: no lift, no cast, no
   screws, one type size, and both phosphors dark until hover. It is hardware
   now, and it holds nineteen modules without becoming a spreadsheet.

   The rail goes flush to the window edge with a single hairline, mirroring
   .bank on the left — two rails of the same material in two different form
   factors read as an accident, not as a decision. */
.nodebank {
  position: relative;
  flex: 0 0 auto;
  display: flex;
  align-items: stretch;
  border-left: 1px solid var(--hairline);
  background: linear-gradient(180deg, #14171b, #101216);
  /* 1fr -> 0fr, because display:none cannot animate and made the whole panel
     teleport out of the layout. */
  transition: none;
}
.nb-collapse {
  width: 20px;
  border: none;
  border-right: 1px solid var(--hairline);
  background: var(--bezel);
  color: var(--silk-dim);
  cursor: pointer;
  font-size: var(--t-label);
  transition: color 120ms ease-out;
}
.nb-collapse:hover { color: var(--phos-a); }

/* ---- collapsed: a 34px rail that keeps its name and its held count ---- */
.nb-rail {
  display: none;
  width: 34px;
  border: none;
  background: transparent;
  color: var(--silk-dim);
  cursor: pointer;
  align-items: center;
  justify-content: center;
  gap: 10px;
  flex-direction: column;
  padding: 14px 0;
}
.nodebank.collapsed .nb-rail { display: flex; }
.nodebank.collapsed .nb-body,
.nodebank.collapsed .nb-resize,
.nodebank.collapsed .nb-collapse,
.nodebank.collapsed .nb-status { display: none; }
.nb-rail .rail-word {
  writing-mode: vertical-lr;
  transform: rotate(180deg);
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  font-weight: 700;
  letter-spacing: 0.3em;
  text-transform: uppercase;
}
.nb-rail .rail-n { font-size: var(--t-micro); color: var(--silk-mute); }
.nb-rail .rail-held {
  font-size: var(--t-micro);
  color: var(--phos-a);
  border: 1px solid var(--phos-a-deep);
  border-radius: 999px;
  padding: 1px 5px;
}
.nb-rail:hover { color: var(--phos-a); }

/* ---- the drag handle on the inboard edge ---- */
.nb-resize {
  position: absolute;
  left: -3px; top: 0; bottom: 0;
  width: 7px;
  cursor: col-resize;
  z-index: 2;
}
.nb-resize:hover { background: linear-gradient(90deg, transparent, rgba(142,240,177,0.18)); }

/* The same handle on the other axis, above the spec strip (M4). It sits in the
   8px gap `.play-canvas` already leaves between the frame and the dock, so it
   costs no height of its own — the grip only appears under the pointer, the
   way the rail's does. */
.dock-resize {
  flex: 0 0 8px;
  margin: -4px 0;
  cursor: row-resize;
  border-radius: 2px;
  z-index: 2;
}
.dock-resize:hover,
.dock-resize:focus-visible { background: linear-gradient(0deg, transparent, rgba(142,240,177,0.18)); }
.dock-resize:focus-visible { outline: 1px solid var(--phos-a-deep); outline-offset: -1px; }

.nb-body {
  width: clamp(196px, 17vw, 268px);
  padding: 0 0 10px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* The header is a bezel band, so the catalogue reads as an instrument's
   silkscreen rather than as a sidebar title. */
.nb-head {
  position: sticky;
  top: 0;
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  /* Same band as .bank-head, to the pixel. */
  padding: 10px 12px;
  background: var(--bezel);
  border-bottom: 1px solid var(--hairline);
}
.nb-head .mono { font-size: var(--t-micro); color: var(--silk-mute); }

/* ---- search: the index. Machined like .perf-sel, never a native widget. ---- */
.nb-search { position: relative; margin: 0 var(--s3); }
.nb-q {
  width: 100%;
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: var(--phos-a-dim);
  background: linear-gradient(180deg, #232830, #181b21);
  border: 1px solid #000;
  border-radius: 4px;
  box-shadow: var(--lift), 0 1px 0 #0a0b0d;
  padding: 6px 24px 6px 8px;
}
.nb-q::placeholder { color: var(--silk-mute); }
.nb-q:focus { color: var(--phos-a); outline-offset: 1px; }
.nb-q::-webkit-search-cancel-button { -webkit-appearance: none; }
.nb-slash {
  position: absolute;
  right: 7px; top: 50%;
  transform: translateY(-50%);
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: var(--silk-mute);
  pointer-events: none;
}

/* ---- groups: signal-flow order, not enum order ---- */
.nb-group { margin: 0 var(--s3); }
.nb-group.empty { display: none; }
/* At 41 modules the rail is three viewports tall, so the group you are inside
   has to stay named while you scroll it — otherwise the only landmarks in the
   catalogue are the ones that have just left the screen. Parks directly under
   the header band, which is sticky at 0.
   
   The offset is measured from the header at build time rather than written
   here: it was hard-coded at 37px and the `?` button changed the header's
   height to 44, which would have left a 7px gap that nothing points at. */
.nb-sect {
  position: sticky;
  top: var(--nb-head-h, 40px);
  z-index: 2;
  margin: 8px 0 4px;
  background: linear-gradient(180deg, #14171b 70%, rgba(20, 23, 27, 0));
  padding-bottom: 2px;
}
/* A group header is NOT a chip, and it must not read as one. The caret sits in
   its own 14px gutter, the row has no plate, no bevel, no lift and no press —
   the only thing hover changes is the label's brightness. Everything below it
   is a pressable object with a shelf to travel into; a header that shared that
   language invited the same click, and clicking one while a module was in your
   hand folded a group away while pick mode stayed silently armed. */
.nb-fold {
  display: grid;
  grid-template-columns: 14px auto minmax(0, 1fr) auto;
  align-items: baseline;
  gap: 0 6px;
  width: 100%;
  background: none;
  border: none;
  border-bottom: 1px solid var(--hairline);
  padding: 0 0 3px;
  cursor: pointer;
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  font-weight: 700;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--phos-a-dim);
}
.nb-group.amber .nb-fold { color: var(--phos-b-dim); }
.nb-fold:hover { color: var(--phos-a); }
.nb-group.amber .nb-fold:hover { color: var(--phos-b); }
/* Inert while something is in your hand: the rail's job for the length of a
   placement is to narrow, not to rearrange itself under the pointer. The row
   still answers when clicked (see the handler) — a control that goes quiet
   teaches nothing about why. */
#nb-groups.armed .nb-fold,
#nb-groups.armed .nb-fold:hover { color: var(--silk-mute); cursor: default; }
.nb-caret {
  width: 7px; height: 5px;
  flex: 0 0 auto;
  transition: transform 140ms ease-out;
}
.nb-caret path { fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; }
.nb-group.folded .nb-caret { transform: rotate(-90deg); }
.nb-group.folded .nb-list, .nb-group.folded .nb-why { display: none; }
/* The section note is the one place lowercase prose is allowed in a header
   band — it says what the group is FOR, which the single word cannot. */
.nb-note {
  flex: 1;
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  font-weight: 400;
  letter-spacing: 0;
  text-transform: none;
  color: var(--silk-mute);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.nb-n { font-size: var(--t-micro); color: var(--silk-mute); letter-spacing: 0; }
.nb-list { display: flex; flex-direction: column; gap: 3px; padding: 4px 0 2px; }

/* ---- the chip: glyph, name, port signature, belief ---- */
.nb-item {
  display: grid;
  /* glyph · name · port signature · belief — one 30px row. A second row for
     the belief bar would be honest but 19 modules × 44px is 836px of scroll,
     and the catalogue has to be scannable in one look. */
  grid-template-columns: 22px minmax(0, 1fr) auto 34px;
  align-items: center;
  gap: 6px;
  width: 100%;
  padding: 5px 7px;
  text-align: left;
  background: linear-gradient(180deg, #1f242b, #15181d);
  border: 1px solid #000;
  border-radius: 5px;
  /* Light from 315°, a black seam, and a shelf to travel into — the same
     physics every other pressable object in the app has. Without it the rail's
     nineteen most-repeated elements read as web cards on an instrument. */
  box-shadow: var(--lift), 0 2px 0 #0a0b0d;
  cursor: pointer;
  transition: border-color 120ms ease-out, color 120ms ease-out,
              transform 60ms ease-out, box-shadow 60ms ease-out;
}
.nb-item.hidden { display: none; }
.ni-name {
  /* The silk face at body size. Law 2 (tracking) applies to case, not to face:
     this is lowercase, so it gets none. The rail was set in a single 10px role
     throughout, which put the catalogue's headline below the rack's own
     silkscreen of the same word in size, weight and face. */
  font-family: var(--font-silk);
  font-size: var(--t-body);
  font-weight: 500;
  color: var(--silk);
  letter-spacing: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* The glyph answers one question — what does this do to a wave — nineteen
   times, so the set cannot drift as it grows. Drawn in the STROKE tier at
   rest: hover intensifies the phosphor rather than revealing it. */
.nb-glyph { width: 20px; height: 14px; overflow: visible; display: block; }
.nb-glyph .gl, .nb-glyph .gl-ghost, .nb-glyph .gl-rule {
  fill: none;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: stroke 120ms ease-out;
}
.nb-glyph .gl { stroke: var(--phos-a-deep); stroke-width: 1.5; }
.nb-glyph .gl-ghost { stroke: var(--phos-a-deep); stroke-width: 1; opacity: 0.5; }
.nb-glyph .gl-rule { stroke: var(--silk-mute); stroke-width: 0.9; stroke-dasharray: 2 2; }
/* A solid mark, not a reference rule: an arrowhead drawn with a 2-2 dash
   renders as two disconnected stubs. */
.nb-glyph .gl-mark, .sp-glyph .gl-mark {
  fill: none; stroke: var(--silk-mute); stroke-width: 0.9;
  stroke-linecap: round; stroke-linejoin: miter;
}
.nb-item.mod .nb-glyph .gl,
.nb-item.mod .nb-glyph .gl-ghost { stroke: var(--phos-b-deep); }

/* The port signature: both phosphors at rest, and the only place in the app
   that states a module's shape before you have committed to it. */
.ni-pips { display: inline-flex; align-items: center; gap: 2px; }
.ni-pips .pip {
  width: 5px; height: 5px;
  border-radius: 50%;
  /* The `-deep` tier is calibrated against the rack void (#0a0c0a). On the
     raised chip it loses a third of its contrast, so the port signature —
     the thing that puts both phosphors to work at rest — sits at the dim tier
     and still intensifies rather than appears on hover. */
  border: 1px solid var(--phos-a-dim);
  transition: border-color 120ms ease-out;
}
.ni-pips .pip.mod { border-color: var(--phos-b-dim); border-style: dashed; }
.ni-pips .pip-arrow { width: 8px; height: 6px; color: var(--silk-mute); flex: 0 0 auto; }
.ni-pips .pip-arrow path { fill: none; stroke: currentColor; stroke-width: 1; stroke-linecap: round; }

/* A belief, never a decoration: the bar is θ under the dominant style and the
   whisker is ±σ. Below the support threshold there is no bar at all — a short
   bar and "no evidence" must not look alike. */
/* The belief column only exists once the model has been fitted at all — 19
   identical placeholders on a fresh session is noise pretending to be data. */
.ni-theta { display: none; }
.has-belief .ni-theta { position: relative; width: 34px; height: 9px; display: block; }
/* The zero line, drawn only where there is a bar to be left or right OF it. */
.has-belief .ni-theta:not(.thin):not(.flat)::before {
  content: "";
  position: absolute;
  left: 17px; top: 0; bottom: 0;
  width: 1px;
  background: var(--silk-mute);
  opacity: 0.55;
}
/* No support: a dash. Never a short bar — "the model barely likes this" and
   "the model has never seen this" must not look alike. */
.has-belief .ni-theta.thin::after {
  content: "";
  position: absolute;
  left: 13px; right: 13px; top: 4px;
  height: 1px;
  background: var(--silk-mute);
  opacity: 0.45;
}
/* "Looked, and found no lean" is a *third* mark: a dot sitting on zero. It has
   to be distinguishable from both the dash (no evidence) and the shortest bar
   the geometry can draw, or the honesty the ladder buys is spent again here. */
.has-belief .ni-theta.flat::after {
  content: "";
  position: absolute;
  /* Centred on the zero rule at 17px, so "no lean" sits exactly where a bar
     of length zero would start. */
  left: 15.5px; top: 3px;
  width: 3px; height: 3px;
  border-radius: 50%;
  background: var(--silk-mute);
}
.ni-theta .tb-bar, .ni-theta .tb-whisk {
  position: absolute;
  top: 3px;
  height: 3px;
  border-radius: 1px;
}
/* The whisker is the uncertainty, so it has to be readable — it was drawn in
   the stroke tier at 1px, which is the least visible thing in the rail
   carrying the most important qualification in it. */
.ni-theta .tb-whisk {
  top: 4px;
  height: 1px;
  background: var(--phos-b-dim);
}
.ni-theta .tb-whisk::after {
  content: "";
  position: absolute;
  top: -2px; width: 1px; height: 5px;
  background: var(--phos-b-dim);
}
.ni-theta.pos .tb-bar, .ni-theta.pos .tb-whisk { left: 18px; }
.ni-theta.pos .tb-whisk::after { right: 0; }
.ni-theta.neg .tb-bar, .ni-theta.neg .tb-whisk { right: 17px; }
.ni-theta.neg .tb-whisk::after { left: 0; }

/* ---- states: the palette was the only clickable hardware with no travel ---- */
.nb-item:hover { border-color: var(--phos-a-dim); }
.nb-item:hover .ni-name { color: var(--phos-a); }
.nb-item:hover .nb-glyph .gl { stroke: var(--phos-a); }
.nb-item:hover .ni-pips .pip { border-color: var(--phos-a); }
.nb-item.mod:hover { border-color: var(--phos-b-dim); }
.nb-item.mod:hover .ni-name { color: var(--phos-b); }
.nb-item.mod:hover .nb-glyph .gl { stroke: var(--phos-b); }
.nb-item.mod:hover .ni-pips .pip.mod { border-color: var(--phos-b); }
.nb-item:active {
  transform: translateY(2px);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.nb-item.armed {
  border-color: var(--phos-a);
  box-shadow: inset 0 0 0 1px var(--phos-a-deep), 0 0 10px rgba(142,240,177,0.18);
}
.nb-item.armed .ni-name { color: var(--phos-a); }
.nb-item.armed .nb-glyph .gl { stroke: var(--phos-a); }
.nb-item.mod.armed {
  border-color: var(--phos-b);
  box-shadow: inset 0 0 0 1px var(--phos-b-deep), 0 0 10px rgba(255,180,84,0.18);
}
.nb-item.mod.armed .ni-name { color: var(--phos-b); }
.nb-item.mod.armed .nb-glyph .gl { stroke: var(--phos-b); }
.nb-item.unavailable { opacity: 0.4; cursor: not-allowed; }
.nb-item.unavailable:hover { border-color: var(--hairline); }
.nb-item.unavailable:hover .ni-name { color: var(--silk); }
.nb-item.unavailable:hover .nb-glyph .gl { stroke: var(--phos-a-deep); }
/* The one place the app broke its own colour law: a modulation control with a
   green focus ring. */
.nb-item.mod:focus-visible { outline-color: var(--phos-b); }

/* Unavailable is explained in place, never silent. */
.nb-why, .nb-empty, .nb-none {
  margin: var(--s1) var(--s3) 0;
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  line-height: 1.55;
  color: var(--silk-dim);
}
.nb-none { font-family: var(--font-voice); font-style: italic; font-size: var(--t-body); }
.nb-inline {
  background: none; border: none; padding: 0;
  font: inherit;
  color: var(--phos-a);
  text-decoration: underline dotted;
  cursor: pointer;
}

/* The armed line: what is in your hand, and how to put it down. */
.nb-status {
  position: sticky;
  bottom: 0;
  z-index: 3;
  margin: var(--s1) 0 0;
  padding: 7px var(--s3);
  font-size: var(--t-micro);
  line-height: 1.5;
  color: var(--phos-a);
  background: var(--bezel);
  border-top: 1px solid var(--phos-a-deep);
}
.nb-status:empty { display: none; }
.nb-status b { font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; }
.sp-dim { color: var(--silk-mute); }

/* The colour law as a legend — in the stroke tier it actually legends, and no
   longer hidden from assistive tech, since it is the app's only statement of
   what green and amber mean. */
.nb-legend { display: flex; flex-direction: column; gap: 3px; margin: var(--s3) var(--s3) 0; }
.nb-legend .lg-row {
  display: flex; align-items: center; gap: 7px;
  font-family: var(--font-mono); font-size: var(--t-micro); color: var(--silk-mute);
}
.nb-legend .lg-swatch { width: 22px; height: 0; border-top: 3px solid var(--phos-a-deep); border-radius: 2px; }
.nb-legend .lg-swatch.mod { width: 22px; border-top: 1.8px dashed var(--phos-b-deep); }

/* ---- IN THIS PATCH: the rail as a legend for the canvas beside it ---- */
.nb-inpatch { margin: var(--s1) var(--s3) 0; }
.nb-inpatch.hidden { display: none; }
.nb-inpatch .nb-sect {
  display: flex; align-items: baseline; gap: 6px;
  border-bottom: 1px solid var(--hairline);
  padding-bottom: 3px;
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  font-weight: 700;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--silk-dim);
}
.nb-inpatch .nb-n { margin-left: auto; }
.nb-chips { display: flex; flex-wrap: wrap; gap: 3px; padding: 5px 0 2px; }
.nb-chip {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 2px 6px 2px 4px;
  background: rgba(255,255,255,0.02);
  border: 1px solid var(--hairline);
  border-radius: 999px;
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: var(--silk-dim);
  cursor: pointer;
  transition: border-color 120ms ease-out, color 120ms ease-out;
}
.nb-chip .nb-glyph { width: 14px; height: 10px; }
/* An empty socket in the in-patch list (p4). It used to print the substitute
   node's name — "vco" — beside a canvas drawing a dashed EMPTY plate, so the
   two surfaces disagreed about a destructive act the player had just made. It
   borrows the plate's own language instead: the dashed stroke, the muted
   silkscreen colour, and no belief cell, because the model has no opinion
   about absence. */
.nb-chip.empty {
  color: var(--silk-mute);
  border-style: dashed;
  background: none;
}
.nb-chip.empty .nb-glyph .gl { stroke: var(--silk-mute); stroke-width: 1.2; }
.nb-chip.empty:hover { color: var(--silk-dim); border-color: var(--silk-mute); }
.nb-chip.empty .ni-theta { display: none; }
/* The belief cell rides on the in-patch chip too — narrower, because the chip
   is a pill in a wrapping row rather than a full-width catalogue row, but the
   same three marks and the same refusal to draw a bar without evidence. */
.nb-chips.has-belief .ni-theta { width: 24px; margin-left: 2px; }
.nb-chips.has-belief .ni-theta:not(.thin):not(.flat)::before { left: 12px; }
.nb-chips.has-belief .ni-theta.thin::after { left: 8px; right: 8px; }
.nb-chips.has-belief .ni-theta.flat::after { left: 10.5px; }
.nb-chips.has-belief .ni-theta.pos .tb-bar, .nb-chips.has-belief .ni-theta.pos .tb-whisk { left: 13px; }
.nb-chips.has-belief .ni-theta.neg .tb-bar, .nb-chips.has-belief .ni-theta.neg .tb-whisk { right: 12px; }
/* The bar's length is written inline in px against 16 units of travel; in the
   narrower cell it is clamped rather than rescaled, so a long bar reads as
   "off the end", never as a shorter belief than the catalogue is showing. */
.nb-chips.has-belief .ni-theta .tb-bar, .nb-chips.has-belief .ni-theta .tb-whisk { max-width: 11px; }
.nb-chip:hover { border-color: var(--phos-a-dim); color: var(--phos-a); }
.nb-chip.mod:hover { border-color: var(--phos-b-dim); color: var(--phos-b); }

/* ---- the spec card: one sentence, the ports, and what the model hears ---- */
.nb-spec {
  position: fixed;
  z-index: 60;
  width: 244px;
  padding: 11px 12px 12px;
  border-radius: 6px;
  background: var(--surface-pop);
  box-shadow: var(--shadow-pop);
  pointer-events: none;
}
.nb-spec.hidden { display: none; }

/* ---- the docked spec strip ----
   The card's real home. It reads left to right the way the chip does — what it
   is, what it does, what the model thinks — and it holds a reserved height so
   that nothing on the bench moves as its content changes. The floating card
   above is now the keyboard's copy only. */
/* Two heights, and which one is in force is decided by whether there is
   anything to describe (M4).

   The strip reserves its height so that nothing under the pointer moves as its
   content changes — that rule is right and it stays. What was wrong is *which*
   height was reserved: 96–150 px of "Point at a module…" held permanently
   against a rack frame that was 295 px at 1280×900, which is how five of five
   stock presets came to open as knob-less block diagrams on a standard laptop.
   A sentence gets a sentence's worth of room. A description gets a
   description's.

   Resting and armed share the one-line height on purpose: arming is a gesture
   that happens over the canvas, and a canvas that resizes under a placement in
   progress would be a worse defect than the one this fixes. */
.play-canvas { --spec-h: clamp(96px, 13vh, 150px); --spec-line: 46px; }
.spec-dock {
  flex: 0 0 var(--spec-h);
  display: grid;
  grid-template-columns: 208px minmax(0, 1fr) 300px;
  gap: var(--s4);
  align-items: start;
  padding: 11px var(--s4);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  background: linear-gradient(180deg, #14171b, #101317);
  box-shadow: var(--lift);
  overflow: hidden;
}
/* Resting and armed are one line, so they span the whole strip rather than
   leaving two empty columns where the description used to be. */
.spec-dock.rest, .spec-dock.armed {
  grid-template-columns: 1fr;
  align-items: center;
  /* `min`, so the divider above still gives feedback when it is dragged past
     the line height — a control with no visible effect in the state you happen
     to be in is a control you stop trusting. */
  flex-basis: min(var(--spec-h), var(--spec-line));
  padding-top: 6px; padding-bottom: 6px;
}
.sd-rest {
  font-size: var(--t-micro);
  line-height: 1.7;
  color: var(--silk-mute);
  /* One line, and it ends in an ellipsis rather than wrapping out of a strip
     that is now one line tall. */
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  max-width: 100%;
}
.sd-id { display: flex; align-items: flex-start; gap: 9px; min-width: 0; }
.sd-idtext { min-width: 0; }
.sd-name { display: block; margin-bottom: 3px; }
.sd-ports { font-size: var(--t-micro); line-height: 1.6; color: var(--phos-a-dim); }
.sd-body { min-width: 0; }
.sd-body .sp-blurb { margin: 0 0 6px; }
.sd-strip { display: flex; flex-wrap: wrap; gap: 4px var(--s4); font-size: var(--t-micro); line-height: 1.6; }
.sd-model {
  font-size: var(--t-micro);
  line-height: 1.6;
  color: var(--silk-dim);
  border-left: 1px solid var(--hairline);
  padding-left: var(--s4);
  min-width: 0;
}
/* One line, and it names the thing in your hand — the strip stops being a
   description the moment the question becomes "where does this go". */
.sd-line { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; min-width: 0; }
.sd-line b { font-family: var(--font-silk); font-size: var(--t-body); color: var(--phos-a); letter-spacing: 0.02em; }
.sd-verb {
  font-size: var(--t-micro); letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--silk-mute);
}
.sd-blurb {
  font-family: var(--font-voice); font-style: italic; font-size: var(--t-body);
  color: var(--silk-dim); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sd-hint { font-size: var(--t-micro); color: var(--silk-mute); margin-left: auto; white-space: nowrap; }
.sd-hint kbd { font-size: var(--t-micro); color: var(--silk-dim); }

/* ---- priced sockets (WS-2 §5) ----
   The number sits in a recessed well so it reads as an instrument readout
   rather than as more prose, and its sign carries the colour. Green is the
   signal channel everywhere else in this app, so a *prediction* deliberately
   does not get it: the price is the model talking, and the model's colour on
   this instrument is amber. */
.sd-price {
  display: inline-flex; align-items: baseline; gap: 6px;
  padding: 2px 8px;
  border: 1px solid var(--hairline);
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.25);
  font-size: var(--t-micro);
  white-space: nowrap;
  cursor: help;
}
.pr { font-weight: 500; letter-spacing: 0.02em; }
.pr.up { color: var(--phos-b); }
.pr.down { color: var(--led-red); }
.pr.pr-flat { color: var(--silk-dim); }
.pr.pr-mute { color: var(--silk-mute); }
.pr-dim { color: var(--silk-mute); }
.pr-note {
  color: var(--silk-mute); text-transform: uppercase;
  letter-spacing: 0.14em; font-size: 0.9em;
  border-bottom: 1px dotted var(--hairline);
}
.nb-price { display: block; margin-top: 3px; font-family: var(--font-mono); font-size: var(--t-micro); }

/* ---- pre-placement audition (WS-2 §6) ---- */
.pv { display: inline-flex; align-items: center; gap: 7px; }
.pv-play {
  width: 22px; height: 22px; flex: 0 0 22px;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--hairline); border-radius: 50%;
  background: rgba(0, 0, 0, 0.25);
  color: var(--phos-a); font-size: 9px; line-height: 1; cursor: pointer;
  padding: 0 0 0 1px;
}
.pv-play:hover:not(:disabled) { color: #fff; border-color: var(--phos-a-deep); background: rgba(142, 240, 177, 0.1); }
.pv-play:disabled { color: var(--silk-mute); cursor: not-allowed; }
.pv-play.playing { color: #fff; box-shadow: 0 0 8px rgba(142, 240, 177, 0.45); }
/* Tall enough to be a measurement. At 22px the trace, its zero line and the
   two full-scale references all landed within a few pixels of each other and
   the audition read as a flat line whatever it actually was. */
.pv-scope {
  width: 156px; height: 38px;
  border: 1px solid var(--hairline); border-radius: 3px;
  background: rgba(0, 0, 0, 0.35);
}
/* The armed strip is one line tall now (M4), and the preview scope is the only
   thing in it that is not text. 30px still reads as a waveform — it is a
   "does this sound like anything" glance, not a measurement. */
.spec-dock.armed .pv-scope { height: 30px; }
.pv-label { font-size: var(--t-micro); color: var(--silk-mute); white-space: nowrap; }
/* Rendering is a half-second of somebody else's CPU: say so by dimming the
   thing that is about to change rather than by adding a spinner. */
.pv.busy .pv-scope { opacity: 0.45; }
.pv.busy .pv-label { color: var(--phos-b-deep); }
.pv.dead .pv-scope { opacity: 0.3; }
.sp-glyph.small { width: 24px; height: 17px; }
.sp-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.sp-glyph { width: 34px; height: 24px; overflow: visible; }
.sp-glyph .gl { fill: none; stroke: var(--phos-a); stroke-width: 1.3; stroke-linecap: round; stroke-linejoin: round; }
.sp-glyph .gl-ghost { fill: none; stroke: var(--phos-a-deep); stroke-width: 1; stroke-linecap: round; }
.sp-glyph .gl-rule { fill: none; stroke: var(--silk-mute); stroke-width: 0.8; stroke-dasharray: 2 2; }
.sp-glyph.mod .gl { stroke: var(--phos-b); }
.sp-glyph.mod .gl-ghost { stroke: var(--phos-b-deep); }
/* The teaching sentence is the app's voice, so it is set in the voice face —
   the same treatment the model's own statements get elsewhere. */
.sp-blurb {
  margin: 0 0 8px;
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--t-body);
  line-height: 1.5;
  color: var(--silk);
}
.sp-ports, .sp-params, .sp-model, .sp-heard {
  font-size: var(--t-micro);
  line-height: 1.6;
  color: var(--silk-dim);
}
.sp-ports { color: var(--phos-a-dim); }
.sp-params { color: var(--silk-mute); }
.sp-model { margin-top: 6px; border-top: 1px solid var(--hairline); padding-top: 6px; }
.sp-heard { color: var(--silk-mute); }
.sp-heard b { color: var(--phos-b-dim); font-weight: 400; }
.sp-dot {
  display: inline-block; width: 7px; height: 7px; border-radius: 50%;
  margin: 0 4px -1px 0;
}
.sp-belief { color: var(--phos-b-dim); }

/* Screen-reader-only live region for the arm/place announcements. */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* ---- tray (staged, unwired modules) ---- */
.tray {
  flex: 0 0 64px;
  display: flex;
  align-items: stretch;
  gap: 10px;
  border: 1px dashed #3a4048;
  border-radius: 6px;
  background: rgba(10, 11, 13, 0.6);
  padding: 6px 10px;
}
.tray-label {
  writing-mode: vertical-lr;
  transform: rotate(180deg);
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  font-weight: 700;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--silk-dim);
  align-self: center;
}
.tray-items { flex: 1; display: flex; gap: 8px; overflow-x: auto; align-items: center; }
.tray-hint { font-size: var(--t-micro); color: var(--silk-dim); font-style: italic; }
/* An empty tray is a slot, not a 74px dashed hole in the rack. It opens when
   a module is staged. */
.tray:not(:has(.tray-item)) {
  flex-basis: 26px;
  border-style: solid;
  border-color: #1c2027;
  align-items: center;
}
.tray:not(:has(.tray-item)) .tray-label { writing-mode: horizontal-tb; transform: none; }
.tray:not(:has(.tray-item)) .tray-hint { opacity: 0.55; }
/* A staged module is a small faceplate, not a name tag: its head, its jack,
   and the parameters it will bring with it. */
.tray-item {
  position: relative;
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  gap: 3px;
  border: 1px dashed #4a505b;
  border-radius: 5px;
  background: linear-gradient(180deg, #1a1e24, #13161a);
  padding: 6px 10px;
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: var(--silk);
  max-width: 240px;
}
.tray-item .ti-head { display: flex; align-items: center; gap: 7px; }
.tray-item .ti-name {
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
.tray-item .ti-params {
  color: var(--silk-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.tray-item .t-jack {
  width: 13px; height: 13px;
  border-radius: 50%;
  border: 2px solid var(--phos-a);
  background: radial-gradient(circle at 40% 35%, #10160f, #000);
  cursor: grab;
  box-shadow: 0 0 6px rgba(142,240,177,0.35);
}
.tray-item.mod .t-jack {
  border-color: var(--phos-b);
  box-shadow: 0 0 6px rgba(255,180,84,0.35);
}
.tray-item .t-x {
  background: none; border: none; color: var(--silk-dim);
  font-size: var(--t-label); cursor: pointer; padding: 0 2px;
}
.tray-item .t-x:hover { color: var(--led-red); }
/* On its way into the patch: it stays on the shelf until the engine has taken
   it (a shelf that empties on a promise is how a held chain got lost), but it
   is inert while it waits, so it cannot be placed twice. */
.tray-item.pending { opacity: 0.5; }
.tray-item.pending .t-jack { cursor: progress; box-shadow: none; }
.tray-item.pending .t-x { cursor: default; }

/* ---- jacks & wires ---- */
.jack { cursor: crosshair; }
.jack circle {
  fill: #0a0c0a;
  stroke: var(--phos-a-deep);
  stroke-width: 1.6;
}
.jack.modjack circle { stroke: var(--phos-b-deep); }
.jack text {
  font-family: var(--font-mono);
  font-size: var(--t-rack-micro);
  fill: var(--silk-dim);
  pointer-events: none;
}
.jack.legal circle {
  stroke: var(--phos-a);
  filter: drop-shadow(0 0 4px rgba(142,240,177,0.9));
  animation: jack-pulse 700ms ease-in-out infinite;
}
/* A source EVICTS what is in the socket; a processor splices in front of it.
   Those two drops used to look identical right up until one of them had
   already thrown a chain away. Amber is the warning, and it is on the target
   before the pointer gets there. */
.jack.legal.replaces circle {
  stroke: var(--phos-b);
  filter: drop-shadow(0 0 4px rgba(255,180,84,0.9));
}
.jack.legal.replaces text { fill: var(--phos-b); }
/* The keyboard's cursor through the lit sockets. */
.jack.hot circle:nth-of-type(1) { fill: rgba(142,240,177,0.16); }
.jack.hot circle { stroke-width: 2; animation: none; }
.jack.legal.replaces.hot circle:nth-of-type(1) { fill: rgba(255,180,84,0.16); }
/* Something is in your hand: the whole canvas says so. */
.rack-scroll.placing { cursor: crosshair; }
.rack-scroll.placing #rack-svg { cursor: crosshair; }
.jack.modjack.legal circle {
  stroke: var(--phos-b);
  filter: drop-shadow(0 0 4px rgba(255,180,84,0.9));
}
@keyframes jack-pulse { 50% { stroke-width: 3; } }
.wiring .jack:not(.legal) { opacity: 0.35; }
/* The output a cable is currently coming out of, while it is out. */
.wiring .jack[data-outkey].hot { opacity: 1; }
.wiring .jack[data-outkey].hot circle { stroke: var(--phos-a); stroke-width: 2.4; }

/* ---- an empty socket ----
   The grammar has no hole: every input is filled, so an unplugged socket is
   still a real node underneath. The plate is what says otherwise — dashed,
   unpopulated, and asking for something. */
.mod-plate.placeholder {
  fill: rgba(255, 255, 255, 0.012);
  stroke: var(--silk-dim);
  stroke-width: 1.2;
  stroke-dasharray: 6 5;
}
.mod-title.empty { fill: var(--silk-dim); }
.plate-hint {
  font-family: var(--font-mono);
  font-size: var(--t-rack-micro);
  fill: var(--silk-mute);
  pointer-events: none;
}

/* ---- pick mode ----
   Arming used to be invisible on the canvas. Dimming is the cheapest way to
   say "not these", and it leaves the targets at full contrast rather than
   painting a highlight over half the rack. */
#rack-svg.picking g[data-key].dimmed { opacity: 0.28; }
#rack-svg.picking g[data-key] { transition: opacity 140ms cubic-bezier(0.2, 0, 0.6, 1); }
.pick-caret path {
  stroke: var(--phos-a);
  stroke-width: 2;
  stroke-linecap: round;
  fill: none;
  filter: drop-shadow(0 0 5px rgba(142, 240, 177, 0.8));
  animation: caret-pulse 900ms ease-in-out infinite;
}
@keyframes caret-pulse { 50% { opacity: 0.35; } }
.pick-halo {
  fill: none;
  stroke: var(--phos-a);
  stroke-width: 1.4;
  stroke-dasharray: 5 4;
  opacity: 0.75;
  pointer-events: none;
}
/* Amber is the app's one word for "something here goes away". */
.pick-halo.replaces { stroke: var(--phos-b); opacity: 0.95; stroke-width: 1.8; }

/* ---- the ghost plate ----
   The module in your hand, drawn where it will land, at rack scale. Greyed and
   desaturated so it can never be mistaken for a module that is already in the
   patch, and dashed so its edge reads as a proposal rather than a panel. It is
   not a target for anything — the sockets under it still are. */
/* Nearly opaque, and desaturated to the bone. An insertion genuinely lands in
   the 28-unit gutter between two plates, so a translucent ghost there reads as
   a smear across both of them; a near-solid grey card reads as an object lying
   on the panel, which is what a proposal should look like. Grey plus a dashed
   edge is what keeps it from being mistaken for a module already in the patch,
   since every real plate in the rack is lit green or amber. */
.pick-ghost {
  pointer-events: none;
  opacity: 0.92;
  /* The cast is what says "above the panel, not part of it". */
  filter: grayscale(0.85) drop-shadow(0 6px 12px rgba(0, 0, 0, 0.75));
  animation: ghost-in 160ms cubic-bezier(0.2, 0, 0.6, 1);
}
@keyframes ghost-in { from { opacity: 0; transform: scale(0.97); } }
.pick-ghost .mod-plate {
  fill: #10131a;
  stroke: var(--phos-a);
  stroke-width: 1.3;
  stroke-dasharray: 6 4;
}
.pick-ghost.modside .mod-plate { stroke: var(--phos-b); }
.pick-ghost .knob-body { filter: none; }
@media (prefers-reduced-motion: reduce) {
  .pick-ghost { animation: none; }
}

.pick-chip {
  position: absolute;
  z-index: 6;
  transform: translate(-50%, -100%);
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 3px 6px 3px 9px;
  border: 1px solid var(--hairline);
  border-radius: 5px;
  background: var(--surface-pop);
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.55);
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  letter-spacing: 0.06em;
  color: var(--silk);
  white-space: nowrap;
  pointer-events: auto;
}
.pick-chip .pick-chip-text { text-transform: uppercase; }
/* The price, on the plate. Separated by a rule rather than a gap so the chip
   reads as one object with two facts in it — what happens, and what it is
   expected to be worth. */
.pick-chip .pick-chip-price {
  border-left: 1px solid var(--hairline);
  padding-left: 6px;
  letter-spacing: 0;
  font-size: var(--t-micro);
}
.pick-chip .pick-chip-price.hidden { display: none; }
.pick-chip kbd {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: var(--silk-mute);
  border: 1px solid var(--hairline);
  border-radius: 3px;
  padding: 0 3px;
}
.pick-chip-x {
  background: none; border: none; padding: 0 2px; cursor: pointer;
  color: var(--silk-dim); font-size: var(--t-micro); line-height: 1;
}
.pick-chip-x:hover { color: var(--led-red); }

/* The chooser's second line: what this row will do to the patch, in the
   patch's own words, before it happens. */
.ctx-menu .cm-item.cm-two { line-height: 1.35; }
.ctx-menu .cm-item .cm-sub {
  display: block;
  margin-top: 2px;
  max-width: 30ch;
  white-space: normal;
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: var(--silk-dim);
}
.ctx-menu .cm-item:hover .cm-sub { color: var(--silk-mute); }

/* ---- the structure menu's header, borrowed from the node bank's spec card ----
   The menu and the card describe the same objects, so they say so in the same
   language: the module's own transfer-function glyph, then its silkscreen. */
.ctx-menu .cm-title {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px 4px;
  border-bottom: 1px solid var(--hairline);
  margin-bottom: 4px;
}
.ctx-menu .cm-title-name {
  font-family: var(--font-silk);
  font-size: var(--t-body);
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--silk);
}
/* Physical distance is the point: `delete` is not one more row in the list,
   and a hand travelling to it should have to notice that it did. */
.ctx-menu .cm-item.cm-sep {
  margin-top: 8px;
  padding-top: 9px;
  border-top: 1px solid var(--hairline);
}
.ctx-menu .cm-item.danger { color: var(--silk); }
.ctx-menu .cm-item.danger::before {
  content: "";
  display: inline-block;
  width: 6px; height: 6px;
  border-radius: 50%;
  margin-right: 7px;
  vertical-align: 1px;
  background: var(--led-red);
  box-shadow: 0 0 5px rgba(255,87,71,0.5);
}
/* A verb that cannot apply here still prints — the list of what a module can
   do should not change shape under the hand — but it is dimmed and it says
   why on hover, which is this app's standing treatment for a refusal. */
.ctx-menu .cm-item[disabled] {
  color: var(--silk-dim);
  opacity: 0.45;
  cursor: not-allowed;
}
.ctx-menu .cm-item[disabled]:hover { background: none; color: var(--silk-dim); }
/* Clamped at 70vh: end the list in a fade, not in a clean cut that reads as
   "that is all of them". */
.ctx-menu.cm-clamped {
  mask-image: linear-gradient(to bottom, #000 calc(100% - 26px), transparent 100%);
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 26px), transparent 100%);
}

/* A module plate is a tabstop now; it needs a ring like every other one. */
.plate-focus { fill: none; stroke: none; pointer-events: none; }
.mod-group:focus { outline: none; }
.mod-group:focus-visible .plate-focus {
  stroke: var(--phos-a);
  stroke-width: 1.5;
  stroke-dasharray: 5 4;
}
/* Why a fragment is on the shelf, when it did not simply come out of a socket. */
.ti-why {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: var(--phos-b);
  letter-spacing: 0.04em;
  margin-left: 5px;
}

#wire-overlay {
  position: fixed;
  inset: 0;
  z-index: 55;
  pointer-events: none;
  width: 100vw;
  height: 100vh;
}
#wire-overlay path {
  fill: none;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-dasharray: 7 5;
}
#wire-overlay path.audio { stroke: rgba(142,240,177,0.85); filter: drop-shadow(0 0 5px rgba(142,240,177,0.6)); }
#wire-overlay path.mod { stroke: rgba(255,180,84,0.85); filter: drop-shadow(0 0 5px rgba(255,180,84,0.6)); }

/* ---- responsive ---- */
/* A and B stay side by side at every width — the comparison IS the
   interaction. The previous rule dropped B onto a third grid row inside an
   `overflow: hidden` view, so the cards overprinted the controls. */
@media (max-width: 1280px) {
  /* The chip gets its own row so it stops fighting the 8-control toolbar
     for wrap space, and the toolbar tightens. */
  .stage-toolbar { row-gap: 6px; }
  .rack-tools { gap: 6px; }
  .util-btn { padding: 6px 8px; }
}
@media (max-width: 1080px) {
  .bank { width: 200px; flex-basis: 200px; }
  .rack-tools { gap: var(--s1); }
  .duel-mid { gap: var(--s3); }
}
/* Short laptops: the deck is the elastic part, so give the fixed chrome less.
   The breakpoint was 860px, which put the plan's own second test size — 1280×900,
   a MacBook — on the *tall* side of a rule written for exactly that machine.
   940 covers it. Nothing in the block changed; it simply now applies where it
   was always meant to. */
@media (max-height: 940px) {
  :root { --keybar-h: 108px; }
  .tray { flex: 0 0 52px; }
  /* The canvas is the elastic part everywhere else in this block, and the spec
     strip is the newest claim on it — so it gives ground first. Its height is
     still reserved, just smaller: the blurb wraps rather than the strip
     appearing and disappearing under the pointer. */
  .play-canvas { --spec-h: 92px; }
  .spec-dock { padding: 8px var(--s4); }
  /* Below this the rack cannot hold both a patch and a 268px catalogue, so the
     rail folds itself to the 34px group tab rather than squeezing the canvas. */
  .nb-body { width: clamp(180px, 15vw, 220px); }
  /* A truncated hint is worse than no hint — the group word already carries
     the meaning, and the note is the part that can be dropped. */
  .nb-note { display: none; }
  /* Same rule for the search field: at the clamp minimum the placeholder is
     the teaching, and 18px of keycap is what pushes it into an ellipsis. The
     shortcut is still in the help overlay. */
  .nb-slash { display: none; }
  .nb-q { padding-right: 8px; }
}
.keybar { flex-basis: var(--keybar-h); }

@media (prefers-reduced-motion: reduce) {
  .nb-item, .nb-chip, .nb-glyph .gl, .nb-glyph .gl-ghost, .ni-pips .pip, .nb-caret {
    transition: none !important;
  }
  .nb-item:active { transform: none; }
  .jack.legal circle { animation: none; }
  * { animation: none !important; transition: none !important; }
}

/* ---------- performance controls (keybar) ---------- */
.perf-ctl { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
/* Selects and the tempo readout wear the same machined material as the
   buttons beside them — nothing in the keybar is a native widget. */
.perf-sel, .perf-num {
  color: var(--phos-a-dim);
  background: linear-gradient(180deg, #232830, #181b21);
  border: 1px solid #000;
  border-radius: 3px;
  box-shadow: var(--lift), 0 1px 0 #0a0b0d;
  font: var(--t-micro)/1.4 var(--font-mono);
  padding: 3px 4px;
  cursor: pointer;
}
.perf-sel:hover, .perf-num:hover { color: var(--phos-a); }
.perf-num { width: 44px; text-align: center; }
/* The spinners are the most native pixels a number input has. */
.perf-num::-webkit-inner-spin-button,
.perf-num::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.perf-glide { display: flex; align-items: center; gap: 3px; font-size: var(--t-micro); color: var(--phos-a-dim); }
.perf-glide input[type="range"] { width: 54px; }
.midi-ind { font-size: var(--t-micro); color: var(--silk-dim); }
.midi-ind.on { color: var(--phos-a); text-shadow: 0 0 6px rgba(142,240,177,0.6); }
#rec-btn.lit { color: var(--led-red); border-color: var(--led-red); text-shadow: 0 0 8px rgba(255,87,71,0.8); }

/* ---------- taste style chips & duel prediction ---------- */
.style-chips { display: flex; gap: 8px; flex-wrap: wrap; padding: 4px 14px; }
.style-chip {
  display: flex; align-items: center; gap: 6px;
  background: #14171c; border: 1px solid #2c313a; border-radius: 12px;
  padding: 3px 8px;
}
.style-chip i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.style-chip .sc-name {
  background: transparent; border: none; outline: none;
  color: var(--silk); font: var(--t-body)/1.4 var(--font-mono); width: 168px;
}
.style-chip .sc-share { font-size: var(--t-micro); color: var(--silk-dim); }
.style-chip .sc-play {
  background: none; border: none; color: var(--phos-a, #8ef0b1);
  cursor: pointer; font-size: var(--t-micro); padding: 0 2px;
}
.style-badge { display: inline-flex; align-items: center; gap: 4px; font-size: var(--t-micro); color: var(--silk-dim); }
.style-badge i { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.duel-pred { font-size: var(--t-micro); color: var(--silk-dim); text-align: center; min-height: 12px; }
.duel-pred.hit { color: var(--phos-a, #8ef0b1); }
.duel-pred.miss { color: var(--phos-b, #ffb454); }

/* ---------- living modulation (tier 4) ---------- */
.wire.mod.pulse { animation: modpulse linear infinite; }
.jack.modjack.pulse circle { animation: jackpulse 1.2s ease-in-out infinite; }
@keyframes modpulse { 0%,100% { stroke-opacity: 0.45; } 50% { stroke-opacity: 1; } }
@keyframes jackpulse { 0%,100% { fill-opacity: 0.55; } 50% { fill-opacity: 1; } }

/* ---------- duel staging ---------- */
.duel-card.deal { animation: deal 0.32s cubic-bezier(0.2, 0.9, 0.3, 1.1); }
@keyframes deal {
  from { opacity: 0; transform: translateY(10px) scale(0.985); }
  to { opacity: 1; transform: none; }
}

/* ---------- quick duel strip (PLAY) ---------- */
.play-duel {
  display: flex; align-items: center; gap: 8px;
  padding: 4px 12px; font-size: var(--t-micro);
  border-top: 1px solid #262b33; background: #14171cd9;
}
.play-duel .pd-label { font-size: var(--t-micro); letter-spacing: 0.12em; text-transform: uppercase; }
.pd-side {
  background: #191d24; border: 1px solid #2c313a; border-radius: 3px;
  color: var(--phos-a, #8ef0b1); font: var(--t-micro)/1.4 var(--font-mono);
  padding: 2px 8px; cursor: pointer; max-width: 180px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pd-side:hover { border-color: var(--phos-a, #8ef0b1); }

/* ---------- help overlay ---------- */
.help-overlay {
  position: fixed; inset: 0; z-index: 60;
  background: rgba(10, 12, 15, 0.82); backdrop-filter: blur(3px);
  display: flex; align-items: center; justify-content: center;
}
.help-card { max-width: 720px; width: min(92vw, 720px); padding: 22px 26px; position: relative; }
.help-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 26px; font-size: var(--t-label); line-height: 1.55; }
.help-cols p { margin: 4px 0 10px; color: var(--silk); }
.help-cols b { color: var(--phos-a, #8ef0b1); font-weight: 600; }
.help-h {
  color: var(--phos-b, #ffb454); font-size: var(--t-micro); letter-spacing: 0.14em;
  text-transform: uppercase; margin-top: 8px;
}
.help-cols kbd {
  background: #1d222a; border: 1px solid #333a45; border-bottom-width: 2px;
  border-radius: 3px; padding: 0 4px; font-size: var(--t-micro);
}
/* The instrument's only About surface. Quiet enough that it never competes with
   the thing a reader opened this card for. */
.help-fine {
  margin: 14px 0 0; font-size: var(--t-micro); letter-spacing: 0.06em;
  color: var(--silk-dim); border-top: 1px solid var(--hairline); padding-top: 12px;
}
.help-fine a { color: var(--silk-dim); }
.help-fine a:hover { color: var(--phos-a, #8ef0b1); }
#help-close { margin-top: 14px; }
@media (max-width: 640px) { .help-cols { grid-template-columns: 1fr; } }

/* ---------- touch pass ----------
   A coarse pointer is a real input, not a degraded mouse. Everything here is
   inside `@media (pointer: coarse)` on purpose: a mouse must see exactly the
   instrument it saw before, so nothing below may leak into the desktop
   cascade. Two laws:

   1. WHAT HOVER REVEALS, TOUCH CANNOT REACH. A finger has no hover state, so
      every affordance gated behind `:hover` is, on a tablet, a feature that
      does not exist. Those are revealed here instead — dimmed rather than
      lit, so they read as available rather than active.
   2. A GESTURE MUST BE CLAIMED BEFORE THE BROWSER CLAIMS IT. Panning wins
      ties, so a control that owns a drag says so (`touch-action`), and a
      frame that must scroll says that too. See `claimGesture` in main.js —
      CSS alone doesn't hold in every engine for SVG children. */
@media (pointer: coarse) {
  .util-btn, .inspect-btn, .hw-btn { min-height: 30px; min-width: 30px; }
  /* Only the invisible hit circle grows. `.jack circle` hit all three — the
     knurled nut (6.5), the dark bore (3.4) and the hit target (5.5) — and a
     CSS geometry property beats the presentation attribute, so every jack on
     every touch device rendered as one flat 8px disc with its bore as wide
     as its nut. The jack is the thing you aim a cable at; it has to keep
     looking like a socket while getting easier to hit. */
  .jack circle:last-of-type { r: 9; }
  .bank-item .star { font-size: var(--t-lead); padding: 4px; }
  .perf-glide input[type="range"], #vol { min-height: 26px; }

  /* Law 1 — the hover-gated affordances, made reachable. The knob lock dot
     is the third one and it is NOT here: `.lock-dot` is re-declared further
     down this file, and a later rule of equal specificity wins no matter
     what a media query wraps it in. It sits with its own kin instead. */
  .bank-item .stars, .bank-item .bi-kill { opacity: 1; visibility: visible; }
  .bank-item .bi-kill { color: var(--silk-dim); }

  /* Law 2 — gesture ownership.
     The rack frame no longer scrolls: it is a camera, and the drag and the
     pinch are both handled in JS against the viewBox. Handing the browser
     `pan-x pan-y` here would give it a scroll that does not exist and eat
     the one-finger pan before the app ever sees it. */
  .rack-scroll { touch-action: none; }
  .bank-list, .nb-body, .tray-items { overscroll-behavior: contain; }

  /* A knob held for half a second is a gesture, not a request for the iOS
     callout menu and a selection of the label underneath it. */
  .rack-scroll, .piano, .duel-card, .tray { -webkit-touch-callout: none; }
  #rack-svg { user-select: none; -webkit-user-select: none; }

  /* Double-tap to rename has to beat double-tap to zoom. */
  .bank-item .bi-name { touch-action: manipulation; }

  /* The grey flash the OS paints over a tapped element belongs to a web page,
     not to a faceplate — the app draws its own :active states. */
  * { -webkit-tap-highlight-color: transparent; }
}

/* ---------- reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
  .wire.pulse, .wire.mod.pulse,
  .jack.modjack.pulse circle, .duel-card.deal { animation: none !important; }
}

/* =====================================================================
   Pass 7 — the surfaces added to make the instrument legible and alive.
   ===================================================================== */

/* ---------- focus ----------
   Only .hw-btn had a focus ring, on a near-black palette where the UA default
   is invisible, and two rules actively removed outlines. Everything gets one. */
.util-btn:focus-visible,
.viewtab:focus-visible,
.tab:focus-visible,
.inspect-btn:focus-visible,
.nb-item:focus-visible,
.nb-fold:focus-visible,
.nb-chip:focus-visible,
.nb-rail:focus-visible,
.nb-inline:focus-visible,
.nb-collapse:focus-visible,
.cm-item:focus-visible,
.ovf-item:focus-visible,
.warm-item:focus-visible,
.bank-item:focus-visible,
.bank-list:focus-visible,
.star:focus-visible,
.bi-kill:focus-visible,
.bi-hear:focus-visible,
.bi-save:focus-visible,
.bank-tour button:focus-visible,
.note-more:focus-visible,
.bank-tour-btn:focus-visible,
.nextstep:focus-visible,
.pd-side:focus-visible,
.bank-filters .bf:focus-visible,
input:focus-visible,
select:focus-visible,
[data-addr]:focus-visible {
  outline: 2px solid var(--phos-a);
  outline-offset: 2px;
  /* No border-radius here: the ring must follow each element's own shape.
     A shared 3px radius squared the .nextstep pill and snapped .hw-btn on
     every keyboard step. */
}
[data-addr]:focus-visible .knob-body { stroke: var(--phos-a); stroke-width: 2; }

/* ---------- menubar: skill readout + overflow ---------- */
.skill { font-size: var(--t-micro); color: var(--phos-b-dim); max-width: 190px; }
.ovf { position: relative; }
.ovf-menu {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 40;
  min-width: 214px;
  padding: var(--s1);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  background: var(--surface-pop);
  box-shadow: var(--shadow-pop);
}
.ovf-item {
  display: block;
  width: 100%;
  text-align: left;
  font-size: var(--t-body);
  color: var(--silk);
  background: none;
  border: none;
  border-radius: 4px;
  padding: 7px 10px;
  cursor: pointer;
}
.ovf-item:hover { background: rgba(142,240,177,0.09); color: var(--phos-a); }
.ovf-item kbd {
  float: right;
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: var(--silk-dim);
}
.ovf-sep { height: 1px; background: var(--hairline); margin: var(--s1) 6px; }

/* ---------- next step ----------
   Nothing in the app ever answered "what now?". This always does.

   It gets a RESERVED ROW of its own, left-aligned under the title, and that
   row keeps its height whether or not there is a callout in it. It used to
   sit inline between the patch name and the tool row, so its x was decided by
   the character count of whatever patch was on the bench — "Gritty Swell" and
   "Noisy Pad 2" put the app's one piece of guidance in two different places —
   and at some widths it wrapped to a second row and pushed the canvas down.
   A chip that moves when the content behind it changes is a chip nobody
   learns the position of. */
.callout-row {
  flex-basis: 100%;
  order: 10;
  min-height: 30px;
  display: flex;
  align-items: center;
  margin-top: -2px;
}
.nextstep {
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--t-lead);
  color: var(--phos-b);
  background: rgba(255,180,84,0.07);
  border: 1px solid var(--phos-b-deep);
  border-radius: 999px;
  padding: 5px var(--s3);
  cursor: pointer;
  transition: background 160ms ease, border-color 160ms ease;
}
.nextstep:hover { background: rgba(255,180,84,0.14); border-color: var(--phos-b); }
.nextstep.inert { color: var(--silk-dim); border-color: var(--hairline); background: none; cursor: default; }

/* ---------- the live scope ----------
   Sits in the rack's *frame*, so the circuit you are looking at is the thing
   that appears to be running — and so the trace stays put when the camera
   moves. Parented inside the viewport it was percentage-positioned against
   the scroll content: it rode over modules and slid out from under itself on
   every pan. */
/* The bezel. A trace drawn straight onto the panel is a decal; a trace behind
   glass in a housing is an instrument. Inset highlight top-left agrees with
   the 315° lamp everything else on the rack is lit by, and the inner shadow
   is the glass sitting below the bezel's lip. */
.scope-shell {
  position: absolute;
  z-index: 4;
  pointer-events: none;
  border-radius: 4px;
  border: 1px solid var(--hairline);
  background: var(--bezel);
  box-shadow: var(--lift), inset 0 0 12px rgba(0,0,0,0.9), 0 4px 14px rgba(0,0,0,0.5);
  overflow: hidden;
}
.scope-shell.hidden { display: none; }
/* The bezel does not fade — it is hardware bolted to the frame, and hardware
   that dissolves when the patch goes quiet is not hardware. What fades is the
   *trace*, down to the floor the player set. */
.scope-shell .live-scope { opacity: var(--scope-floor, 0.35); transition: opacity 320ms ease; }
.scope-shell.live .live-scope { opacity: 1; }
/* …with one exception, and it is the reserve's other half. The fit keeps the
   patch out from under the bezel; a *pan* can put eight plates under it, and
   there the promise ("the trace never rides over a module") can only be kept by
   the glass. So the whole housing — bezel included — ducks to the parked
   presence the trace already uses, and the module underneath is readable
   through it. Capped a little under the trace's own floor because this fade has
   a different job: parked means "not asking for attention", ducked means "not
   in your way", and the second one has to be got out of. */
.scope-shell { transition: opacity 220ms ease; }
.scope-shell.ducked { opacity: min(0.3, var(--scope-floor, 0.35)); }
/* Four corners, three sizes. The scope is in the rack *frame*, so none of
   this moves when the camera does. */
.scope-shell.corner-br { right: 8px; bottom: 8px; }
.scope-shell.corner-bl { left: 8px; bottom: 8px; }
.scope-shell.corner-tr { right: 8px; top: 8px; }
.scope-shell.corner-tl { left: 8px; top: 8px; }
/* Every dimension carries the same factor, so the bezel keeps its proportions
   when the fit has to shrink it (`scopeCapBezel`): the size the player asked
   for is the size they get until it would cost the patch its reserve, and then
   it is scaled down rather than the reserve being dropped. `--scope-scale` is
   1 unless the frame is too short to hold the requested bezel beside a
   readable patch. */
.scope-shell { --scope-scale: 1; }
.scope-shell.size-s {
  width: calc(24% * var(--scope-scale)); height: calc(22% * var(--scope-scale));
  min-width: calc(150px * var(--scope-scale)); min-height: calc(70px * var(--scope-scale));
}
.scope-shell.size-m {
  width: calc(36% * var(--scope-scale)); height: calc(30% * var(--scope-scale));
  min-width: calc(200px * var(--scope-scale)); min-height: calc(90px * var(--scope-scale));
}
.scope-shell.size-l {
  width: calc(50% * var(--scope-scale)); height: calc(42% * var(--scope-scale));
  min-width: calc(260px * var(--scope-scale)); min-height: calc(120px * var(--scope-scale));
}
.live-scope { display: block; width: 100%; height: 100%; }
/* The caption names the tap. "What the instrument does" and "what I hear" are
   two different signals and the app used to pick one silently. */
.scope-cap {
  position: absolute;
  left: 7px; bottom: 4px;
  font-size: var(--t-rack-micro);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--silk-mute);
  pointer-events: none;
  /* A spectrum fills the bottom of the screen, which is exactly where the
     caption is: the halo keeps it readable without a plate behind it. */
  text-shadow: 0 0 4px var(--bezel), 0 0 8px var(--bezel);
}

/* ---------- the scope's settings panel ---------- */
.scope-panel {
  position: absolute;
  right: 0; top: calc(100% + 6px);
  z-index: 60;
  width: 300px;
  max-height: 70vh;
  overflow-y: auto;
  padding: var(--s3);
  border-radius: 6px;
  border: 1px solid var(--hairline);
  background: var(--panel);
  box-shadow: var(--shadow-pop);
  text-align: left;
}
.scope-panel.hidden { display: none; }
.sp-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--s2); }
.sp-title {
  font-family: var(--font-silk); font-size: var(--t-micro);
  letter-spacing: 0.18em; text-transform: uppercase; color: var(--silk-dim);
}
.sp-grid { display: flex; flex-direction: column; gap: 6px; }
.sp-row {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  font-size: var(--t-micro); letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--silk-dim); cursor: pointer;
}
.sp-row select, .sp-row input[type="range"] {
  flex: 0 0 130px;
  font-family: var(--font-mono); font-size: var(--t-micro);
  color: var(--silk); background: var(--bezel);
  border: 1px solid var(--hairline); border-radius: 3px;
  padding: 2px 4px;
}
.sp-row.sp-check { justify-content: flex-start; }
.sp-row.sp-check input { flex: none; }
.sp-val { font-family: var(--font-mono); font-style: normal; color: var(--phos-a-dim); margin-left: 6px; }
.sp-note {
  margin-top: var(--s2);
  font-family: var(--font-voice); font-style: italic;
  font-size: var(--t-micro); color: var(--silk-dim); line-height: 1.5;
}

/* ---------- the image-export dialog (WS-7 §3) ----------
   Deliberately the *same* shell as the scope panel — same surface, same
   shadow, same row grammar — because a second popover with its own look would
   say these are two different kinds of thing, and they are not: both are a
   handful of settings hung off the header's ⋯. Only the width and the two
   pieces the scope panel has no equivalent of are new. */
.image-panel { width: 268px; }
/* `flex: 0 0 130px` alone does not hold a `<select>` to 130px: a flex item's
   `min-width` is `auto`, so the longest option ("selection — nothing
   selected") sets a floor and that one row grows wider than the four beside
   it. The rows have to agree or the panel reads as four controls and an
   accident. */
.image-panel .sp-row select { min-width: 0; }
/* The measured result, before the button rather than after the download. Its
   own recessed strip so it reads as a readout and not as another control. */
.ix-dims {
  margin-top: var(--s3);
  padding: 6px 8px;
  border: 1px solid var(--hairline); border-radius: 3px;
  background: var(--bezel);
  font-size: var(--t-micro); color: var(--phos-a-dim);
  text-align: center;
}
.ix-dims.busy { color: var(--silk-dim); }
/* The only filled control in the panel, because it is the only one that does
   anything — everything above it just describes what it will do. */
.ix-go {
  margin-top: var(--s2);
  width: 100%;
  padding: 7px 0;
  border: 1px solid var(--phos-b-deep); border-radius: 3px;
  background: linear-gradient(180deg, #3a2a12, #241a0c);
  color: var(--phos-b);
  font-family: var(--font-silk); font-size: var(--t-label);
  letter-spacing: 0.16em; text-transform: uppercase;
  cursor: pointer;
}
.ix-go:hover { border-color: var(--phos-b); background: linear-gradient(180deg, #4a3517, #2c200e); }
.ix-go:disabled { opacity: 0.4; cursor: default; }
.sp-row select:disabled { opacity: 0.45; }
.sp-row option:disabled { color: var(--silk-mute); }

/* ---------- the drop target ----------
   A veil rather than a rim light on the frame: a dragged file has no obvious
   home in a three-pane app, and the honest answer is "anywhere". */
.drop-veil {
  position: fixed; inset: 0; z-index: 90;
  display: flex; align-items: center; justify-content: center;
  background: rgba(7, 8, 10, 0.72);
  backdrop-filter: blur(2px);
  /* The veil must never eat the dragleave that dismisses it. */
  pointer-events: none;
}
.drop-veil.hidden { display: none; }
.dv-card {
  padding: var(--s5) var(--s6);
  border: 1px dashed var(--phos-a-deep); border-radius: 8px;
  background: var(--surface-pop); box-shadow: var(--shadow-pop);
  text-align: center;
}
.dv-mark { font-size: 26px; color: var(--phos-a); line-height: 1; }
.dv-line {
  margin-top: var(--s2);
  font-family: var(--font-silk); font-size: var(--t-title);
  letter-spacing: 0.1em; color: var(--silk);
}
.dv-sub { margin-top: var(--s1); font-size: var(--t-micro); color: var(--silk-dim); letter-spacing: 0.1em; }

/* ---------- the offscreen export stage ----------
   `buildRack` draws into a live document or it draws nothing: `fitLabels`
   needs `getBBox`, and the whole style-inlining pass reads `getComputedStyle`,
   both of which want a laid-out element in a real stylesheet's scope. So the
   export renders on stage and off screen, rather than in a detached fragment
   where every colour would come back as an initial value. */
.export-stage {
  position: fixed; left: -20000px; top: 0;
  width: 1200px; height: 900px;
  opacity: 0; pointer-events: none;
}

/* ---------- rack material ----------
   A bevel is a lit edge *and the roll-off behind it*. One 1px white line is a
   border; a 2px highlight over a 6px gradient falling away from it is an edge
   with thickness, and the eye reads the second as an object. Bottom shade and
   left edge complete the 315° lamp the plate, knob, screw and nut all agree
   on. */
.plate-lit { fill: rgba(255,255,255,0.13); pointer-events: none; }
.plate-roll { pointer-events: none; }
.plate-shade { fill: rgba(0,0,0,0.75); pointer-events: none; }
.plate-edge { fill: rgba(255,255,255,0.06); pointer-events: none; }
.plate-screw { opacity: 0.85; pointer-events: none; }
.mod-plate { transition: stroke 260ms cubic-bezier(0.2, 0, 0.6, 1); }
/* Note-on lights the amp plate: picture tied to sound, at the module where
   the sound leaves the patch. */
.mod-plate.struck { stroke: var(--phos-a); transition: stroke 30ms linear; }

.knob-ticks line { stroke: #3a4049; stroke-width: 1; }
.knob-track { fill: none; stroke: var(--hairline); stroke-width: 2.5; stroke-linecap: round; }
/* The value arc is the single change that turns a clock hand into a control
   you can read across the room. */
/* The glow used to be unconditional, which meant ten knobs glowing at all
   times: a blur region per knob for the compositor to paint every frame, and
   — worse — a signal that meant nothing because it was always on. It now
   means "this one is moving": under the hand, being dragged, or being driven
   by a cable. */
.knob-arc {
  fill: none;
  stroke: var(--phos-a);
  stroke-width: 2.5;
  stroke-linecap: round;
  pointer-events: none;
}
.knob-arc.modside { stroke: var(--phos-b); }
g:hover > .knob-arc, .dragging > .knob-arc, .knob-arc.modulated {
  filter: drop-shadow(0 0 3px rgba(142,240,177,0.55));
}
g:hover > .knob-arc.modside, .dragging > .knob-arc.modside, .knob-arc.modside.modulated {
  filter: drop-shadow(0 0 3px rgba(255,180,84,0.55));
}
/* A knob a cable is actually driving pulses at rest — the one place on the
   plate where "something else is turning this" has to be legible without
   tracing the amber run back to its source. */
.knob-arc.modulated { animation: arcbreathe 2.4s ease-in-out infinite; }
@keyframes arcbreathe { 50% { opacity: 0.55; } }

.j-nut { fill: url(#jackNut); pointer-events: none; }
.j-bore { fill: #050607; pointer-events: none; }
.j-spec { fill: none; stroke: rgba(255,255,255,0.18); stroke-width: 1; pointer-events: none; }
/* The outermost circle is the jack's hit target — it draws the green ring and
   is otherwise unfilled. Unfilled is the problem: SVG hit-testing defaults to
   `visiblePainted`, which means *painted* pixels only, so with no fill the
   whole middle of a jack was dead and the only thing you could grab or drop a
   cable on was the 1.6px ring itself. Scanning across a jack's 11px diameter
   found exactly two 2px bands that responded — the left and right edges of
   the stroke — with eight dead pixels between them. Patching by hand meant
   hitting a sliver, and the nut and bore drawn inside it are `pointer-events:
   none`, so they could not help. `all` hit-tests the disc whether or not it
   is painted, which is what a socket has always looked like it did. */
.jack circle:last-of-type { fill: none; pointer-events: all; }

/* Ten knobs meant ten amber dots glowing at all times, for nothing. Only a
   knob that is actually locked speaks; the rest appear on hover or focus. */
.lock-dot { opacity: 0; transition: opacity 160ms ease; }
.lock-dot.on { opacity: 1; }
g:hover > .lock-dot, [data-addr]:focus-visible .lock-dot { opacity: 1; }
/* Nothing hovers a knob on a tablet, so "appear on hover" means "never" —
   and locking is half of what the rack is for. Dimmed, not lit: the dot has
   to read as available without competing with the ones actually locked. */
@media (pointer: coarse) { .lock-dot { opacity: 0.4; } }

/* Audio cables only travel while audio does — and each one travels at the
   speed of the level actually present at that point in the chain (the
   per-cable duration is set in JS). Uniform perpetual motion is noise;
   differential motion is a meter you can read without playing a note. */
#rack-svg.flowing .wire.audio {
  stroke-dasharray: 1 15;
  animation: flow 760ms linear infinite;
}
/* Crossfade a mixer all the way over and the branch you turned down does not
   slow down — it stops, and goes solid, which is what "nothing is going
   through here" looks like on a real panel. */
#rack-svg.flowing .wire.audio.still {
  animation: none;
  stroke-dasharray: none;
}
@keyframes flow { to { stroke-dashoffset: -16; } }

/* ---------- duel cards ---------- */
.duel-name.chosen { color: var(--phos-a); text-shadow: 0 0 10px rgba(142,240,177,0.5); }
.duel-name {
  font-size: var(--t-title);
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--silk);
  display: flex;
  align-items: baseline;
  gap: var(--s2);
  min-width: 0;
}
.dn-id { font-family: var(--font-mono); font-size: var(--t-micro); color: var(--silk-dim); }
.dn-sig { font-size: var(--t-micro); color: var(--phos-a-dim); }
.duel-controls .hw-btn.promote { flex: 0 0 auto; color: var(--phos-a-dim); font-size: var(--t-micro); }
.duel-controls .hw-btn.promote:hover { color: var(--phos-a); }

/* ---------- teaching meter ----------
   The first six votes used to produce no pixel change anywhere. */
.teach { display: flex; align-items: center; gap: var(--s3); }
.teach-pips { display: flex; gap: 5px; }
.teach-pips i {
  width: 9px; height: 9px;
  border-radius: 50%;
  background: #1a1410;
  box-shadow: inset 0 0 2px #000;
  transition: background 220ms ease, box-shadow 220ms ease;
}
.teach-pips i.lit {
  background: var(--phos-b);
  box-shadow: 0 0 8px rgba(255,180,84,0.7);
}
.teach-copy {
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--t-lead);
  line-height: 1.45;
  color: var(--silk-dim);
  text-align: left;
}
/* The sentence that teaches the whole product lands whole, not as three
   ragged lines beside 900px of empty bar. */
@media (min-width: 1200px) {
  .teach-copy { min-width: 0; white-space: nowrap; font-size: var(--t-title); }
}
#evolve-btn { justify-self: end; }
.duel-mid.learning .teach-copy { color: var(--phos-b); }
.duel-pred { text-align: center; }
.duel-centre { display: flex; flex-direction: column; align-items: center; gap: 4px; }

/* ---------- toasts & the alarm strip ---------- */
/* One lane, top-right of the rack frame, positioned by `positionToastLane`.
   It used to stack up from the bottom centre — straight over PICK A / PICK B,
   so the recovery affordance obscured the app's core teaching action. The
   z-index is deliberately BELOW anything holding a pressed state or a teaching
   strip (see the rule below), so this class of collision cannot come back by
   accident: even a mispositioned lane would pass under them. */
.toasts {
  position: fixed;
  top: 64px;
  right: 16px;
  z-index: 46;
  display: flex;
  flex-direction: column;
  gap: 6px;
  align-items: flex-end;
  pointer-events: none;
}
/* The primary teaching affordances, and every control that holds a state,
   sit above transient chrome — always. */
.play-duel, .duel-mid, [aria-pressed] { position: relative; z-index: 50; }
.toast {
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: var(--s3);
  max-width: min(360px, 62vw);
  font-size: var(--t-body);
  color: var(--silk);
  background: linear-gradient(180deg, #21262e, #171a1f);
  border: 1px solid var(--hairline);
  border-left: 2px solid var(--phos-a-deep);
  border-radius: 5px;
  padding: 8px var(--s3);
  box-shadow: var(--cast);
  animation: toast-in 220ms cubic-bezier(0.2, 0.9, 0.3, 1.05);
}
.toast.out { opacity: 0; transform: translateY(-6px); transition: opacity 280ms ease, transform 280ms ease; }
@keyframes toast-in { from { opacity: 0; transform: translateY(-8px); } }
.toast-msg { flex: 1; min-width: 0; }
/* What the lane is holding back. A queue with no counter is a queue that
   looks like dropped messages. */
.toast-stack {
  flex: 0 0 auto;
  font-size: var(--t-micro);
  color: var(--silk-mute);
  border: 1px solid var(--hairline);
  border-radius: 999px;
  padding: 1px 6px;
}
.toast-stack.hidden { display: none; }
.toast-undo {
  font-size: var(--t-micro);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--phos-b);
  background: none;
  border: 1px solid var(--phos-b-deep);
  border-radius: 3px;
  padding: 3px 9px;
  cursor: pointer;
}
.toast-undo:hover { border-color: var(--phos-b); box-shadow: 0 0 10px rgba(255,180,84,0.25); }

/* A condition, not a remark: this stays until it is resolved. */
.alarm {
  position: fixed;
  left: 50%;
  top: calc(var(--menubar-h) + var(--s2));
  transform: translateX(-50%);
  z-index: 58;
  display: flex;
  align-items: center;
  gap: var(--s3);
  max-width: 72vw;
  font-size: var(--t-body);
  color: #21160a;
  background: linear-gradient(180deg, #ffc774, var(--phos-b));
  border-radius: 5px;
  padding: 8px var(--s4);
  box-shadow: 0 8px 26px rgba(0,0,0,0.55);
}
.alarm .al-icon { font-size: var(--t-lead); }
.alarm .toast-undo { color: #21160a; border-color: rgba(0,0,0,0.35); }

/* ---------- first-run elicitation ---------- */
.warm {
  position: fixed; inset: 0; z-index: 59;
  background: rgba(10, 12, 15, 0.9);
  backdrop-filter: blur(3px);
  display: flex; align-items: center; justify-content: center;
}
.warm-card { width: min(760px, 92vw); padding: var(--s6) var(--s5) var(--s5); text-align: center; }
.warm-sub {
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--t-lead);
  line-height: 1.5;
  color: var(--silk-dim);
  max-width: 52ch;
  margin: var(--s3) auto var(--s5);
}
.warm-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--s2); }
.warm-item {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 3px;
  text-align: left;
  padding: var(--s3);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  background: linear-gradient(180deg, #1f242b, #15181d);
  cursor: pointer;
  transition: border-color 160ms ease, box-shadow 160ms ease;
}
.warm-item:hover { border-color: var(--silk-dim); }
.warm-item.picked {
  border-color: var(--phos-b);
  box-shadow: 0 0 16px rgba(255,180,84,0.22), inset 0 0 10px rgba(255,180,84,0.08);
}
.wi-name { font-size: var(--t-body); font-weight: 500; color: var(--silk); }
.wi-sig { font-size: var(--t-micro); color: var(--phos-a-dim); }
/* The ▶ overlays the pick card's bottom-right rather than sitting inside it:
   one cell, two targets, no nested buttons. Padding on `.warm-item` keeps the
   name clear of it. */
.warm-cell { position: relative; display: flex; }
.warm-cell .warm-item { flex: 1; padding-right: 40px; }
.wi-play {
  position: absolute;
  right: 6px;
  bottom: 6px;
  width: 28px;
  height: 28px;
  display: grid;
  place-items: center;
  font-size: var(--t-body);
  line-height: 1;
  color: var(--phos-a-dim);
  background: rgba(0, 0, 0, 0.35);
  border: 1px solid var(--hairline);
  border-radius: 5px;
  cursor: pointer;
}
.wi-play:hover { color: var(--phos-a); border-color: var(--phos-a-dim); }
.wi-play.playing { color: var(--phos-a); box-shadow: inset 0 0 10px rgba(142,240,177,0.3); }
/* Waiting on the engine, which on a cold boot is genuinely busy. */
.wi-play.loading { color: var(--phos-b-dim); border-color: var(--phos-b-deep); }
@media (pointer: coarse) { .wi-play { width: 34px; height: 34px; } }
.warm-foot { display: flex; justify-content: center; gap: var(--s3); margin-top: var(--s5); align-items: center; }

/* ---------- the commit duel ---------- */
/* Deliberately the warm-start's card, not a new one: it is the same act — the
   instrument asking a question it will learn from — and a second dialect for
   it would teach the player that these are different kinds of question. Two
   cells, a letter each, and the two things you can do to each of them side by
   side: hear it, choose it. */
.cduel-card { width: min(680px, 92vw); }
.cduel-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s3); }
.cduel-cell {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  padding: var(--s3);
  border: 1px solid var(--hairline);
  border-radius: 6px;
  background: linear-gradient(180deg, #1f242b, #15181d);
  text-align: left;
}
.cd-head { display: flex; align-items: baseline; gap: var(--s2); }
.cd-letter {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: 0.18em;
  color: var(--phos-b);
}
.cd-name { font-size: var(--t-body); color: var(--silk); }
/* The waveform is the only thing on the card that is *of* the sound, so it
   gets the plate's own recess rather than sitting on the card's face. */
.cd-scope {
  width: 100%;
  height: 54px;
  border: 1px solid var(--hairline);
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.3);
}
.cd-row { display: flex; gap: var(--s2); align-items: center; }
.cd-play { flex: 1; }
.cd-pick { flex: 1; }
.cd-hint { font-size: var(--t-micro); }
@media (max-width: 700px) {
  .cduel-grid { grid-template-columns: 1fr; }
}

/* ---------- boot ---------- */
.boot { transition: opacity 420ms cubic-bezier(0.4, 0, 0.2, 1); }
.boot.done { opacity: 0; pointer-events: none; }
.boot-word {
  font-size: var(--t-hero);
  font-weight: 500;
  letter-spacing: 0.3em;
  color: var(--silk);
}
.boot-word b { font-weight: 500; color: var(--phos-b); text-shadow: 0 0 14px rgba(255,180,84,0.7); }
/* The wait draws the search happening, on the same field the taste map uses. */
.boot-field {
  display: block;
  width: 100%;
  height: 190px;
  margin: var(--s4) 0 var(--s2);
  border-radius: 5px;
  background: radial-gradient(ellipse at 50% 40%, #171107 0%, var(--bezel) 85%);
  box-shadow: inset 0 0 18px rgba(0,0,0,0.9), inset 0 0 2px 1px #000;
}

@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; }
}

/* ---------- bank rows ---------- */
/* Everything that dresses a bank row lives in the `.bank` block near the top
   of this file. It used to be split across two places 1600 lines apart, and
   the halves disagreed: a `.bi-u { width: 54px }` down here silently overrode
   the full-width meter declared up there, and a `#bank-sort` rule carried a
   comment claiming it shed the keybar's chrome while the markup kept the class
   that applied it. Splitting one component's rules across the file is how
   contradictions like those survive review. */
.bank-item .bi-row button.star { color: var(--silk-dim); }

@media (prefers-reduced-motion: reduce) {
  .toast, .duel-mid, .mod-plate, .lock-dot, .live-scope, .boot { transition: none !important; }
  #rack-svg.flowing .wire.audio { animation: none !important; stroke-dasharray: none; }
}

/* Roving keyboard cursor in the bank (the list itself is the tab stop). */
.bank-item.kbd { border-color: var(--phos-a); box-shadow: 0 0 0 1px var(--phos-a-deep) inset; }

/* ---------- local explanation ----------
   Utility is linear within a style lens, so these contributions are exact.
   "The model likes this" is a claim; "it likes this because it's bright and
   has a long tail, under your Dark Drones lens" is one you can check by ear. */
/* ---------- the belief row: what the model thinks, above the rack ---------- */
/* One row, two claims, opposite ends: what the model makes of the patch you
   are holding (left, where reading starts) and how much structural room that
   patch has left (right, under COMMIT, which is the button both numbers are
   about). The row keeps its height when either half is empty — it sits between
   the toolbar and the rack, and a row that collapses would move the whole
   canvas every time a patch is loaded. */
.belief-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s3);
  min-height: 22px;
  padding: 0 var(--s1);
}
.belief {
  display: flex;
  align-items: baseline;
  gap: var(--s2);
  /* One line, always. Wrapping would make the row's height a function of how
     many characters the model's top three features happen to have, and this
     row sits directly above the canvas — a second line moves the whole rack
     every time a patch is loaded. The score is first and the decomposition is
     last, so what gets clipped on a narrow frame is what matters least. */
  flex-wrap: nowrap;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: var(--t-body);
  color: var(--silk-dim);
  padding: var(--s1) 0;
  transition: opacity 160ms ease;
}
.belief > * { flex: 0 0 auto; }
.belief .ex-why { color: var(--phos-b); letter-spacing: 0.1em; text-transform: uppercase; font-size: var(--t-micro); }
.belief b.up { color: var(--phos-b); font-weight: 400; }
.belief b.down { color: var(--silk-dim); font-weight: 400; }
.belief .ex-lens { font-family: var(--font-voice); font-style: italic; color: var(--silk-dim); }
.belief .ex-lens b { color: var(--phos-b); font-weight: 400; }
/* The number itself is the loudest thing in the row: it is what changes. */
.belief .bl-u { color: var(--silk); font-weight: 500; font-size: var(--t-lead); }
.belief .bl-was { color: var(--silk-mute); }
.belief .bl-sep, .belief .bl-none { color: var(--silk-mute); }
.belief .bl-arrow.up { color: var(--phos-a); }
.belief .bl-arrow.down { color: var(--silk-mute); }
/* Stale is drawn, never guessed: an edit is in flight and this number is about
   the tree before it. Dimmed rather than hidden, because a number that
   disappears and comes back is a flicker under every knob turn. */
.belief.stale { opacity: 0.45; }
.belief .bl-stale { color: var(--phos-b-dim); font-style: italic; }

/* The ceilings evolution can search. Quiet until it is nearly spent. */
.budget {
  display: flex;
  align-items: baseline;
  gap: 6px;
  font-size: var(--t-micro);
  color: var(--silk-mute);
  white-space: nowrap;
}
.budget .bg-sep { opacity: 0.45; }
.budget .bg-cell b { color: var(--silk-dim); font-weight: 400; }
.budget .bg-cell.tight b { color: var(--phos-b); }
.budget .bg-cell.full { color: var(--led-red); }
.budget .bg-cell.full b { color: var(--led-red); }

/* A duel drawn at random rather than by the acquisition function. */
.duel-pred.check { color: var(--phos-b); }
.duel-pred.check::before { content: "◇ "; }

/* ---------- round-2 corrections ---------- */

/* Native range inputs render as OS widgets 40px from hand-drawn knobs.
   Drawn as faders instead, lit from the same 315° as everything else. */
input[type="range"] {
  appearance: none;
  -webkit-appearance: none;
  height: 4px;
  border-radius: 999px;
  background: linear-gradient(90deg, var(--phos-a-deep), var(--hairline));
  cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
  appearance: none;
  -webkit-appearance: none;
  width: 12px;
  height: 20px;
  border-radius: 2px;
  background: linear-gradient(180deg, #2a2f37, #14161a);
  border: 1px solid #000;
  box-shadow: var(--lift), 0 2px 4px rgba(0, 0, 0, 0.6);
}
input[type="range"]::-moz-range-thumb {
  width: 12px;
  height: 20px;
  border-radius: 2px;
  background: linear-gradient(180deg, #2a2f37, #14161a);
  border: 1px solid #000;
  box-shadow: var(--lift), 0 2px 4px rgba(0, 0, 0, 0.6);
}
.perf-sel, .perf-num { appearance: none; -webkit-appearance: none; }
.perf-sel {
  padding-right: 16px;
  background-image: linear-gradient(45deg, transparent 50%, var(--silk-dim) 50%),
                    linear-gradient(135deg, var(--silk-dim) 50%, transparent 50%),
                    linear-gradient(180deg, #232830, #181b21);
  background-position: right 7px center, right 3px center, 0 0;
  background-size: 4px 4px, 4px 4px, 100% 100%;
  background-repeat: no-repeat;
}


/* Tick rings turn to moiré at zoom 1 — they are a texture for the zoomed-in
   case, not information. */
.knob-ticks line { stroke: #3a4049; stroke-width: 1; opacity: 0.55; }

/* The value arc carried a full-strength glow on every knob, which made
   parameter values brighter than the cables carrying the signal. It was
   halved here and is now off entirely at rest (see `.knob-arc` above): a glow
   that is always on is not a signal, it is a texture, and it cost a blur
   region per knob on every paint. */
.knob-arc { filter: none; }

/* A duel that is settling must not look live. */
.duel-card.dealing { opacity: 0.55; }
.duel-card.dealing .duel-controls { pointer-events: none; }

/* The play-view teaching strip carries the same state as EVOLVE's — a
   stripped-down copy of the core interaction teaches the user there is
   nothing in it. */
.play-duel .teach-pips i { width: 7px; height: 7px; }
.pd-pred { margin-left: auto; font-size: var(--t-micro); color: var(--silk-dim); }
.pd-pred:empty { display: none; }
.pd-pred.hit { color: var(--phos-a); }
.pd-pred.miss { color: var(--phos-b); }
/* Which candidate is sounding must be legible on the strip itself. */
.pd-side.live-sel {
  border-color: var(--phos-a);
  color: var(--phos-a);
  box-shadow: 0 0 8px rgba(142, 240, 177, 0.25);
}
#pd-back { display: none; }
.play-duel.auditioning #pd-back { display: inline-block; }

/* =====================================================================
   Panel pass — stage identity, first-run, empty states, keybar drawers.
   ===================================================================== */

/* ---------- stage header: the patch is the headline ----------
   The patch being played/edited was set smaller than "PICKS 0". */
/* Transport sits against the name of the thing it plays (§10). */
.subject-block { display: flex; flex-direction: row; align-items: center; gap: 10px; min-width: 0; }
.subject-id { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.xport {
  width: 30px; height: 30px; flex: none;
  border-radius: 50%;
  border: 1px solid var(--phos-a-deep);
  background: radial-gradient(circle at 35% 30%, #1a2620, #0b0f0d 78%);
  color: var(--phos-a);
  font-size: 12px;
  line-height: 1;
  padding: 0 0 0 2px;
  cursor: pointer;
  box-shadow: var(--lift), 0 2px 0 #0a0b0d;
  transition: border-color 160ms ease, box-shadow 160ms ease, color 160ms ease;
}
.xport:hover:not(:disabled) { border-color: var(--phos-a); box-shadow: var(--lift), 0 0 12px rgba(142,240,177,0.24); }
.xport:disabled { color: var(--silk-mute); border-color: var(--hairline); cursor: default; box-shadow: none; }
.xport.playing { color: #0b0f0d; background: var(--phos-a); border-color: var(--phos-a); }
.xport:focus-visible { outline: 2px solid var(--phos-a); outline-offset: 2px; }
.subject-name {
  font-size: var(--t-title);
  font-weight: 500;
  letter-spacing: 0.01em;
  text-transform: none;
  color: var(--silk);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 300px;
}
.subject-name.hearing { color: var(--phos-b); }
.subject-meta { font-family: var(--font-mono); font-size: var(--t-micro); color: var(--silk-dim); }
/* No rack dimming while auditioning: the candidate is FULLY benched, so the
   rack legitimately shows the sound you are hearing. The amber header line
   carries the "this is a candidate" state. */

/* ---------- empty states: HTML, not canvas paint ---------- */
.crt-wrap { position: relative; }
.crt-empty {
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: var(--s3);
  text-align: center;
  padding: var(--s5);
}
/* Pre-fit map: the invitation floats over live dots without stealing them. */
.crt-empty.translucent {
  background: rgba(12, 13, 16, 0.55);
  pointer-events: none;
}
.crt-empty.translucent .hw-btn { pointer-events: auto; }
.crt-empty .ce-title {
  font-size: var(--t-label);
  font-weight: 700;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--phos-b);
}
.crt-empty .ce-copy {
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--t-lead);
  line-height: 1.55;
  color: var(--silk-dim);
  max-width: 44ch;
}
.crt-empty .ce-count { font-family: var(--font-mono); font-size: var(--t-body); color: var(--phos-b-dim); }
.crt-empty .hw-btn { margin-top: var(--s2); }
.crt-empty .ce-skel {
  display: flex;
  flex-direction: column;
  gap: 6px;
  width: min(360px, 60%);
  margin-bottom: var(--s2);
}
.crt-empty .ce-skel i {
  height: 10px;
  border-radius: 3px;
  background: linear-gradient(90deg, rgba(255,180,84,0.14), transparent);
}
/* Directions: bars of different pulls, so the skeleton previews the chart. */
.crt-empty .ce-skel.dir { align-items: flex-start; }
.crt-empty .ce-skel.dir i { align-self: flex-start; width: 85%; }
.crt-empty .ce-skel.dir i:nth-child(2) { width: 55%; }
.crt-empty .ce-skel.dir i:nth-child(3) { width: 70%; }
.crt-empty .ce-skel.dir i:nth-child(4) { width: 38%; }
/* Trust: the reliability box with its honest diagonal, waiting for dots. */
.ce-trust-skel {
  width: 130px;
  height: 130px;
  border: 1px solid rgba(255, 180, 84, 0.25);
  border-radius: 4px;
  background: linear-gradient(
    to top right,
    transparent calc(50% - 1px),
    rgba(255, 180, 84, 0.35) 50%,
    transparent calc(50% + 1px)
  );
  margin-bottom: var(--s2);
}

/* ---------- one-time bench walkthrough ---------- */
.bench-tour {
  display: flex;
  align-items: center;
  gap: var(--s3);
  border: 1px solid var(--phos-b-deep);
  border-radius: 6px;
  background: rgba(255, 180, 84, 0.06);
  padding: var(--s2) var(--s3);
}
.bench-tour .bt-copy {
  flex: 1;
  font-size: var(--t-body);
  line-height: 1.5;
  color: var(--silk-dim);
}
.bench-tour .bt-copy b { color: var(--phos-b); font-weight: 500; }

/* ---------- first-run coach mark on the piano ---------- */
.coach {
  position: fixed;
  left: 50%;
  /* Lands ON the keybed it points at — above the keybar it overprinted the
     TEACH strip and clipped the pick buttons. */
  bottom: calc(var(--keybar-h) - 42px);
  transform: translateX(-50%);
  z-index: 57;
  font-family: var(--font-voice);
  font-style: italic;
  font-size: var(--t-lead);
  color: var(--phos-b);
  background: rgba(23, 26, 31, 0.94);
  border: 1px solid var(--phos-b-deep);
  border-radius: 999px;
  padding: 7px var(--s4);
  box-shadow: var(--cast);
  pointer-events: none;
  animation: coach-bob 1.6s ease-in-out infinite;
}
.coach::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: -5px;
  width: 8px; height: 8px;
  transform: translateX(-50%) rotate(45deg);
  background: #171a1f;
  border-right: 1px solid var(--phos-b-deep);
  border-bottom: 1px solid var(--phos-b-deep);
}
@keyframes coach-bob { 50% { transform: translate(-50%, -4px); } }

/* ---------- keybar drawers ---------- */
/* The arp row belongs to the ARP button: recessed and inert until it runs. */
.perf-ctl.idle { opacity: 0.45; }
.perf-ctl.idle > * { pointer-events: none; }
.perf-ctl .pc { display: flex; flex-direction: column; align-items: center; gap: 1px; }
.pc-cap {
  font-family: var(--font-silk);
  font-size: var(--t-micro);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--silk-dim);
}
.pc-val { font-family: var(--font-mono); font-size: var(--t-micro); color: var(--phos-a-dim); min-width: 34px; text-align: center; }

/* One attention beat, used by the next-step chip to point at a surface. */
.pulse-once { animation: pulse-once 1.2s ease-in-out; }
@keyframes pulse-once {
  0%, 100% { box-shadow: none; }
  35% { box-shadow: 0 0 0 2px var(--phos-b), 0 0 22px rgba(255, 180, 84, 0.4); }
}
.teach-link { cursor: pointer; }
.teach-link:hover { text-decoration: underline; }

/* ---------- taste map affordance ---------- */
#taste-crt.hit { cursor: pointer; }
.map-tip {
  position: fixed;
  z-index: 45;
  pointer-events: none;
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  line-height: 1.5;
  color: var(--silk);
  background: var(--surface-pop);
  border: 1px solid var(--hairline);
  border-radius: 4px;
  padding: 5px 8px;
  box-shadow: var(--cast);
  max-width: 240px;
}
.map-tip .mt-name { color: var(--phos-b); }
.map-tip .mt-dim { color: var(--silk-dim); }
.map-tip .mt-u { color: var(--phos-b-dim); }
.map-legend {
  position: absolute;
  right: 12px;
  bottom: 10px;
  display: flex;
  align-items: center;
  gap: 14px;
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  color: var(--silk-dim);
  pointer-events: none;
}
.map-legend .ml-ramp {
  display: inline-block;
  width: 46px; height: 8px; border-radius: 4px;
  vertical-align: middle;
  background: linear-gradient(90deg, rgba(255,180,84,0.08), rgba(255,180,84,0.85));
}
.map-legend .ml-ramp-wrap { display: inline-flex; align-items: center; gap: 5px; }
.map-legend .ml-ring { display: inline-flex; align-items: center; gap: 4px; }
.map-legend .ml-ring i { display: inline-block; border: 1px solid var(--phos-b-dim); border-radius: 50%; }
.map-legend .ml-ring .sm { width: 7px; height: 7px; }
.map-legend .ml-ring .lg { width: 13px; height: 13px; }

/* ===================== handheld gate =====================
   Shown only when the early script in index.html sets html.handheld, which
   also means main.js never loaded — so this screen is the whole page, and it
   is plain CSS with no engine behind it. Hide, don't just cover: the app
   shell and boot overlay would otherwise sit under it half-built. */
.handheld-gate { display: none; }
html.handheld .app,
html.handheld #boot,
html.handheld #warmstart,
html.handheld #help,
html.handheld #wire-overlay { display: none !important; }
/* The app is a fixed, non-scrolling instrument (html, body { overflow: hidden }).
   This screen is a document — it has to scroll on a phone in landscape. */
html.handheld, html.handheld body { height: auto; min-height: 100%; overflow: visible; }
html.handheld .handheld-gate {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100%;
  padding: var(--s5) var(--s4);
  background:
    radial-gradient(ellipse at 50% 26%, #14110a 0%, var(--rack) 72%);
}
.hg-card {
  width: min(420px, 100%);
  text-align: center;
  padding: 32px 24px 24px;
}
.hg-card .boot-word { font-size: var(--t-title); }
.hg-lede {
  margin-top: var(--s4);
  font-family: var(--font-voice);
  font-size: var(--t-lead);
  line-height: 1.45;
  color: var(--silk);
}
/* The teaser: the loop, three beats, no feature list. */
.hg-loop {
  list-style: none;
  margin: var(--s5) 0 0;
  display: grid;
  gap: var(--s2);
  text-align: left;
  font-size: var(--t-body);
  color: var(--phos-a-dim);
}
.hg-loop li { display: flex; align-items: center; gap: var(--s2); }
.hg-loop .hg-n {
  flex: none;
  width: 18px; height: 18px;
  display: grid;
  place-items: center;
  border: 1px solid var(--phos-a-deep);
  border-radius: 50%;
  font-size: var(--t-micro);
  color: var(--phos-a);
}
.hg-rule {
  height: 1px;
  margin: var(--s5) 0 var(--s4);
  background: linear-gradient(90deg, transparent, var(--hairline) 20%, var(--hairline) 80%, transparent);
}
.hg-ask {
  font-size: var(--t-body);
  line-height: 1.5;
  color: var(--silk-dim);
}
.hg-ask b { color: var(--phos-b); font-weight: 500; }
.hg-soon {
  margin-top: var(--s3);
  font-size: var(--t-micro);
  letter-spacing: 0.08em;
  color: var(--silk-mute);
}
/* Deliberately quiet — it leads somewhere that doesn't work well yet. */
.hg-anyway {
  margin-top: var(--s5);
  padding: 8px 12px;
  min-height: 34px;
  border: 0;
  background: none;
  font-family: var(--font-mono);
  font-size: var(--t-label);
  color: var(--silk-mute);
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}
.hg-anyway:active { color: var(--silk-dim); }

/* ===================== the keybed, resized =====================
   The keybed is the one surface whose size is a *performance* decision, so it
   is the one surface the player gets to resize — in both directions, from two
   controls that do exactly one thing each. `⇕ tall` is height: 128px is a
   control strip, and the tall dock is a keyboard. `keys` is width: fewer
   octaves, wider keys. Neither touches the other, and both persist.

   Height comes from the same `--keybar-h` token the dock already sizes from,
   so the deck above simply gets less and re-zooms; nothing is positioned
   against a hard-coded footer height. Capped at 44vh so the rack never loses
   more than it can spare, and floored at 210px so the mode is worth entering
   on a short screen. */
.app.bigkeys { --keybar-h: clamp(210px, 44vh, 340px); }

/* Fewer, wider keys deserve bigger markings, and the strike-position velocity
   law (top of the key is soft, bottom is hard) is finally a real gesture at
   this height — so the hint sits clear of where the finger lands. */
.app.bigkeys .pkey { border-right-width: 2px; }
.app.bigkeys .pkey .hint { bottom: 10px; font-size: var(--t-body); }
.app.bigkeys .bkey .hint { font-size: var(--t-label); }
.app.bigkeys .bkey { width: 58%; }

/* The arp strip is a settings panel, and in big mode it is the widest thing
   standing between the keybed and the edge of the screen. The arp BUTTON
   stays — only its six selects fold away, and they come back with the dock. */
.app.bigkeys #arp-ctl { display: none; }
/* No lit-state rule here on purpose: `.util-btn.lit` already says "engaged"
   for hold / uni / arp, and a fourth toggle in the same row that lit up a
   different colour would read as a different kind of thing. */

/* The width selector sits in the control grid, spanning the cells the fifth
   column leaves over, with its caption inline so the row stays two deep. */
.keys-span { display: flex; align-items: center; gap: 4px; grid-column: span 2; }
.keys-span .pc-cap { font-size: var(--t-rack-micro); color: var(--silk-mute); text-transform: uppercase; letter-spacing: 0.1em; }
.keys-span .perf-sel { flex: 1; min-width: 0; }
