moontide-mcp 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +94 -6
  2. package/dist/ask.js +76 -15
  3. package/dist/mcp.js +198 -26
  4. package/dist/protocol.js +130 -2
  5. package/dist/reference.js +22 -0
  6. package/dist/request-status.js +221 -0
  7. package/dist/session.js +256 -7
  8. package/package.json +1 -1
  9. package/page/ask-view.js +64 -57
  10. package/page/ask.js +363 -188
  11. package/page/bar-model.js +43 -2
  12. package/page/change-timing.js +60 -4
  13. package/page/harmony.js +13 -0
  14. package/page/index.html +7 -4
  15. package/page/jam-analysis.js +68 -23
  16. package/page/jam-model.js +196 -31
  17. package/page/jam-resolve.js +271 -0
  18. package/page/jam-view.js +182 -10
  19. package/page/lights.js +300 -0
  20. package/page/main.js +2171 -188
  21. package/page/midi-clock.js +23 -4
  22. package/page/midi-out.js +17 -2
  23. package/page/mixer-groups.js +114 -0
  24. package/page/mixer-model.js +172 -29
  25. package/page/mixer-rewrite.js +30 -1
  26. package/page/mixer-view.js +289 -14
  27. package/page/note-edit.js +267 -0
  28. package/page/note-locate.js +346 -0
  29. package/page/note-panel.js +402 -0
  30. package/page/note-tag.js +141 -0
  31. package/page/page-rewrite.js +184 -0
  32. package/page/query-check.js +21 -2
  33. package/page/request-status-view.js +419 -0
  34. package/page/request-status.js +686 -0
  35. package/page/song-edit.js +230 -0
  36. package/page/song-fit.js +250 -0
  37. package/page/song-grid-model.js +506 -0
  38. package/page/song-grid-view.js +710 -0
  39. package/page/song-prefs.js +100 -0
  40. package/page/structure-check.js +216 -0
  41. package/page/structure-view.js +38 -0
  42. package/page/structure.js +761 -0
  43. package/page/style.css +1218 -65
  44. package/page/takeover.js +20 -5
  45. package/page/tempo-glide.js +330 -0
  46. package/page/tempo-guard.js +62 -10
  47. package/page/timeline-edit-view.js +1956 -0
  48. package/page/timeline-gesture.js +279 -0
  49. package/page/timeline.js +197 -3
  50. package/page/top-bar-view.js +18 -13
  51. package/page/tracks.js +37 -8
  52. package/page/ui-state.js +10 -5
  53. package/page/views.js +39 -9
  54. package/reference/functions.md +1 -0
  55. package/reference/midi.md +2 -0
  56. package/reference/structure.md +131 -0
  57. package/reference/transitions.md +13 -1
