moontide-mcp 2.1.0 → 2.4.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 +54 -331
  2. package/dist/ask.js +59 -12
  3. package/dist/catalog.js +118 -9
  4. package/dist/instruments.js +83 -9
  5. package/dist/mcp.js +146 -20
  6. package/dist/midi-state.js +43 -1
  7. package/dist/protocol.js +123 -5
  8. package/dist/reference.js +12 -0
  9. package/dist/request-status.js +221 -0
  10. package/dist/session.js +203 -7
  11. package/dist/vocabulary.js +420 -0
  12. package/package.json +3 -2
  13. package/page/ask-view.js +118 -50
  14. package/page/ask.js +139 -169
  15. package/page/bar-model.js +44 -2
  16. package/page/change-timing.js +35 -0
  17. package/page/index.html +6 -2
  18. package/page/instruments-model.js +50 -3
  19. package/page/instruments-view.js +14 -2
  20. package/page/jam-analysis.js +68 -23
  21. package/page/jam-model.js +185 -29
  22. package/page/jam-resolve.js +271 -0
  23. package/page/jam-view.js +165 -10
  24. package/page/lights.js +29 -7
  25. package/page/main.js +807 -66
  26. package/page/midi-clock.js +22 -3
  27. package/page/midi-in.js +407 -0
  28. package/page/midi-out.js +557 -48
  29. package/page/midi-plan.js +42 -6
  30. package/page/midi-rig.js +286 -13
  31. package/page/midi-settings.js +507 -0
  32. package/page/mixer-view.js +7 -1
  33. package/page/note-panel.js +29 -8
  34. package/page/note-tag.js +19 -4
  35. package/page/page-rewrite.js +1 -1
  36. package/page/played-buffer.js +208 -0
  37. package/page/query-check.js +21 -2
  38. package/page/request-status-view.js +419 -0
  39. package/page/request-status.js +686 -0
  40. package/page/scratch-gesture.js +239 -0
  41. package/page/scratch-listen.js +260 -0
  42. package/page/scratch-model.js +1696 -0
  43. package/page/scratch-view.js +2176 -0
  44. package/page/song-grid-model.js +15 -0
  45. package/page/song-grid-view.js +1 -1
  46. package/page/structure.js +2 -1
  47. package/page/style.css +1243 -43
  48. package/page/tempo-glide.js +330 -0
  49. package/page/tempo-guard.js +62 -10
  50. package/page/timeline-edit-view.js +1 -1
  51. package/page/timeline.js +2 -1
  52. package/page/top-bar-view.js +18 -13
  53. package/page/views.js +20 -7
  54. package/reference/midi.md +2 -0
  55. package/reference/motif.md +83 -0
  56. package/reference/settings.md +161 -0
  57. package/reference/transitions.md +13 -1
