moontide-mcp 2.1.0 → 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.
package/page/jam-view.js CHANGED
@@ -1,21 +1,25 @@
1
- // The Jam view's DOM: the message, the scale block, the seven degree chips, the
1
+ // The Jam view's DOM: the message, the scale block with the line that says why a scale is proposed and the list of the scales that
2
+ // fit (buttons that switch while one can be chosen, plain text when none can), the seven degree chips, the
2
3
  // status box, the coming-up rows and the piano, built with DOM methods only. Every text reaches
3
4
  // the page through textContent, title or setAttribute, never as markup: a part name comes from
4
5
  // the code and is not trusted. The view attaches no right-click listener: page/main.js owns the
5
6
  // only one. No DOM access at import time: main.js calls createJamView() with its document, so
6
7
  // this module can be loaded anywhere. The words and the model come from page/jam-model.js.
7
8
 
8
- import { JAM_COPY, statusAnnounceKey, noteDescriptor, pianoKeys, keyLabel, fretCells, fretUnits, legendEntries, STRING_NAMES, FRET_NUMBERS } from './jam-model.js';
9
+ import { JAM_COPY, statusAnnounceKey, noteDescriptor, pianoKeys, keyLabel, keyTitle, fretCells, fretUnits, legendEntries, STRING_NAMES, FRET_NUMBERS } from './jam-model.js';
9
10
 
10
11
  const DEGREE_COUNT = 7;
11
12
 
12
13
  /**
13
14
  * Builds the Jam view's DOM once and returns `{ showMessage, render, setPreferences }`.
14
15
  * `onInstrumentChange(name)` is called with 'piano' or 'guitar' when the user picks the other
15
- * segment, `onChordTonesChange(checked)` when the user flips the Chord tones checkbox; the view
16
- * never touches browser storage, main.js owns the preferences.
16
+ * segment, `onChordTonesChange(checked)` when the user flips the Chord tones checkbox,
17
+ * `onScaleChoice({ setKey, key })` when the user picks a scale that is not the one in use (a click, or Enter or Space on
18
+ * the focused button); a handler that throws is caught and said once per set of candidates. The view never touches
19
+ * browser storage and never chooses: main.js owns the preferences and the choice.
17
20
  */
18
- export function createJamView({ document, onInstrumentChange, onChordTonesChange }) {
21
+ export function createJamView({ document, onInstrumentChange, onChordTonesChange, onScaleChoice }) {
22
+ if (typeof onScaleChoice !== 'function') throw new Error('Moontide: createJamView needs onScaleChoice, a function that takes { setKey, key }.');
19
23
  const jamView = document.getElementById('jam-view');
20
24
  const jamMessage = document.getElementById('jam-message');
21
25
  const jamMessageText = document.getElementById('jam-message-text');
@@ -25,9 +29,17 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
25
29
  const jamDeclaredLabel = document.getElementById('jam-declared-label');
26
30
  const jamDeclaredValue = document.getElementById('jam-declared-value');
27
31
  const jamDeclaredSection = document.getElementById('jam-declared-section');
32
+ const jamScale = document.getElementById('jam-scale');
28
33
  const jamScaleLabel = document.getElementById('jam-scale-label');
29
34
  const jamScaleName = document.getElementById('jam-scale-name');
30
35
  const jamScaleSub = document.getElementById('jam-scale-sub');
36
+ const jamReason = document.getElementById('jam-reason');
37
+ const jamReasonLead = document.getElementById('jam-reason-lead');
38
+ const jamReasonRest = document.getElementById('jam-reason-rest');
39
+ const jamCandidates = document.getElementById('jam-candidates');
40
+ const jamCandidatesLabel = document.getElementById('jam-candidates-label');
41
+ const jamCandidateList = document.getElementById('jam-candidate-list');
42
+ const jamCandidatesMore = document.getElementById('jam-candidates-more');
31
43
  const jamDegrees = document.getElementById('jam-degrees');
32
44
  const jamStatus = document.getElementById('jam-status');
33
45
  const jamStatusLead = document.getElementById('jam-status-lead');
@@ -105,7 +117,7 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
105
117
  labelEl.className = 'jam-key-label';
106
118
  el.append(labelEl);
107
119
  jamPiano.append(el);
108
- return { key, el, labelEl, shown: { note: null, role: null, root: null, chord: null, label: null } };
120
+ return { key, el, labelEl, shown: { note: null, role: null, root: null, chord: null, label: null, title: '' } };
109
121
  });