@@ -721,6 +721,10 @@ function inspectTrack(code, entry, scope) {
721
721
  const sharedRoot =
722
722
  (root.type === 'Identifier' && declared.has(root.name)) ||
723
723
  (root.type === 'CallExpression' && root.callee.type === 'Identifier' && declared.has(root.callee.name));
724
+ // A track that starts with play(...) may be built by the song's own helper, whose lines are not shared code the user needs
725
+ // to hear about. The model drops the "built from shared code" line only when the song reading says that helper is the
726
+ // canonical one; this read just says the track starts with a call named play.
727
+ const playRoot = root.type === 'CallExpression' && root.callee.type === 'Identifier' && root.callee.name === 'play';
724
728
  for (const call of spine.calls) {
725
729
  if (!MIXER_CALLS.has(call.name) && effectOfMethod(call.name) === null) continue;
726
730
  const args = call.node.arguments;
@@ -731,6 +735,7 @@ function inspectTrack(code, entry, scope) {
731
735
  return {
732
736
  spine,
733
737
  sharedRoot,
738
+ playRoot,
734
739
  ...readReach(spine, entry.expression, scope),
735
740
  gain: readControl(spine.calls, 'gain'),
736
741
  postgain: readControl(spine.calls, 'postgain'),
@@ -774,7 +779,7 @@ function publicBus({ orbit, sends, busParams }) {
774
779
  /**
775
780
  * Parses `code` once and reads every track: `{ tracks, reads }`, `tracks` from extractTracks and
776
781
  * `reads` a Map from track identity to `{ ok: true, gain: { kind, value }, postgain: { kind,
777
- * value }, effects, bus, label, sharedRoot, playsMidi, innerPostgain }` (`sharedRoot` is true when the track's root is a declared name or a call of one; `playsMidi` is true when a `.midi(` call sits anywhere the track's code reaches, `innerPostgain` is true when a `.postgain(` call sits in that code off the spine; `busHidden` is true when the track writes an orbit, a send or a bus value anywhere but on its own chain (inside another call, in shared code it names, through `.set({ ... })` or a bare call), so its bus facts are not trusted; see readReach; kind 'none', 'fixed' or 'pattern'; `bus` is `{ orbit, sends, busParams }`
782
+ * value }, effects, bus, label, sharedRoot, playRoot, playsMidi, innerPostgain }` (`sharedRoot` is true when the track's root is a declared name or a call of one; `playRoot` is true when the track's own statement starts with a `play(...)` call; `playsMidi` is true when a `.midi(` call sits anywhere the track's code reaches, `innerPostgain` is true when a `.postgain(` call sits in that code off the spine; `busHidden` is true when the track writes an orbit, a send or a bus value anywhere but on its own chain (inside another call, in shared code it names, through `.set({ ... })` or a bare call), so its bus facts are not trusted; see readReach; kind 'none', 'fixed' or 'pattern'; `bus` is `{ orbit, sends, busParams }`
778
783
  * of readBusFacts without call positions (kind 'unwritten', 'fixed' or 'pattern'); `effects` is a Map from effect id
779
784
  * to `{ calls, params, written, bypassable, bypassWhy }`, see readEffects; `label` is null for a track without one,
780
785
  * else `{ raw, ...parseLabel(raw) }`) or `{ ok: false, reason }`. One bad track never throws; an
@@ -798,6 +803,7 @@ export function readProgram(code, { parse }) {
798
803
  bus: publicBus(read.bus),
799
804
  label: raw === null ? null : { raw, ...parseLabel(raw) },
800
805
  sharedRoot: read.sharedRoot,
806
+ playRoot: read.playRoot,
801
807
  playsMidi: read.playsMidi,
802
808
  innerPostgain: read.innerPostgain,
803
809
  busHidden: read.busHidden,
@@ -1561,3 +1567,26 @@ export function writeTrack(code, identity, change, { parse }) {
1561
1567
  }
1562
1568
  return { code: applyEdits(code, edits), edits };
1563
1569
  }
1570
+
1571
+ /**
1572
+ * Writes one mute or solo change on many tracks at once and returns `{ code, edits }`, like writeTrack. `change` is
1573
+ * `{ type: 'mute' | 'solo', on }` and `identities` the tracks to write, each read and written against the SAME base
1574
+ * text. All their edits are joined into one list and applied once, by position, so the result is the same whatever
1575
+ * order the tracks are listed in, no track is written twice (a repeated identity counts once) and two edits that
1576
+ * overlap throw. The first track that cannot be written throws its MixerRewriteError and nothing is returned, so a
1577
+ * refused track never leaves the others half written. A track already in the asked state adds no edit.
1578
+ */
1579
+ export function writeTracks(code, identities, change, { parse }) {
1580
+ if (!Array.isArray(identities) || identities.length === 0 || identities.some((identity) => typeof identity !== 'string')) {
1581
+ throw new MixerRewriteError('unsupported-change', 'a group change needs at least one track.');
1582
+ }
1583
+ const type = change ? change.type : undefined;
1584
+ if (type !== 'mute' && type !== 'solo') {
1585
+ throw new MixerRewriteError('unsupported-change', `a group can only be muted or soloed, not '${String(type)}'.`);
1586
+ }
1587
+ const edits = [];
1588
+ for (const identity of new Set(identities)) {
1589
+ edits.push(...writeTrack(code, identity, { identity, type, on: change.on }, { parse }).edits);
1590
+ }
1591
+ return { code: applyEdits(code, edits), edits };
1592
+ }
@@ -14,14 +14,19 @@ import { BUS_IDS, GAIN_STEPS, MOVE_OVER_MAX, MOVE_OVER_MIN, PARAM_STEPS, SENDS,
14
14
  * MIDI fader while it is dragged and the last value at its release, with no evaluation.
15
15
  * `onStripSelect()` (optional) is called after the user selects or clears a track strip (a click on its name, or a
16
16
  * primary-button pointer-down inside it); a render never calls it. The caller reads `selectedStrip()` then.
17
+ * `onGroupFold(key)` is called when a group's fold button is pressed; the caller changes the fold state both views share
18
+ * and draws the Mixer again (the view itself never folds anything).
17
19
  */
18
- export function createMixerView({ document, gesture, midiPreview, onStripSelect = null }) {
20
+ export function createMixerView({ document, gesture, midiPreview, onStripSelect = null, onGroupFold }) {
19
21
  if (!midiPreview || typeof midiPreview.move !== 'function' || typeof midiPreview.flush !== 'function') {
20
22
  throw new Error('Moontide: the Mixer view needs a midiPreview with move and flush.');
21
23
  }
22
24
  if (onStripSelect !== null && typeof onStripSelect !== 'function') {
23
25
  throw new Error('Moontide: the Mixer view needs onStripSelect to be a function.');
24
26
  }
27
+ if (typeof onGroupFold !== 'function') {
28
+ throw new Error('Moontide: the Mixer view needs onGroupFold to be a function.');
29
+ }
25
30
  const mixerView = document.getElementById('mixer-view');
26
31
  const mixerNotice = document.getElementById('mixer-notice');
27
32
  const mixerMessage = document.getElementById('mixer-message');
@@ -37,6 +42,10 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
37
42
  const refs = new Map();
38
43
  // One entry per bus strip, keyed `${orbit}:${bus}`: { el, titleEl, chipsEl, fedByEl, warningEl, chips, current }.
39
44
  const busRefs = new Map();
45
+ // One entry per group frame, keyed by the group's key: { el, foldEl, chevronEl, nameEl, countEl, muteEl, soloEl, noteEl, bodyEl, current, stale }.
46
+ const groupRefs = new Map();
47
+ // Counts the bodies built, so each group body has an id of its own for the fold button's aria-controls.
48
+ let groupBodies = 0;
40
49
 
41
50
  // The inspector's own state: the selected chip's key, the shape of the rows drawn, the rows by
42
51
  // parameter id, the model it shows, whether More parameters is open, and whether a control kept its
@@ -72,6 +81,14 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
72
81
  control.disabled = !writable || !ref.current.editable || control.dataset.controlDisabled === 'true';
73
82
  }
74
83
  }
84
+ // A group head's own buttons (never the frame's querySelectorAll: a frame holds its strips' controls too). They are off
85
+ // while a notice is up and when the group has no track they can reach; the fold button is never one of them.
86
+ // A frame the model no longer has (kept only because a held strip is inside) is off too: its tracks are out of date.
87
+ for (const groupRef of groupRefs.values()) {
88
+ for (const control of [groupRef.muteEl, groupRef.soloEl]) {
89
+ control.disabled = !writable || groupRef.stale === true || control.dataset.controlDisabled === 'true';
90
+ }
91
+ }
75
92
  // The inspector's writing controls follow the same rule; a bypassed effect is read-only.
76
93
  const inspectorEditable = insp.data !== null && insp.data.editable;
77
94
  for (const control of mixerInspector.querySelectorAll('[data-writes="true"]')) {
@@ -159,6 +176,19 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
159
176
  gesture.discrete({ identity, type: kind, on }, mixerHistoryLabel({ kind, name, on }));
160
177
  }
161
178
 
179
+ /**
180
+ * A press of a group's M or S: one discrete change for every track the button reaches, applied and recorded at once, one
181
+ * history version. The direction is the one the model drew: a group with none or some of its tracks pressed presses them
182
+ * all, a group with every track pressed lets them all go.
183
+ */
184
+ function pressGroup(ref, kind) {
185
+ // The buttons of a frame the model no longer has are disabled; this keeps a press that still arrives from writing old names.
186
+ if (ref.stale === true) return;
187
+ const { name } = ref.current;
188
+ const { on, identities } = ref.current[kind];
189
+ gesture.discrete({ type: 'group', kind, on, identities, group: name }, mixerHistoryLabel({ kind: kind === 'mute' ? 'group-mute' : 'group-solo', group: name, on }));
190
+ }
191
+
162
192
  /** Scrolls the strips area sideways, instantly, by the smallest amount that puts `stripEl` wholly in
163
193
  * view (keeping the area's own left padding as a margin). Only the strips area moves. */
164
194
  function revealStrip(stripEl) {
@@ -502,6 +532,148 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
502
532
  });
503
533
  }
504
534
 
535
+ /** Sets an element's text only when it changed, so drawing the same model again writes nothing. */
536
+ function setText(el, text) {
537
+ if (el.textContent !== text) el.textContent = text;
538
+ }
539
+
540
+ /** Sets an attribute only when its value changed. */
541
+ function setAttr(el, name, value) {
542
+ if (el.getAttribute(name) !== value) el.setAttribute(name, value);
543
+ }
544
+
545
+ /**
546
+ * A light: a ring with a fill inside it. The fill is the part that changes (page/lights.js says how bright it is); the
547
+ * whole light is decoration for the eye and is never announced, since a note would otherwise speak every time it plays.
548
+ */
549
+ function createLight() {
550
+ const el = document.createElement('span');
551
+ el.className = 'mx-light';
552
+ el.setAttribute('aria-hidden', 'true');
553
+ const fillEl = document.createElement('span');
554
+ fillEl.className = 'mx-light-fill';
555
+ el.append(fillEl);
556
+ return { el, fillEl };
557
+ }
558
+
559
+ /**
560
+ * The frame of one group: a head (the fold button with its chevron and name, then the count line) and a body that holds
561
+ * the group's strips and sub-groups. Names reach the page only through textContent, title and setAttribute.
562
+ */
563
+ function createGroupFrame(item) {
564
+ const el = document.createElement('div');
565
+ el.className = 'mx-group';
566
+ el.setAttribute('role', 'group');
567
+ const head = document.createElement('div');
568
+ head.className = 'mx-group-head';
569
+ const titleRow = document.createElement('div');
570
+ titleRow.className = 'mx-group-title';
571
+ const foldEl = document.createElement('button');
572
+ foldEl.type = 'button';
573
+ foldEl.className = 'mx-fold';
574
+ const chevronEl = document.createElement('span');
575
+ chevronEl.className = 'mx-fold-chevron';
576
+ chevronEl.setAttribute('aria-hidden', 'true');
577
+ const nameEl = document.createElement('span');
578
+ nameEl.className = 'mx-fold-name';
579
+ foldEl.append(chevronEl, nameEl);
580
+ const light = createLight();
581
+ titleRow.append(light.el, foldEl);
582
+ const line = document.createElement('div');
583
+ line.className = 'mx-group-line';
584
+ const countEl = document.createElement('span');
585
+ countEl.className = 'mx-group-count';
586
+ const muteEl = createToggle('mx-mute', 'M', MIXER_COPY.mute);
587
+ const soloEl = createToggle('mx-solo', 'S', MIXER_COPY.solo);
588
+ line.append(countEl, muteEl, soloEl);
589
+ // The one muted line that says why a button reaches fewer tracks, or that the group has none.
590
+ const noteEl = document.createElement('p');
591
+ noteEl.className = 'mx-group-note';
592
+ noteEl.toggleAttribute('hidden', true);
593
+ head.append(titleRow, line, noteEl);
594
+ const bodyEl = document.createElement('div');
595
+ bodyEl.className = 'mx-group-body';
596
+ groupBodies += 1;
597
+ bodyEl.id = `mx-group-body-${groupBodies}`;
598
+ foldEl.setAttribute('aria-controls', bodyEl.id);
599
+ el.append(head, bodyEl);
600
+ const ref = { el, foldEl, chevronEl, nameEl, lightEl: light.el, lightFillEl: light.fillEl, countEl, muteEl, soloEl, noteEl, bodyEl, current: item };
601
+ // Only the caller changes the fold state: this view just says which group was pressed.
602
+ // A frame the model no longer has (ref.stale) folds nothing: its group is not in the code any more.
603
+ foldEl.addEventListener('click', () => {
604
+ if (ref.stale !== true) onGroupFold(ref.current.key);
605
+ });
606
+ muteEl.addEventListener('click', () => pressGroup(ref, 'mute'));
607
+ soloEl.addEventListener('click', () => pressGroup(ref, 'solo'));
608
+ return ref;
609
+ }
610
+
611
+ function updateGroupFrame(ref, item) {
612
+ ref.current = item;
613
+ ref.stale = false;
614
+ const { el } = ref;
615
+ setAttr(el, 'aria-label', item.label);
616
+ setAttr(el, 'data-group', item.key);
617
+ setAttr(el, 'data-folded', String(!item.open));
618
+ if (item.deep) setAttr(el, 'data-deep', 'true');
619
+ else if (el.hasAttribute('data-deep')) el.removeAttribute('data-deep');
620
+ setText(ref.chevronEl, item.open ? '\u25be' : '\u25b8');
621
+ setText(ref.nameEl, item.text);
622
+ setAttr(ref.nameEl, 'title', item.title);
623
+ setAttr(ref.foldEl, 'aria-expanded', String(item.open));
624
+ setAttr(ref.foldEl, 'title', item.foldTitle);
625
+ // A function as the replacement keeps a `$` in a name from being read as a pattern.
626
+ setAttr(ref.lightEl, 'title', MIXER_COPY.lightGroupTitle.replace('{group}', () => item.text));
627
+ setText(ref.countEl, item.countText);
628
+ setAttr(ref.countEl, 'title', item.countTitle);
629
+ for (const [button, kind] of [[ref.muteEl, 'mute'], [ref.soloEl, 'solo']]) {
630
+ const control = item[kind];
631
+ setAttr(button, 'aria-label', control.name);
632
+ setAttr(button, 'aria-pressed', String(control.pressed));
633
+ setAttr(button, 'title', control.title);
634
+ setAttr(button, 'data-control-disabled', String(control.disabled));
635
+ }
636
+ const noteText = item.noteText;
637
+ setText(ref.noteEl, noteText ?? '');
638
+ if (noteText === null) {
639
+ if (ref.noteEl.hasAttribute('title')) ref.noteEl.removeAttribute('title');
640
+ } else {
641
+ setAttr(ref.noteEl, 'title', noteText);
642
+ }
643
+ if (ref.noteEl.hasAttribute('hidden') !== (noteText === null)) ref.noteEl.toggleAttribute('hidden', noteText === null);
644
+ if (ref.bodyEl.hasAttribute('hidden') === item.open) ref.bodyEl.toggleAttribute('hidden', !item.open);
645
+ }
646
+
647
+ /**
648
+ * Puts `desired` (elements) as the children of `parent`, in that order, moving only what is not already in place.
649
+ * `held` holds the elements that must not be moved or removed (a strip whose fader the hand holds): they stay where
650
+ * they are, and `misplaced` collects those that are not where the model wants them, so a later draw puts them right.
651
+ */
652
+ function place(parent, desired, held, misplaced) {
653
+ const wanted = new Set(desired);
654
+ for (const child of [...parent.children]) {
655
+ if (wanted.has(child) || held.has(child)) continue;
656
+ // A frame that still holds a held strip stays until the strip is let go.
657
+ if (held.size > 0 && [...held].some((heldEl) => child.contains(heldEl))) continue;
658
+ child.remove();
659
+ }
660
+ let next = null;
661
+ for (let i = desired.length - 1; i >= 0; i--) {
662
+ const el = desired[i];
663
+ if (el.parentNode === parent && el.nextSibling === next) {
664
+ next = el;
665
+ continue;
666
+ }
667
+ if (held.has(el) && el.parentNode !== null) {
668
+ misplaced.add(el);
669
+ if (el.parentNode === parent) next = el;
670
+ continue;
671
+ }
672
+ parent.insertBefore(el, next);
673
+ next = el;
674
+ }
675
+ }
676
+
505
677
  function createStrip(strip) {
506
678
  const el = document.createElement('div');
507
679
  el.className = 'mx-strip';
@@ -510,6 +682,11 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
510
682
  nameEl.type = 'button';
511
683
  nameEl.className = 'mx-name';
512
684
  nameEl.setAttribute('aria-pressed', 'false');
685
+ // The light sits before the name; the row keeps both on one line. A strip with no label has no light (updateStrip hides it).
686
+ const nameRowEl = document.createElement('div');
687
+ nameRowEl.className = 'mx-name-row';
688
+ const light = createLight();
689
+ nameRowEl.append(light.el, nameEl);
513
690
  // Present only for a strip the mixer cannot edit: the one muted line that says why.
514
691
  const reasonEl = document.createElement('div');
515
692
  reasonEl.className = 'mx-reason';
@@ -581,11 +758,11 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
581
758
  sharedEl.toggleAttribute('hidden', true);
582
759
  if (midi) {
583
760
  el.dataset.midi = 'true';
584
- el.append(nameEl, tagEl, rootNoteEl, midiChipsEl, midiNoteEl, section, sharedEl, msEl, noteEl);
761
+ el.append(nameRowEl, tagEl, rootNoteEl, midiChipsEl, midiNoteEl, section, sharedEl, msEl, noteEl);
585
762
  } else {
586
- el.append(nameEl, rootNoteEl, chainEl, sendsEl, section, msEl, noteEl);
763
+ el.append(nameRowEl, rootNoteEl, chainEl, sendsEl, section, msEl, noteEl);
587
764
  }
588
- const ref = { midi, el, nameEl, rootNoteEl, sharedEl, tagEl, tagNameEl, tagChannelEl, reasonEl, faderEl, readoutEl, lineEl, section, muteEl, soloEl, noteEl, chainEl, addEl, addListEl, sendsEl, sendRows: [], chips: new Map(), midiChipsEl, midiNoteEl, midiChips: new Map(), addOpen: false, addKey: null, current: strip, pointerDown: false };
765
+ const ref = { midi, el, nameEl, lightEl: light.el, lightFillEl: light.fillEl, rootNoteEl, sharedEl, tagEl, tagNameEl, tagChannelEl, reasonEl, faderEl, readoutEl, lineEl, section, muteEl, soloEl, noteEl, chainEl, addEl, addListEl, sendsEl, sendRows: [], chips: new Map(), midiChipsEl, midiNoteEl, midiChips: new Map(), addOpen: false, addKey: null, current: strip, pointerDown: false };
589
766
  for (const bus of BUS_IDS) {
590
767
  const row = createSendRow(ref, bus);
591
768
  ref.sendRows.push(row);
@@ -647,6 +824,9 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
647
824
  ref.el.dataset.color = String(strip.color);
648
825
  ref.nameEl.textContent = strip.name;
649
826
  ref.nameEl.title = strip.name;
827
+ const hasLight = strip.lightKey !== null && strip.lightKey !== undefined;
828
+ if (ref.lightEl.hasAttribute('hidden') === hasLight) ref.lightEl.toggleAttribute('hidden', !hasLight);
829
+ setAttr(ref.lightEl, 'title', MIXER_COPY.lightTrackTitle.replace('{name}', () => strip.name));
650
830
  const tag = strip.midi === null ? null : strip.midi.tag;
651
831
  ref.tagEl.toggleAttribute('hidden', tag === null);
652
832
  ref.tagNameEl.textContent = tag === null ? '' : tag.name;
@@ -1863,11 +2043,12 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
1863
2043
  mixerNoNotes.textContent = '';
1864
2044
  refs.clear();
1865
2045
  busRefs.clear();
2046
+ groupRefs.clear();
1866
2047
  selectedStrip = null;
1867
2048
  mixerStrips.replaceChildren();
1868
2049
  renderInspector();
1869
2050
  refreshNotice();
1870
- return;
2051
+ return { created: 0 };
1871
2052
  }
1872
2053
  mixerMessage.hidden = true;
1873
2054
  mixerBody.hidden = false;
@@ -1877,8 +2058,16 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
1877
2058
  if (noNotesText === null) mixerNoNotes.removeAttribute('title');
1878
2059
  else mixerNoNotes.title = MIXER_COPY.noNotesTitle;
1879
2060
  mixerNoNotes.hidden = noNotesText === null;
2061
+ // How many strips and group frames this render built new. A new element starts dark, so the caller makes the lights write
2062
+ // their lit values again when it is more than 0.
2063
+ let created = 0;
1880
2064
  const seen = new Set();
1881
- model.strips.forEach((strip, index) => {
2065
+ // The strips placed in the DOM whose fader the hand holds: they are not moved or removed until it lets go.
2066
+ const held = new Set();
2067
+ for (const ref of refs.values()) {
2068
+ if (ref.el.parentNode !== null && isHeld(ref)) held.add(ref.el);
2069
+ }
2070
+ model.strips.forEach((strip) => {
1882
2071
  seen.add(strip.identity);
1883
2072
  let ref = refs.get(strip.identity);
1884
2073
  if (ref && ref.midi !== (strip.midi !== null)) {
@@ -1895,16 +2084,14 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
1895
2084
  if (!ref) {
1896
2085
  ref = createStrip(strip);
1897
2086
  refs.set(strip.identity, ref);
2087
+ created += 1;
1898
2088
  }
1899
2089
  updateStrip(ref, strip);
1900
- // Move nodes only when the order changed, so a node the user is dragging is never re-attached.
1901
- if (mixerStrips.children[index] !== ref.el) {
1902
- mixerStrips.insertBefore(ref.el, mixerStrips.children[index] || null);
1903
- }
1904
2090
  });
1905
2091
  // The bus strips come after the tracks, in the model's order (orbit, then reverb before delay).
1906
2092
  const seenBuses = new Set();
1907
- model.buses.forEach((entry, index) => {
2093
+ const busEls = [];
2094
+ model.buses.forEach((entry) => {
1908
2095
  seenBuses.add(entry.key);
1909
2096
  let busRef = busRefs.get(entry.key);
1910
2097
  if (!busRef) {
@@ -1912,9 +2099,60 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
1912
2099
  busRefs.set(entry.key, busRef);
1913
2100
  }
1914
2101
  updateBusStrip(busRef, entry);
1915
- const position = model.strips.length + index;
1916
- if (mixerStrips.children[position] !== busRef.el) mixerStrips.insertBefore(busRef.el, mixerStrips.children[position] || null);
2102
+ busEls.push(busRef.el);
1917
2103
  });
2104
+ // Where every element goes: a flat Mixer is its strips then its buses; a structured piece nests the strips in group
2105
+ // frames (the frames are walked with a stack, never by recursion). Each parent gets the order it should have.
2106
+ const topEls = [];
2107
+ const plans = [{ parent: mixerStrips, desired: topEls }];
2108
+ const seenGroups = new Set();
2109
+ if (model.layout === null || model.layout === undefined) {
2110
+ for (const strip of model.strips) {
2111
+ const ref = refs.get(strip.identity);
2112
+ if (ref !== undefined) topEls.push(ref.el);
2113
+ }
2114
+ } else {
2115
+ const work = [{ items: model.layout.items, desired: topEls }];
2116
+ while (work.length > 0) {
2117
+ const { items, desired } = work.pop();
2118
+ for (const item of items) {
2119
+ if (item.kind === 'strip') {
2120
+ const ref = refs.get(item.identity);
2121
+ if (ref !== undefined) desired.push(ref.el);
2122
+ continue;
2123
+ }
2124
+ // Two frames with one key would share one element and hide strips, so a repeated key stops the render.
2125
+ if (seenGroups.has(item.key)) throw new Error(`Moontide: the Mixer got the group ${item.key} twice.`);
2126
+ seenGroups.add(item.key);
2127
+ let groupRef = groupRefs.get(item.key);
2128
+ if (groupRef === undefined) {
2129
+ groupRef = createGroupFrame(item);
2130
+ groupRefs.set(item.key, groupRef);
2131
+ created += 1;
2132
+ }
2133
+ updateGroupFrame(groupRef, item);
2134
+ desired.push(groupRef.el);
2135
+ const inner = [];
2136
+ plans.push({ parent: groupRef.bodyEl, desired: inner });
2137
+ work.push({ items: item.items, desired: inner });
2138
+ }
2139
+ }
2140
+ }
2141
+ for (const el of busEls) topEls.push(el);
2142
+ const misplaced = new Set();
2143
+ for (const { parent, desired } of plans) place(parent, desired, held, misplaced);
2144
+ // A frame the model no longer has goes, with the frames nested in it. One that still holds a held strip stays until the
2145
+ // strip is let go, marked stale: its buttons are off and hasStale asks for a draw after the release.
2146
+ for (const [key, groupRef] of [...groupRefs]) {
2147
+ if (seenGroups.has(key)) continue;
2148
+ if (groupRef.el.parentNode !== null && [...held].some((heldEl) => groupRef.el.contains(heldEl))) {
2149
+ groupRef.stale = true;
2150
+ continue;
2151
+ }
2152
+ groupRef.el.remove();
2153
+ groupRefs.delete(key);
2154
+ }
2155
+ for (const ref of refs.values()) ref.misplaced = misplaced.has(ref.el);
1918
2156
  for (const [key, busRef] of [...busRefs]) {
1919
2157
  if (seenBuses.has(key)) continue;
1920
2158
  busRef.el.remove();
@@ -1936,6 +2174,7 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
1936
2174
  refs.delete(identity);
1937
2175
  }
1938
2176
  refreshDisabled();
2177
+ return { created };
1939
2178
  }
1940
2179
 
1941
2180
  return {
@@ -1959,6 +2198,15 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
1959
2198
  const busRef = busRefs.get(`${busEl.dataset.orbit}:${busEl.dataset.bus}`);
1960
2199
  return busRef === undefined ? null : busStripTarget(element, busRef);
1961
2200
  }
2201
+ // A group's head or frame (a strip inside it was answered above): the group, with its parent group when it is nested.
2202
+ // The names come from the model's entry for the frame, never from the shown (cut) text.
2203
+ const groupEl = element.closest('.mx-group[data-group]');
2204
+ if (groupEl !== null) {
2205
+ const groupRef = groupRefs.get(groupEl.dataset.group);
2206
+ if (groupRef === undefined) return null;
2207
+ const { name, path } = groupRef.current;
2208
+ return path.length > 1 ? { kind: 'group', name, parent: path[path.length - 2] } : { kind: 'group', name };
2209
+ }
1962
2210
  return null;
1963
2211
  },
1964
2212
  /** The identity of the selected track strip, or null: the editor lights that track's block. */
@@ -1979,11 +2227,38 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
1979
2227
  /** True when a fader kept its own value while held and is free to take the model's now. */
1980
2228
  hasStale() {
1981
2229
  for (const ref of refs.values()) {
1982
- if (ref.stale && !isHeld(ref)) return true;
2230
+ if ((ref.stale || ref.misplaced === true) && !isHeld(ref)) return true;
2231
+ }
2232
+ // A frame kept for a strip that has been let go since: the next draw removes it.
2233
+ for (const groupRef of groupRefs.values()) {
2234
+ if (groupRef.stale === true && !gesture.hasPending() && ![...refs.values()].some((ref) => ref.pointerDown && groupRef.el.contains(ref.el))) return true;
1983
2235
  }
1984
2236
  // An inspector control kept its own value while it was held: free to take the model's now.
1985
2237
  return insp.stale && !gesture.hasPending() && ![...insp.rows.values()].some((r) => r.pointerDown);
1986
2238
  },
2239
+ /**
2240
+ * Draws the lights that changed (page/lights.js frame): `[{ kind: 'track' | 'group', id, value }]`, `value` from 0 to 1.
2241
+ * With motion 'on' the fill's own opacity is the brightness; with motion 'off' a light is only lit or unlit, which the
2242
+ * stylesheet shows through `data-lit`, and no opacity is ever written. A light this view does not hold is skipped.
2243
+ */
2244
+ setLights(changes, motion) {
2245
+ if (motion !== 'on' && motion !== 'off') {
2246
+ throw new Error(`Moontide: the lights need motion 'on' or 'off', got ${String(motion)}.`);
2247
+ }
2248
+ for (const change of changes) {
2249
+ const ref = change.kind === 'group' ? groupRefs.get(change.id) : refs.get(change.id);
2250
+ if (ref === undefined) continue;
2251
+ if (motion === 'on') ref.lightFillEl.style.opacity = String(change.value);
2252
+ else ref.lightEl.dataset.lit = String(change.value > 0);
2253
+ }
2254
+ },
2255
+ /** Takes every light back to unlit: no inline opacity and no lit mark, in both forms. */
2256
+ clearLights() {
2257
+ for (const ref of [...refs.values(), ...groupRefs.values()]) {
2258
+ if (ref.lightFillEl.style.opacity !== '') ref.lightFillEl.style.opacity = '';
2259
+ if (ref.lightEl.dataset.lit !== undefined) delete ref.lightEl.dataset.lit;
2260
+ }
2261
+ },
1987
2262
  /** 'connected' | 'connecting' | 'disconnected' (the same states the History view gets). */
1988
2263
  setConnected(state) {
1989
2264
  connection = state;