@@ -0,0 +1,208 @@
1
+ // The notes played on a MIDI keyboard, kept in memory as notes with a start and an end in music cycles. Pure: no DOM, no window, no
2
+ // timer and no clock at import or at any call, so node:test and page/main.js import it the same way. Every call is given the cycle
3
+ // it happened at; this module never reads a clock and never sends anything. Which bytes are a note is decided here too, so the
4
+ // listener can drop everything else before it reads any clock.
5
+ //
6
+ // The buffer keeps a window: the 8 whole cycles that end with the current one, so everything it holds fits on the roll (whose longest motif
7
+ // is 8 cycles) and its edges sit on the music's cycle lines. `snapshot(now)` is what "record that" and, later, a request to Claude read:
8
+ // `{ start, cycles, notes }` where `start` is the window's first cycle, `cycles` its length (8) and each note
9
+ // `{ pitch, velocity, start, end, run, held }` in cycles from the window's start, in the order played, a note that began before the window cut at
10
+ // 0 and a note still held closed at `now` (`held` is true for it, so the live view can keep growing it). `run` says which run of the music the note was placed in: the cycle axis's whole-number offset
11
+ // (the axis cycle minus the music's own cycle count) or null for a note placed by the tempo; equal runs mean one run of the music.
12
+
13
+ import { MAX_CYCLES } from './scratch-model.js';
14
+
15
+ /** How many whole cycles the buffer keeps: the roll's longest motif. */
16
+ export const BUFFER_CYCLES = MAX_CYCLES;
17
+ /** The most notes the buffer keeps, so a stuck key or a flood never grows memory without bound. */
18
+ export const MAX_BUFFER_NOTES = 2000;
19
+
20
+ const NOTE_OFF_STATUS = 0x80;
21
+ const NOTE_ON_STATUS = 0x90;
22
+
23
+ function isDataByte(value) {
24
+ return Number.isInteger(value) && value >= 0 && value <= 127;
25
+ }
26
+
27
+ /**
28
+ * Reads the bytes of one MIDI message. Gives `{ kind, pitch, velocity, channel }` for a three-byte note-on (status 0x90 to 0x9F) or
29
+ * note-off (0x80 to 0x8F) whose two data bytes are whole numbers from 0 to 127: `kind` is 'on' or 'off', `channel` 1 to 16, and a note-on
30
+ * with velocity 0 is an off, as the MIDI standard says. Everything else gives null: clock (0xF8), active sensing (0xFE), controllers
31
+ * (the sustain pedal), pitch bend, aftertouch, system exclusive, and data that is short, long or not bytes. Those are ignored on purpose:
32
+ * the phrase form of a motif cannot say them.
33
+ */
34
+ export function parseNoteMessage(data) {
35
+ if (data === null || typeof data !== 'object' || data.length !== 3) return null;
36
+ const [status, pitch, velocity] = [data[0], data[1], data[2]];
37
+ if (!Number.isInteger(status) || status < NOTE_OFF_STATUS || status > 0x9f) return null;
38
+ if (!isDataByte(pitch) || !isDataByte(velocity)) return null;
39
+ const channel = (status & 0x0f) + 1;
40
+ const isOn = status >= NOTE_ON_STATUS && velocity > 0;
41
+ return { kind: isOn ? 'on' : 'off', pitch, velocity, channel };
42
+ }
43
+
44
+ function checkCycle(cycle) {
45
+ if (typeof cycle !== 'number' || !Number.isFinite(cycle)) throw new Error(`Moontide: a played note needs a finite cycle, got ${String(cycle)}`);
46
+ }
47
+
48
+ function checkPitch(pitch) {
49
+ if (!isDataByte(pitch)) throw new Error(`Moontide: a played note needs a whole pitch from 0 to 127, got ${String(pitch)}`);
50
+ }
51
+
52
+ /**
53
+ * The played notes, in play order. A note is `{ pitch, velocity, start, end, run }` with the cycles of its start and end and the velocity as the
54
+ * keyboard sent it (1 to 127); `end` is null while the key is held; `run` is the axis offset the note was placed at (a whole number)
55
+ * or null when it was placed by the tempo. `noteOn(cycle, pitch, velocity, run)` (run left out is null) starts a note (a second on of a held
56
+ * pitch ends the first at that cycle) and gives `{ kept: true }`, or `{ kept: false, full: true }` when 2000 notes are kept already;
57
+ * `noteOff(cycle, pitch)` ends the held note of that pitch (an off for a pitch that is not held does nothing); `closeAll(cycle)` ends every
58
+ * held note there. `count(now)` is how many notes the window holds (`now` is the current cycle; left out, the latest cycle any call gave),
59
+ * `notes()` a copy of them, `snapshot(now)` the window as described above (the buffer is left as it was) and `clear()` empties it.
60
+ * Every call that takes a cycle drops the notes that ended before the window's start, judged against the latest cycle any call has given,
61
+ * so a note stamped a little late never moves the window back. A held note is never dropped.
62
+ */
63
+ export function createPlayedBuffer() {
64
+ let kept = [];
65
+ let latest = -Infinity;
66
+ const heldOf = (pitch) => kept.find((note) => note.pitch === pitch && note.end === null);
67
+ const windowEnd = (at) => Math.floor(at) + 1;
68
+ const outside = (note, windowStart) => note.end !== null && note.end <= windowStart && note.start < windowStart;
69
+
70
+ // Learns the cycle of a call and drops what has left the window.
71
+ function advance(cycle) {
72
+ if (cycle > latest) latest = cycle;
73
+ if (!Number.isFinite(latest)) return;
74
+ const windowStart = windowEnd(latest) - BUFFER_CYCLES;
75
+ if (kept.some((note) => outside(note, windowStart))) kept = kept.filter((note) => !outside(note, windowStart));
76
+ }
77
+
78
+ function noteOn(cycle, pitch, velocity, run = null) {
79
+ checkCycle(cycle);
80
+ checkPitch(pitch);
81
+ if (run !== null && !Number.isInteger(run)) throw new Error(`Moontide: a played note needs a run that is null or a whole number, got ${String(run)}`);
82
+ if (!Number.isInteger(velocity) || velocity < 1 || velocity > 127) throw new Error(`Moontide: a played note needs a velocity from 1 to 127, got ${String(velocity)}`);
83
+ advance(cycle);
84
+ if (kept.length >= MAX_BUFFER_NOTES) return { kept: false, full: true };
85
+ const held = heldOf(pitch);
86
+ if (held !== undefined) held.end = Math.max(cycle, held.start);
87
+ kept.push({ pitch, velocity, start: cycle, end: null, run });
88
+ return { kept: true };
89
+ }
90
+
91
+ function noteOff(cycle, pitch) {
92
+ checkCycle(cycle);
93
+ checkPitch(pitch);
94
+ advance(cycle);
95
+ const held = heldOf(pitch);
96
+ if (held !== undefined) held.end = Math.max(cycle, held.start);
97
+ }
98
+
99
+ function closeAll(cycle) {
100
+ checkCycle(cycle);
101
+ advance(cycle);
102
+ for (const note of kept) if (note.end === null) note.end = Math.max(cycle, note.start);
103
+ }
104
+
105
+ function count(now = latest) {
106
+ if (now !== latest) checkCycle(now);
107
+ advance(now);
108
+ return kept.length;
109
+ }
110
+
111
+ function snapshot(now) {
112
+ checkCycle(now);
113
+ const at = Math.max(latest, now);
114
+ const windowStart = windowEnd(at) - BUFFER_CYCLES;
115
+ const notes = kept
116
+ .filter((note) => !outside(note, windowStart))
117
+ .map((note) => {
118
+ const start = Math.max(0, note.start - windowStart);
119
+ const end = Math.max(start, (note.end === null ? Math.max(now, note.start) : note.end) - windowStart);
120
+ return { pitch: note.pitch, velocity: note.velocity, start, end, run: note.run, held: note.end === null };
121
+ });
122
+ return { start: windowStart, cycles: BUFFER_CYCLES, notes };
123
+ }
124
+
125
+ return {
126
+ noteOn,
127
+ noteOff,
128
+ closeAll,
129
+ count,
130
+ snapshot,
131
+ clear: () => {
132
+ kept = [];
133
+ latest = -Infinity;
134
+ },
135
+ notes: () => kept.map((note) => ({ ...note })),
136
+ };
137
+ }
138
+
139
+ function isLine(lines) {
140
+ if (lines === null || typeof lines !== 'object') return false;
141
+ const { a, b } = lines;
142
+ for (const anchor of [a, b]) {
143
+ if (anchor === null || typeof anchor !== 'object' || !Number.isFinite(anchor.c) || !Number.isFinite(anchor.t)) return false;
144
+ }
145
+ return a.t !== b.t;
146
+ }
147
+
148
+ const cycleOnLine = (line, h) => line.a.c + ((h - line.a.t) * (line.b.c - line.a.c)) / (line.b.t - line.a.t);
149
+
150
+ /**
151
+ * One cycle axis for the buffer. The page clock gives cycle lines only for a music that plays, yet the buffer must keep the last cycles
152
+ * by the tempo while the music is stopped. The axis uses the page clock's own lines while the music plays and runs on by the
153
+ * scheduler's tempo otherwise, and in both cases hands the same kind of two anchors to the placement code (checkStamp, then
154
+ * cycleAtStamp). It never goes back: notes already kept keep their order. While the music plays its whole cycles stay whole cycles of
155
+ * the axis, because a Play moves the axis forward to the next whole cycle: the axis cycle equals the music's own cycle count plus the
156
+ * offset (the count the code's patterns are queried at), and the offset changes only when the music restarts. Notes played before a Play are older cycles of the same axis;
157
+ * their place against the new music has no meaning, because they were played against no music.
158
+ *
159
+ * Pure: it reads no clock and keeps no timer; every read is handed its moment. `at(h, { music, cps, playing })` takes `h`, the handler's
160
+ * clock in milliseconds (performance.now() time), `music`, the page clock's two cycle lines `{ a: { c, t }, b: { c, t } }` or null,
161
+ * `cps`, the scheduler's cycles per second, and `playing`, whether the music plays. It gives `{ anchors: { a, b }, source, now }`:
162
+ * `source` is 'music' or 'free' (the tempo), `now` the axis cycle at `h` on those anchors, and `offset`, the whole number added to the music's line
163
+ * (null when `source` is 'free'); the whole answer is null when there are no lines and no tempo above 0 (nothing changes then).
164
+ *
165
+ * With usable lines the axis is the music's line plus a whole-number offset. The offset stays, except when the line reads more than half a
166
+ * cycle below the last cycle (the scheduler restarted unseen, as when the tab was hidden): it then grows by whole cycles; a smaller step
167
+ * back is clock jitter and changes nothing. While the music plays and the lines are briefly missing, the last line is kept, so a gap
168
+ * never costs a cycle. A line from before a Stop is never reused: a Play whose clock is still warming up stays on the tempo.
169
+ */
170
+ export function createCycleAxis() {
171
+ let mode = null; // null before the first read, then 'music' or 'free'
172
+ let offset = 0;
173
+ let line = null; // the line in use, in axis cycles: the music's plus the offset, or the tempo's from its origin
174
+ let freeCps = 0;
175
+ let last = null; // the axis cycle of the latest read
176
+
177
+ function answer(h, source) {
178
+ last = cycleOnLine(line, h);
179
+ return { anchors: { a: { ...line.a }, b: { ...line.b } }, source, now: last, offset: source === 'music' ? offset : null };
180
+ }
181
+
182
+ function at(h, { music, cps, playing }) {
183
+ if (!Number.isFinite(h)) throw new Error(`Moontide: the cycle axis needs a finite moment, got ${String(h)}`);
184
+ if (isLine(music)) {
185
+ const raw = cycleOnLine(music, h);
186
+ if (mode !== 'music') offset = line === null ? 0 : Math.ceil(cycleOnLine(line, h) - raw);
187
+ else if (raw + offset < last - 0.5) offset = Math.ceil(last - raw);
188
+ mode = 'music';
189
+ line = { a: { c: music.a.c + offset, t: music.a.t }, b: { c: music.b.c + offset, t: music.b.t } };
190
+ return answer(h, 'music');
191
+ }
192
+ if (playing === true && mode === 'music') return answer(h, 'music');
193
+ if (Number.isFinite(cps) && cps > 0) {
194
+ if (mode === 'free' && cps === freeCps) return answer(h, 'free');
195
+ const period = 1000 / cps;
196
+ const c = line === null ? 0 : cycleOnLine(line, h);
197
+ if (Number.isFinite(period) && period > 0 && Number.isFinite(c) && h + period !== h) {
198
+ mode = 'free';
199
+ freeCps = cps;
200
+ line = { a: { c, t: h }, b: { c: c + 1, t: h + period } };
201
+ return answer(h, 'free');
202
+ }
203
+ }
204
+ return null;
205
+ }
206
+
207
+ return { at };
208
+ }
@@ -80,8 +80,13 @@ export function captureQueryTypes(kept, globals) {
80
80
  * `onReached` when the gate first queries past the line, because the old music that keeps playing must
81
81
  * not have its errors forwarded a second time and the new pattern's first errors must still count.
82
82
  * `getPlayingCode()` gives the code that plays now, for the waiting record.
83
+ *
84
+ * `refuse`, when given, is asked first, for every value handed over, before the pattern is checked or installed: when it
85
+ * returns a text, setPattern throws an Error with that text. This is the one seam where a change can be refused for
86
+ * something only the finished program knows (the tempo it set) and still fail the evaluation before the new pattern or a
87
+ * waiting record exists: the scheduler's pattern and any waiting change stay as they were.
83
88
  */
84
- export function installQueryCheck(scheduler, { getTypes, queryErrors, schedulerQueries, takeover, getPlayingCode }) {
89
+ export function installQueryCheck(scheduler, { getTypes, queryErrors, schedulerQueries, takeover, getPlayingCode, refuse }) {
85
90
  const originalSetPattern = scheduler.setPattern;
86
91
  if (typeof originalSetPattern !== 'function') {
87
92
  throw new Error('Moontide: the scheduler of the pinned @strudel/repl 1.3.0 has no setPattern function.');
@@ -103,6 +108,10 @@ export function installQueryCheck(scheduler, { getTypes, queryErrors, schedulerQ
103
108
  return result;
104
109
  }
105
110
  scheduler.setPattern = function (pattern, autostart) {
111
+ if (typeof refuse === 'function') {
112
+ const refusal = refuse();
113
+ if (typeof refusal === 'string') throw new Error(refusal);
114
+ }
106
115
  // The repl's own handling of a value that is not a pattern stays as before (a waiting change is replaced too).
107
116
  if (pattern === null || pattern === undefined || typeof pattern.query !== 'function') {
108
117
  queryErrors.reset();
@@ -154,8 +163,11 @@ export function installQueryCheck(scheduler, { getTypes, queryErrors, schedulerQ
154
163
  * to `onError` as the bundle's log text (`[query] error: <message>`), then return no events as the
155
164
  * bundle does. Every other query keeps the bundle's own `queryArc`, whose log the page ignores.
156
165
  * `getTypes()` gives { State, TimeSpan }.
166
+ * `onSchedulerQuery`, when given, is called once on each of the scheduler's own queries, before the query runs (it is the
167
+ * tempo ramp's way to learn that the scheduler is playing). It is the scheduler's slice that calls it, so a throw is
168
+ * caught here, logged as one `Moontide:` line and never reaches the scheduler.
157
169
  */
158
- export function createSchedulerQueryScope({ getTypes, onError }) {
170
+ export function createSchedulerQueryScope({ getTypes, onError, onSchedulerQuery }) {
159
171
  const wrapped = new WeakSet();
160
172
  return {
161
173
  wrap(pattern) {
@@ -169,6 +181,13 @@ export function createSchedulerQueryScope({ getTypes, onError }) {
169
181
  if (controls === null || typeof controls !== 'object' || typeof controls.cyclist !== 'string') {
170
182
  return original.call(this, begin, end, controls);
171
183
  }
184
+ if (typeof onSchedulerQuery === 'function') {
185
+ try {
186
+ onSchedulerQuery();
187
+ } catch (err) {
188
+ console.error('Moontide: the hook on the scheduler query failed:', err);
189
+ }
190
+ }
172
191
  const { State, TimeSpan } = getTypes();
173
192
  try {
174
193
  return this.query(new State(new TimeSpan(begin, end), controls));
@@ -0,0 +1,419 @@
1
+ // The Requests chip in the top bar and the panel under it: one row per request the user sent, with the start of
2
+ // its text, the elapsed time and the words of its state; a row opens to a detail under it, and finished requests
3
+ // move to a folded Recent group. Every text reaches the page through textContent, title and setAttribute, never as
4
+ // markup: a request and Claude's words are untrusted. The view never decides anything: page/request-status.js holds
5
+ // the rows and their rules, page/main.js gives the view the model after every change. No DOM access at import
6
+ // time: main.js calls createRequestStatusView() with its document.
7
+ //
8
+ // The panel is non-modal and pushes nothing: it overlays the page under the bar's right end. It opens by itself
9
+ // when the user sends a request and never moves focus when it does; it folds by itself only when it opened by
10
+ // itself, nothing is in flight and the last finished row has moved to Recent, unless the pointer is over it. Any
11
+ // action of the user (opening it with the chip, opening a detail, putting focus inside) makes it the user's, and
12
+ // then it stays until the user folds it. Esc on the chip or inside the panel folds it and gives focus back to the
13
+ // chip; a click elsewhere does nothing. A text is written only when it changed, so a repeated draw writes nothing
14
+ // and a focused row keeps its focus. Nothing animates.
15
+
16
+ import { FINISHED_SHOWN_MS, REQUEST_STATUS_COPY, joinLive } from './request-status.js';
17
+
18
+ /** The elapsed time and a counting phrase step once a second. */
19
+ export const TICK_MS = 1000;
20
+
21
+ /** The spoken line is written at most once in this long; what arrives in between is joined. */
22
+ export const LIVE_EVERY_MS = 1000;
23
+
24
+ function fill(template, values) {
25
+ return template.replace(/\{(\w+)\}/g, (_, key) => String(values[key]));
26
+ }
27
+
28
+ /**
29
+ * Creates the view. `document` is the page's document (named so on purpose: every element lookup below reads
30
+ * `document.getElementById`, the shape test/page-hidden.test.js scans). `now`, `setTimer` and `clearTimer` are the
31
+ * page's clock. `onOpen` runs right before the panel opens (main.js closes the Session drawer there, because the
32
+ * two never show together). `onHold(id, held)` says a row is under the pointer, has focus or has its detail open,
33
+ * so the tracker keeps a finished row where it is. `takeLive()` gives the sentences to speak since the last call;
34
+ * `getModel()` gives the tracker's model again each second, for the elapsed time.
35
+ */
36
+ export function createRequestStatusView({
37
+ document,
38
+ now = () => Date.now(),
39
+ setTimer = (fn, ms) => setTimeout(fn, ms),
40
+ clearTimer = (timer) => clearTimeout(timer),
41
+ onOpen = () => {},
42
+ onHold = () => {},
43
+ takeLive = () => [],
44
+ getModel = null,
45
+ }) {
46
+ const toggle = document.getElementById('requests-toggle');
47
+ const panel = document.getElementById('requests-panel');
48
+ const live = document.getElementById('requests-live');
49
+ if (!toggle || !panel || !live) {
50
+ throw new Error('Moontide: the Requests markup is missing from the page.');
51
+ }
52
+ const word = toggle.querySelector('.rq-word');
53
+ const count = toggle.querySelector('.rq-count');
54
+ if (!word || !count) {
55
+ throw new Error('Moontide: the Requests markup is missing from the page.');
56
+ }
57
+ // The spoken line is the one live region of the feature: it must be there and speak, so a hidden one is a mistake.
58
+ if (live.hasAttribute('hidden')) {
59
+ throw new Error('Moontide: the Requests live region must never be hidden.');
60
+ }
61
+
62
+ word.textContent = REQUEST_STATUS_COPY.chipWord;
63
+ toggle.setAttribute('title', REQUEST_STATUS_COPY.chipTitle);
64
+ toggle.setAttribute('aria-controls', 'requests-panel');
65
+ toggle.setAttribute('aria-expanded', 'false');
66
+ count.setAttribute('aria-hidden', 'true');
67
+ panel.setAttribute('aria-label', REQUEST_STATUS_COPY.panelName);
68
+
69
+ const list = document.createElement('ul');
70
+ list.className = 'rq-list';
71
+ list.setAttribute('role', 'list');
72
+ panel.appendChild(list);
73
+
74
+ const recent = document.createElement('div');
75
+ recent.className = 'rq-recent';
76
+ recent.toggleAttribute('hidden', true);
77
+ const recentHead = document.createElement('button');
78
+ recentHead.type = 'button';
79
+ recentHead.className = 'rq-recent-head';
80
+ recentHead.setAttribute('aria-expanded', 'false');
81
+ recentHead.setAttribute('aria-controls', 'requests-recent-body');
82
+ const recentLabel = document.createElement('span');
83
+ recentLabel.className = 'rq-recent-label';
84
+ const recentChevron = document.createElement('span');
85
+ recentChevron.className = 'rq-chevron';
86
+ recentChevron.setAttribute('aria-hidden', 'true');
87
+ recentChevron.textContent = REQUEST_STATUS_COPY.recentFolded;
88
+ recentHead.appendChild(recentLabel);
89
+ recentHead.appendChild(recentChevron);
90
+ const recentBody = document.createElement('ul');
91
+ recentBody.id = 'requests-recent-body';
92
+ recentBody.className = 'rq-recent-body';
93
+ recentBody.setAttribute('role', 'list');
94
+ recentBody.toggleAttribute('hidden', true);
95
+ recent.appendChild(recentHead);
96
+ recent.appendChild(recentBody);
97
+ panel.appendChild(recent);
98
+
99
+ // id -> the nodes and the state of that request's row, kept between draws so a focused row keeps its focus.
100
+ const entries = new Map();
101
+ let detailCount = 0;
102
+ // Whether the user (or a send) wants the panel open. It shows only while there is a row to show.
103
+ let wantOpen = false;
104
+ // True while the panel is open only because the user sent a request; any action of the user ends it.
105
+ let autoOpened = false;
106
+ let pointerInPanel = false;
107
+ let recentOpen = false;
108
+ let board = { empty: true, rows: [], recent: [] };
109
+ let tickTimer = null;
110
+ let liveTimer = null;
111
+ let liveQueue = [];
112
+ let lastLiveAt = null;
113
+
114
+ function setText(node, value) {
115
+ if (node.textContent !== value) node.textContent = value;
116
+ }
117
+
118
+ function setAttr(node, name, value) {
119
+ if (value === '' || value === null) {
120
+ if (node.hasAttribute(name)) node.removeAttribute(name);
121
+ } else if (node.getAttribute(name) !== value) {
122
+ node.setAttribute(name, value);
123
+ }
124
+ }
125
+
126
+ // toggleAttribute with a value that is already there writes nothing.
127
+ function setHidden(node, hidden) {
128
+ node.toggleAttribute('hidden', hidden);
129
+ }
130
+
131
+ function buildEntry(id) {
132
+ detailCount += 1;
133
+ const item = document.createElement('li');
134
+ item.className = 'rq-item';
135
+ const button = document.createElement('button');
136
+ button.type = 'button';
137
+ button.className = 'rq-row';
138
+ const text = document.createElement('span');
139
+ text.className = 'rq-text';
140
+ const time = document.createElement('span');
141
+ time.className = 'rq-time';
142
+ const mark = document.createElement('span');
143
+ mark.className = 'rq-mark';
144
+ mark.setAttribute('aria-hidden', 'true');
145
+ const state = document.createElement('span');
146
+ state.className = 'rq-state';
147
+ button.appendChild(text);
148
+ button.appendChild(time);
149
+ button.appendChild(mark);
150
+ button.appendChild(state);
151
+ const detail = document.createElement('div');
152
+ detail.className = 'rq-detail';
153
+ detail.id = `requests-detail-${detailCount}`;
154
+ detail.toggleAttribute('hidden', true);
155
+ button.setAttribute('aria-controls', detail.id);
156
+ button.setAttribute('aria-expanded', 'false');
157
+ item.appendChild(button);
158
+ item.appendChild(detail);
159
+ const entry = { id, item, button, text, time, mark, state, detail, open: false, pointer: false, focus: false, held: false, lines: '', row: null };
160
+ item.addEventListener('pointerenter', () => {
161
+ entry.pointer = true;
162
+ reportHold(entry);
163
+ });
164
+ item.addEventListener('pointerleave', () => {
165
+ entry.pointer = false;
166
+ reportHold(entry);
167
+ });
168
+ item.addEventListener('focusin', (event) => {
169
+ entry.focus = item.contains(event.target);
170
+ reportHold(entry);
171
+ });
172
+ item.addEventListener('focusout', (event) => {
173
+ entry.focus = event.relatedTarget !== null && event.relatedTarget !== undefined && item.contains(event.relatedTarget);
174
+ reportHold(entry);
175
+ });
176
+ button.addEventListener('click', () => {
177
+ entry.open = !entry.open;
178
+ // Opening a detail is the user's own act.
179
+ autoOpened = false;
180
+ drawDetail(entry);
181
+ reportHold(entry);
182
+ });
183
+ return entry;
184
+ }
185
+
186
+ /** Tells the tracker when the combined hold of a row (pointer, focus or an open detail) changed. */
187
+ function reportHold(entry) {
188
+ const held = entry.pointer || entry.focus || entry.open;
189
+ if (held !== entry.held) {
190
+ entry.held = held;
191
+ onHold(entry.id, held);
192
+ }
193
+ }
194
+
195
+ function drawDetail(entry) {
196
+ setAttr(entry.button, 'aria-expanded', String(entry.open));
197
+ setHidden(entry.detail, !entry.open);
198
+ if (!entry.open || entry.row === null) return;
199
+ const key = JSON.stringify(entry.row.detail);
200
+ if (key === entry.lines) return;
201
+ entry.lines = key;
202
+ entry.detail.replaceChildren();
203
+ for (const line of entry.row.detail) {
204
+ const paragraph = document.createElement('p');
205
+ paragraph.className = `rq-${line.kind}`;
206
+ paragraph.textContent = line.text;
207
+ entry.detail.appendChild(paragraph);
208
+ }
209
+ }
210
+
211
+ function drawRow(entry, row) {
212
+ entry.row = row;
213
+ setText(entry.text, row.text);
214
+ setAttr(entry.text, 'title', row.fullText);
215
+ setText(entry.time, row.time);
216
+ setAttr(entry.time, 'title', row.timeTitle);
217
+ // The number of a row in flight changes every second: it is out of the accessible name until the row is finished.
218
+ setAttr(entry.time, 'aria-hidden', row.finished ? '' : 'true');
219
+ setAttr(entry.mark, 'data-shape', row.shape);
220
+ setText(entry.state, row.phrase);
221
+ setAttr(entry.state, 'title', row.phraseTitle);
222
+ setAttr(entry.item, 'data-finished', String(row.finished));
223
+ drawDetail(entry);
224
+ }
225
+
226
+ /** Puts the nodes of `rows` into `container` in order, one stable node per request. */
227
+ function drawRows(container, rows) {
228
+ let cursor = container.firstChild;
229
+ for (const row of rows) {
230
+ let entry = entries.get(row.id);
231
+ if (entry === undefined) {
232
+ entry = buildEntry(row.id);
233
+ entries.set(row.id, entry);
234
+ }
235
+ drawRow(entry, row);
236
+ if (entry.item === cursor) {
237
+ cursor = cursor.nextSibling;
238
+ } else {
239
+ container.insertBefore(entry.item, cursor);
240
+ }
241
+ }
242
+ }
243
+
244
+ function dropGone(wanted) {
245
+ for (const [id, entry] of entries) {
246
+ if (wanted.has(id)) continue;
247
+ entry.item.remove();
248
+ entries.delete(id);
249
+ }
250
+ }
251
+
252
+ function showPanel() {
253
+ return wantOpen && !board.empty;
254
+ }
255
+
256
+ function drawChrome() {
257
+ toggle.hidden = board.empty;
258
+ const shown = showPanel();
259
+ panel.hidden = !shown;
260
+ setAttr(toggle, 'aria-expanded', String(shown));
261
+ }
262
+
263
+ function drawRecent() {
264
+ const rows = board.recent ?? [];
265
+ setHidden(recent, rows.length === 0);
266
+ setText(recentLabel, fill(REQUEST_STATUS_COPY.recentHead, { n: rows.length }));
267
+ setAttr(recentHead, 'aria-label', fill(REQUEST_STATUS_COPY.recentName, { n: rows.length }));
268
+ setAttr(recentHead, 'aria-expanded', String(recentOpen));
269
+ setText(recentChevron, recentOpen ? REQUEST_STATUS_COPY.recentOpen : REQUEST_STATUS_COPY.recentFolded);
270
+ setHidden(recentBody, !recentOpen);
271
+ }
272
+
273
+ /** Folds the panel by itself when it opened by itself and nothing keeps it: nothing in flight, no listed row, no pointer over it. */
274
+ function maybeAutoFold() {
275
+ if (!autoOpened || !wantOpen || board.empty) return;
276
+ if ((board.inFlight ?? 0) > 0 || (board.rows ?? []).length > 0) return;
277
+ if (pointerInPanel) return;
278
+ wantOpen = false;
279
+ autoOpened = false;
280
+ drawChrome();
281
+ }
282
+
283
+ /** Whether a row still needs the clock: in flight, quiet, or finished less than 30 s ago. */
284
+ function needsClock(model) {
285
+ return (model.rows ?? []).some((row) => !row.finished || (row.finishedAgo ?? 0) < FINISHED_SHOWN_MS);
286
+ }
287
+
288
+ function onTick() {
289
+ tickTimer = null;
290
+ render(getModel === null ? board : getModel());
291
+ }
292
+
293
+ function scheduleTick() {
294
+ const needed = needsClock(board);
295
+ if (needed && tickTimer === null) {
296
+ tickTimer = setTimer(onTick, TICK_MS);
297
+ } else if (!needed && tickTimer !== null) {
298
+ clearTimer(tickTimer);
299
+ tickTimer = null;
300
+ }
301
+ }
302
+
303
+ function flushLive() {
304
+ liveTimer = null;
305
+ if (liveQueue.length === 0) return;
306
+ live.textContent = joinLive(liveQueue);
307
+ liveQueue = [];
308
+ lastLiveAt = now();
309
+ }
310
+
311
+ function scheduleLive() {
312
+ if (liveTimer !== null || liveQueue.length === 0) return;
313
+ const wait = lastLiveAt === null ? 0 : Math.max(0, lastLiveAt + LIVE_EVERY_MS - now());
314
+ if (wait === 0) flushLive();
315
+ else liveTimer = setTimer(flushLive, wait);
316
+ }
317
+
318
+ /** Draws the model of page/request-status.js: the chip, its count, the rows and the Recent group. */
319
+ function render(model) {
320
+ board = model;
321
+ const flight = model.inFlight ?? 0;
322
+ setText(count, String(flight));
323
+ setHidden(count, flight === 0);
324
+ setAttr(
325
+ toggle,
326
+ 'aria-label',
327
+ flight === 0 ? REQUEST_STATUS_COPY.chipNameNone : fill(REQUEST_STATUS_COPY.chipNameFlight, { n: flight }),
328
+ );
329
+ setAttr(toggle, 'data-inflight', String(flight > 0));
330
+ const listed = model.rows ?? [];
331
+ const kept = model.recent ?? [];
332
+ dropGone(new Set([...listed, ...kept].map((row) => row.id)));
333
+ drawRows(list, listed);
334
+ drawRows(recentBody, kept);
335
+ drawRecent();
336
+ drawChrome();
337
+ maybeAutoFold();
338
+ const sentences = takeLive();
339
+ if (sentences.length > 0) {
340
+ liveQueue.push(...sentences);
341
+ scheduleLive();
342
+ }
343
+ scheduleTick();
344
+ }
345
+
346
+ function open({ byKeyboard = false } = {}) {
347
+ if (!wantOpen) {
348
+ onOpen();
349
+ wantOpen = true;
350
+ }
351
+ // Opening by hand is the user's own act, whatever opened it before.
352
+ autoOpened = false;
353
+ drawChrome();
354
+ if (byKeyboard && showPanel()) focusFirst();
355
+ }
356
+
357
+ /** Folds the panel; with `focusChip` focus goes back to the chip (Esc does that), otherwise focus stays where it is. */
358
+ function close({ focusChip = false } = {}) {
359
+ if (wantOpen) {
360
+ wantOpen = false;
361
+ autoOpened = false;
362
+ drawChrome();
363
+ }
364
+ if (focusChip) toggle.focus();
365
+ }
366
+
367
+ /** The user sent a request: the panel opens by itself and focus stays where it is. */
368
+ function noteSend() {
369
+ if (wantOpen) return;
370
+ onOpen();
371
+ wantOpen = true;
372
+ autoOpened = true;
373
+ drawChrome();
374
+ }
375
+
376
+ function focusFirst() {
377
+ const first = list.querySelector('.rq-row');
378
+ if (first) first.focus();
379
+ else panel.focus();
380
+ }
381
+
382
+ // Keeps focus where it is on a press, so a click does not move it; a keyboard activation (a click event with no
383
+ // detail) that opens the panel moves focus to its first row.
384
+ toggle.addEventListener('mousedown', (event) => event.preventDefault());
385
+ toggle.addEventListener('click', (event) => {
386
+ if (wantOpen) close();
387
+ else open({ byKeyboard: event.detail === 0 });
388
+ });
389
+
390
+ recentHead.addEventListener('click', () => {
391
+ recentOpen = !recentOpen;
392
+ drawRecent();
393
+ });
394
+
395
+ // Esc folds the panel and gives focus back to the chip; it never reaches a view or the Ask box. With the panel
396
+ // folded it is left to the page.
397
+ const onKeydown = (event) => {
398
+ if (event.key !== 'Escape' || !wantOpen) return;
399
+ event.preventDefault();
400
+ event.stopPropagation();
401
+ close({ focusChip: true });
402
+ };
403
+ toggle.addEventListener('keydown', onKeydown);
404
+ panel.addEventListener('keydown', onKeydown);
405
+
406
+ panel.addEventListener('pointerenter', () => {
407
+ pointerInPanel = true;
408
+ });
409
+ panel.addEventListener('pointerleave', () => {
410
+ pointerInPanel = false;
411
+ maybeAutoFold();
412
+ });
413
+ // Focus inside makes the panel the user's; it never folds by itself under a focused row.
414
+ panel.addEventListener('focusin', (event) => {
415
+ if (panel.contains(event.target)) autoOpened = false;
416
+ });
417
+
418
+ return { render, noteSend, open, close, isOpen: () => showPanel() };
419
+ }