110
122
  let shownPianoLabel = null;
111
123
 
@@ -163,6 +175,11 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
163
175
  }
164
176
 
165
177
  let shownDeclaredOn = false;
178
+ let shownLabelOn = null;
179
+ let shownSource = null;
180
+ let shownReasonOn = null;
181
+ let shownReasonLead = null;
182
+ let shownReasonRest = null;
166
183
  let shownDeclaredKind = null;
167
184
  let shownDeclaredText = null;
168
185
  let shownDeclaredTitle = null;
@@ -199,14 +216,13 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
199
216
  lastModel = null;
200
217
  }
201
218
 
202
- // The Declared line and the Heard label: both shown with a declaration, both hidden without one. The
203
- // text comes from the code, so it goes in as text only, and only when it changed.
219
+ // The Declared line: shown with a declaration, hidden without one. The text comes from the code, so it goes
220
+ // in as text only, and only when it changed.
204
221
  function renderDeclared(declared) {
205
222
  if (declared === undefined) throw new Error('Moontide: a ready Jam model needs its declared field (null without a declaration).');
206
223
  const on = declared !== null;
207
224
  if (on !== shownDeclaredOn) {
208
225
  jamDeclared.hidden = !on;
209
- jamScaleLabel.hidden = !on;
210
226
  shownDeclaredOn = on;
211
227
  }
212
228
  if (!on) return;
@@ -224,6 +240,136 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
224
240
  }
225
241
  }
226
242
 
243
+ // Where the scale in use came from. The Heard label shows only with a declaration and only for a scale that was heard: it
244
+ // would be false for a proposed one. The source rides on #jam-scale for tests and the probe.
245
+ function renderSource(declared, scaleSource) {
246
+ if (scaleSource !== 'heard' && scaleSource !== 'proposed' && scaleSource !== 'chosen') {
247
+ throw new Error(`Moontide: a ready Jam model needs a scale source of heard, proposed or chosen, got '${String(scaleSource)}'.`);
248
+ }
249
+ const labelOn = declared !== null && scaleSource === 'heard';
250
+ if (labelOn !== shownLabelOn) {
251
+ jamScaleLabel.hidden = !labelOn;
252
+ shownLabelOn = labelOn;
253
+ }
254
+ if (scaleSource !== shownSource) {
255
+ jamScale.dataset.source = scaleSource;
256
+ shownSource = scaleSource;
257
+ }
258
+ }
259
+
260
+ // The line that says why the scale is proposed: a lead word and one sentence, written only when they changed so the
261
+ // polite live region reads a proposal once and never per tick. Hiding it clears both texts, so a proposal that comes back
262
+ // later is read again.
263
+ function renderReason(reason) {
264
+ if (reason === undefined) throw new Error('Moontide: a ready Jam model needs its reason field (null for a scale that was heard).');
265
+ const on = reason !== null;
266
+ const lead = on ? reason.lead : '';
267
+ const rest = on ? reason.rest : '';
268
+ if (lead !== shownReasonLead) {
269
+ jamReasonLead.textContent = lead;
270
+ shownReasonLead = lead;
271
+ }
272
+ if (rest !== shownReasonRest) {
273
+ jamReasonRest.textContent = rest;
274
+ shownReasonRest = rest;
275
+ }
276
+ if (on !== shownReasonOn) {
277
+ jamReason.hidden = !on;
278
+ shownReasonOn = on;
279
+ }
280
+ }
281
+
282
+ // The scales that fit. Pick mode draws one button per scale and exactly one is pressed; list mode draws plain items in a
283
+ // list. The items are rebuilt only when the mode, the set of candidates or the names drawn changed; a switch of the
284
+ // pressed scale rewrites aria-pressed in place, so the button just clicked keeps focus (and a rebuild gives focus back to
285
+ // the button of the same scale). Each button reads its own key
286
+ // from its closure, never from the markup, and a click on the pressed one does nothing. A choice the page refuses is said
287
+ // once per set of candidates, not once per click.
288
+ let shownCandidatesOn = null;
289
+ let shownCandidatesMode = null;
290
+ let shownCandidatesCaption = null;
291
+ let shownCandidatesMore = null;
292
+ let shownCandidatesKey = null;
293
+ let candidateRefs = [];
294
+ const choiceReportedFor = new Set();
295
+
296
+ function chooseCandidate(setKey, ref) {
297
+ if (ref.pressed) return;
298
+ try {
299
+ onScaleChoice({ setKey, key: ref.key });
300
+ } catch (err) {
301
+ if (!choiceReportedFor.has(setKey)) {
302
+ choiceReportedFor.add(setKey);
303
+ console.error('Moontide: the Jam view could not switch the scale:', err);
304
+ }
305
+ }
306
+ }
307
+
308
+ function buildCandidates(candidates) {
309
+ // The buttons are replaced, so a focused one would be lost: note its key now and give focus to the new button of the
310
+ // same scale (a pick can change which scale sits in the last place drawn when more than eight fit).
311
+ const focused = candidateRefs.find((ref) => ref.el === document.activeElement);
312
+ candidateRefs = candidates.items.map((item) => {
313
+ const pick = candidates.mode === 'pick';
314
+ const el = document.createElement(pick ? 'button' : 'span');
315
+ el.className = 'jam-candidate';
316
+ el.textContent = item.name;
317
+ const ref = { el, key: item.key, pressed: pick && item.pressed };
318
+ if (pick) {
319
+ el.type = 'button';
320
+ el.setAttribute('aria-pressed', String(item.pressed));
321
+ el.addEventListener('click', () => chooseCandidate(candidates.setKey, ref));
322
+ } else {
323
+ el.setAttribute('role', 'listitem');
324
+ }
325
+ return ref;
326
+ });
327
+ if (candidates.mode === 'list') jamCandidateList.setAttribute('role', 'list');
328
+ else jamCandidateList.removeAttribute('role');
329
+ jamCandidateList.replaceChildren(...candidateRefs.map((ref) => ref.el));
330
+ if (focused !== undefined) candidateRefs.find((ref) => ref.key === focused.key)?.el.focus();
331
+ }
332
+
333
+ function renderCandidates(candidates) {
334
+ if (candidates === undefined) throw new Error('Moontide: a ready Jam model needs its candidates field (null for a scale that was heard).');
335
+ const on = candidates !== null;
336
+ if (on !== shownCandidatesOn) {
337
+ jamCandidates.hidden = !on;
338
+ shownCandidatesOn = on;
339
+ }
340
+ if (!on) return;
341
+ if (candidates.mode !== 'pick' && candidates.mode !== 'list') {
342
+ throw new Error(`Moontide: the Jam view cannot draw scales that fit in the mode '${String(candidates.mode)}'.`);
343
+ }
344
+ if (candidates.mode !== shownCandidatesMode) {
345
+ jamCandidates.dataset.mode = candidates.mode;
346
+ shownCandidatesMode = candidates.mode;
347
+ }
348
+ if (candidates.caption !== shownCandidatesCaption) {
349
+ jamCandidatesLabel.textContent = candidates.caption;
350
+ shownCandidatesCaption = candidates.caption;
351
+ }
352
+ if (candidates.more !== shownCandidatesMore) {
353
+ jamCandidatesMore.textContent = candidates.more ?? '';
354
+ jamCandidatesMore.hidden = candidates.more === null;
355
+ shownCandidatesMore = candidates.more;
356
+ }
357
+ const listKey = [candidates.mode, candidates.setKey, ...candidates.items.map((item) => item.key)].join('\n');
358
+ if (listKey !== shownCandidatesKey) {
359
+ buildCandidates(candidates);
360
+ shownCandidatesKey = listKey;
361
+ return;
362
+ }
363
+ if (candidates.mode !== 'pick') return;
364
+ candidates.items.forEach((item, i) => {
365
+ const ref = candidateRefs[i];
366
+ if (ref.pressed !== item.pressed) {
367
+ ref.el.setAttribute('aria-pressed', String(item.pressed));
368
+ ref.pressed = item.pressed;
369
+ }
370
+ });
371
+ }
372
+
227
373
  // The section the Declared line speaks for: its words after the value, hidden for a piece without sections.
228
374
  function renderDeclaredSection(section) {
229
375
  if (section === undefined) throw new Error('Moontide: a ready Jam model needs its section field (null without sections).');
@@ -376,7 +522,7 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
376
522
  });
377
523
  }
378
524
 
379
- // The piano: each key's note, role, avoided-root ring and label, written only when changed.
525
+ // The piano: each key's note, role, avoided-root ring, label and hover title, written only when changed.
380
526
  function renderInstrument(instrument) {
381
527
  const { roles, names, rootPc, chordPcs } = instrument;
382
528
  for (const ref of keys) {
@@ -408,6 +554,12 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
408
554
  ref.labelEl.textContent = label;
409
555
  ref.shown.label = label;
410
556
  }
557
+ const title = keyTitle(ref.key, roles, names, chordPcs, rootPc);
558
+ if (ref.shown.title !== title) {
559
+ if (title === '') ref.el.removeAttribute('title');
560
+ else ref.el.setAttribute('title', title);
561
+ ref.shown.title = title;
562
+ }
411
563
  }
412
564
  if (instrument.pianoLabel !== shownPianoLabel) {
413
565
  jamPiano.setAttribute('aria-label', instrument.pianoLabel);
@@ -494,6 +646,9 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
494
646
  lastModel = model;
495
647
  renderDeclared(model.declared);
496
648
  renderDeclaredSection(model.section);
649
+ renderSource(model.declared, model.scaleSource);
650
+ renderReason(model.reason);
651
+ renderCandidates(model.candidates);
497
652
  renderScale(model.scale, model.degrees);
498
653
  renderStatus(model.status);
499
654
  if (model.paused !== shownPaused) {
package/page/lights.js CHANGED
@@ -78,8 +78,8 @@ export function readOutputPair(audio) {
78
78
 
79
79
  /**
80
80
  * What the lights need to know about a Mixer model: `{ tracks, ancestors }`. `tracks` has one entry
81
- * `{ key, id, sounding }` for every strip that has a light key (`key` is what a note names, `id` the strip's identity,
82
- * which the view finds the light by). A strip that plays MIDI cannot sound in a browser without Web MIDI, so it counts as
81
+ * `{ key, id, sounding, midi }` for every strip that has a light key (`key` is what a note names, `id` the strip's identity,
82
+ * which the view finds the light by, `midi` whether the strip plays a MIDI instrument). A strip that plays MIDI cannot sound in a browser without Web MIDI, so it counts as
83
83
  * not sounding there. `ancestors` maps a strip's id to the keys of the groups above it, outermost first. A model that
84
84
  * is not ready gives an empty layout.
85
85
  */
@@ -90,7 +90,7 @@ export function lightLayout(model, { webMidi }) {
90
90
  for (const strip of model.strips) {
91
91
  if (strip.lightKey === null || strip.lightKey === undefined) continue;
92
92
  const playsMidi = (strip.midi !== null && strip.midi !== undefined) || strip.playsMidi === true;
93
- tracks.push({ key: strip.lightKey, id: strip.identity, sounding: strip.sounding === true && (webMidi === true || !playsMidi) });
93
+ tracks.push({ key: strip.lightKey, id: strip.identity, sounding: strip.sounding === true && (webMidi === true || !playsMidi), midi: playsMidi });
94
94
  }
95
95
  if (model.layout !== null && model.layout !== undefined) {
96
96
  for (const group of model.layout.groups) {
@@ -108,10 +108,13 @@ export function lightLayout(model, { webMidi }) {
108
108
  * The light state. `setLayout(layout)` says which tracks and groups exist (see lightLayout); `noteSeen(key, heard)` queues
109
109
  * a note, `heard` being `{ at }` (a time on the page clock, in ms) or `{ target }` (an audio time, in seconds);
110
110
  * `frame(ts, { motion, cps, toPerf })` lights what is due at `ts` and returns `[{ kind, id, value }]` for the lights whose
111
- * value changed since the last frame; `reset()` forgets every note and every lit light.
111
+ * value changed since the last frame; `reset()` forgets every note and every lit light; `forgetShown()` forgets only which
112
+ * values were handed out, so the next frame hands out every lit value again and still darkens a light that has gone out
113
+ * meanwhile (after the view rebuilt some of its lights);
114
+ * `playsMidi(key)` tells whether the strip that owns `key` plays a MIDI instrument (false for an unknown key).
112
115
  */
113
116
  export function createLights() {
114
- // key -> { id, sounding }
117
+ // key -> { id, sounding, midi }
115
118
  let tracks = new Map();
116
119
  // strip id -> keys of the groups above it
117
120
  let ancestors = new Map();
@@ -147,7 +150,7 @@ export function createLights() {
147
150
  for (const track of layout.tracks) {
148
151
  const known = next.get(track.key);
149
152
  if (known !== undefined && known.sounding) continue;
150
- next.set(track.key, { id: track.id, sounding: track.sounding === true });
153
+ next.set(track.key, { id: track.id, sounding: track.sounding === true, midi: track.midi === true });
151
154
  }
152
155
  tracks = next;
153
156
  ancestors = layout.ancestors;
@@ -242,8 +245,15 @@ export function createLights() {
242
245
  const changes = [];
243
246
  for (const [key, value] of trackValues) {
244
247
  const shown = shownTracks.get(key);
245
- if (shown !== undefined && shown.value === value) continue;
246
248
  const id = tracks.get(key).id;
249
+ // The strip that owns a shared key can change while its light is lit: the old strip goes dark and the new one shows
250
+ // the current value in this same frame.
251
+ if (shown !== undefined && shown.id !== id) {
252
+ changes.push({ kind: 'track', id: shown.id, value: 0 });
253
+ shown.id = id;
254
+ shown.value = -1;
255
+ }
256
+ if (shown !== undefined && shown.value === value) continue;
247
257
  if (shown === undefined) shownTracks.set(key, { id, value });
248
258
  else shown.value = value;
249
259
  changes.push({ kind: 'track', id, value });
@@ -266,6 +276,18 @@ export function createLights() {
266
276
  return changes;
267
277
  },
268
278
 
279
+ forgetShown() {
280
+ // Keep the entries but mark their values unknown: a light that is lit gets its value handed out again, and one that
281
+ // has gone dark by the next frame still falls into the leaving loops and gets its dark change.
282
+ for (const shown of shownTracks.values()) shown.value = -1;
283
+ for (const group of shownGroups.keys()) shownGroups.set(group, -1);
284
+ },
285
+
286
+ playsMidi(key) {
287
+ const track = tracks.get(key);
288
+ return track !== undefined && track.midi === true;
289
+ },
290
+
269
291
  reset() {
270
292
  pending.clear();
271
293
  pendingKeys.clear();