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.
- package/README.md +94 -6
- package/dist/ask.js +76 -15
- package/dist/mcp.js +198 -26
- package/dist/protocol.js +130 -2
- package/dist/reference.js +22 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +256 -7
- package/package.json +1 -1
- package/page/ask-view.js +64 -57
- package/page/ask.js +363 -188
- package/page/bar-model.js +43 -2
- package/page/change-timing.js +60 -4
- package/page/harmony.js +13 -0
- package/page/index.html +7 -4
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +196 -31
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +182 -10
- package/page/lights.js +300 -0
- package/page/main.js +2171 -188
- package/page/midi-clock.js +23 -4
- package/page/midi-out.js +17 -2
- package/page/mixer-groups.js +114 -0
- package/page/mixer-model.js +172 -29
- package/page/mixer-rewrite.js +30 -1
- package/page/mixer-view.js +289 -14
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +402 -0
- package/page/note-tag.js +141 -0
- package/page/page-rewrite.js +184 -0
- package/page/query-check.js +21 -2
- package/page/request-status-view.js +419 -0
- package/page/request-status.js +686 -0
- package/page/song-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +506 -0
- package/page/song-grid-view.js +710 -0
- package/page/song-prefs.js +100 -0
- package/page/structure-check.js +216 -0
- package/page/structure-view.js +38 -0
- package/page/structure.js +761 -0
- package/page/style.css +1218 -65
- package/page/takeover.js +20 -5
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +197 -3
- package/page/top-bar-view.js +18 -13
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +39 -9
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
- package/reference/transitions.md +13 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The one path through which the page itself changes the code: a Mixer move, a part launch, a grid
|
|
2
|
+
// gesture. It runs the guards in a fixed order, writes the new text and checks the write, makes one
|
|
3
|
+
// evaluation and, when that fails, sets the editor back and shows the failed-change banner through the
|
|
4
|
+
// same helper every other failure uses. Each caller keeps its own wording for a refusal and sends its own
|
|
5
|
+
// message to the server; this module only decides whether the text may change and whether it played.
|
|
6
|
+
// No DOM access at import time and every dependency injected, so node:test can import it with fakes.
|
|
7
|
+
|
|
8
|
+
import { parseTiming } from './change-timing.js';
|
|
9
|
+
|
|
10
|
+
// The reasons a change is refused before anything is written. A refusal never touches the editor, except
|
|
11
|
+
// write-mismatch, which sets the editor back to the text it had before the write.
|
|
12
|
+
export const PAGE_REWRITE_REFUSALS = Object.freeze(['not-played', 'waiting', 'closed', 'no-parser', 'dirty', 'write-mismatch']);
|
|
13
|
+
|
|
14
|
+
// The guards, in the order they run. Each one is on unless the request turns it off.
|
|
15
|
+
const GUARD_KEYS = Object.freeze(['played', 'waiting', 'socket', 'parser', 'dirty']);
|
|
16
|
+
|
|
17
|
+
const WRITES = Object.freeze(['dispatch', 'replace']);
|
|
18
|
+
|
|
19
|
+
const DEP_FUNCTIONS = Object.freeze([
|
|
20
|
+
'hasEvaluated',
|
|
21
|
+
'pending',
|
|
22
|
+
'playingCode',
|
|
23
|
+
'socketOpen',
|
|
24
|
+
'parserReady',
|
|
25
|
+
'evaluate',
|
|
26
|
+
'evalError',
|
|
27
|
+
'revertAndReport',
|
|
28
|
+
'markPageOrigin',
|
|
29
|
+
'whenFinished',
|
|
30
|
+
'markPageOriginAtTakeover',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function refused(reason) {
|
|
34
|
+
return Object.freeze({ status: 'refused', reason });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Builds the page's rewrite path around its dependencies:
|
|
39
|
+
* editor { code, setCode(text), dispatch(edits) }: the text the user sees; dispatch applies a list of
|
|
40
|
+
* { from, to, insert } edits in the coordinates of the text before the write, keeping selection and scroll,
|
|
41
|
+
* hasEvaluated() -> true once some code has played, pending() -> the waiting change or null,
|
|
42
|
+
* playingCode() -> the code that plays now, socketOpen() -> true while the server socket is open,
|
|
43
|
+
* parserReady() -> true when the code parser is loaded,
|
|
44
|
+
* evaluate({ code, timing, autostart, flash }) -> promise of the run ({ timing, deferred, ... }); flash true
|
|
45
|
+
* evaluates through the editor (it flashes), flash false through the repl directly,
|
|
46
|
+
* evalError() -> the evaluation's error or null,
|
|
47
|
+
* revertAndReport(error) -> { errorText, line }: sets the editor back to the committed code and shows the banner,
|
|
48
|
+
* markPageOrigin() -> the playing code now has no Claude evaluate id,
|
|
49
|
+
* whenFinished(record, fn) -> runs fn when the waiting change takes over; never when it is canceled (replaced or stopped),
|
|
50
|
+
* markPageOriginAtTakeover() -> the same mark, for the moment a waiting change takes over.
|
|
51
|
+
*/
|
|
52
|
+
export function createPageRewrite(deps) {
|
|
53
|
+
if (deps === null || typeof deps !== 'object') {
|
|
54
|
+
throw new Error('Moontide: createPageRewrite needs its dependencies.');
|
|
55
|
+
}
|
|
56
|
+
const editor = deps.editor;
|
|
57
|
+
if (
|
|
58
|
+
editor === null ||
|
|
59
|
+
typeof editor !== 'object' ||
|
|
60
|
+
typeof editor.setCode !== 'function' ||
|
|
61
|
+
typeof editor.dispatch !== 'function' ||
|
|
62
|
+
!('code' in editor)
|
|
63
|
+
) {
|
|
64
|
+
throw new Error('Moontide: createPageRewrite needs an editor with code, setCode and dispatch.');
|
|
65
|
+
}
|
|
66
|
+
for (const name of DEP_FUNCTIONS) {
|
|
67
|
+
if (typeof deps[name] !== 'function') {
|
|
68
|
+
throw new Error(`Moontide: createPageRewrite needs the function ${name}.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readGuards(guards) {
|
|
73
|
+
const on = {};
|
|
74
|
+
for (const key of GUARD_KEYS) on[key] = true;
|
|
75
|
+
if (guards === undefined) return on;
|
|
76
|
+
if (guards === null || typeof guards !== 'object' || Array.isArray(guards)) {
|
|
77
|
+
throw new Error('Moontide: the guards of a page rewrite are an object.');
|
|
78
|
+
}
|
|
79
|
+
for (const [key, value] of Object.entries(guards)) {
|
|
80
|
+
if (!GUARD_KEYS.includes(key)) {
|
|
81
|
+
throw new Error(`Moontide: unknown guard '${key}' in a page rewrite (expected one of ${GUARD_KEYS.join(', ')}).`);
|
|
82
|
+
}
|
|
83
|
+
if (typeof value !== 'boolean') {
|
|
84
|
+
throw new Error(`Moontide: the guard '${key}' of a page rewrite is true or false.`);
|
|
85
|
+
}
|
|
86
|
+
on[key] = value;
|
|
87
|
+
}
|
|
88
|
+
return on;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Applies one change to the code. See the request fields below; resolves one of
|
|
93
|
+
* { status: 'played', code, run, edits } the text that was evaluated; run.deferred is the waiting change
|
|
94
|
+
* when the change waits for its line, else null,
|
|
95
|
+
* { status: 'unchanged', code } the build left the text as it was and the request did not ask to evaluate it,
|
|
96
|
+
* { status: 'refused', reason } one of PAGE_REWRITE_REFUSALS; nothing was written,
|
|
97
|
+
* { status: 'failed', errorText, line } the evaluation failed; the editor is already set back and the banner shown.
|
|
98
|
+
*
|
|
99
|
+
* Request:
|
|
100
|
+
* build(base) -> { code, edits }: the new text; edits is a list of { from, to, insert } in the coordinates of
|
|
101
|
+
* base for write 'dispatch' and may be null for write 'replace'. An error it throws reaches the caller as it is.
|
|
102
|
+
* base: the text the build starts from and the dirty guard compares with the editor (default: the playing code).
|
|
103
|
+
* timing: a parsed timing (default: at once). autostart: only used when flash is false (default true).
|
|
104
|
+
* flash: evaluate through the editor, which flashes (default false).
|
|
105
|
+
* write: 'dispatch' (default) changes the text by edits and checks the result; 'replace' sets the whole text.
|
|
106
|
+
* guards: any of played, waiting, socket, parser, dirty set to false to leave that guard out.
|
|
107
|
+
* evaluateUnchanged: also evaluate when the build left the text as it was (default false).
|
|
108
|
+
*/
|
|
109
|
+
async function apply(request) {
|
|
110
|
+
if (request === null || typeof request !== 'object' || typeof request.build !== 'function') {
|
|
111
|
+
throw new Error('Moontide: a page rewrite needs a build function.');
|
|
112
|
+
}
|
|
113
|
+
const write = request.write === undefined ? 'dispatch' : request.write;
|
|
114
|
+
if (!WRITES.includes(write)) {
|
|
115
|
+
throw new Error(`Moontide: unknown write '${String(request.write)}' in a page rewrite (expected one of ${WRITES.join(', ')}).`);
|
|
116
|
+
}
|
|
117
|
+
const guards = readGuards(request.guards);
|
|
118
|
+
const timing = request.timing === undefined ? parseTiming('now') : request.timing;
|
|
119
|
+
const autostart = request.autostart === undefined ? true : request.autostart;
|
|
120
|
+
const flash = request.flash === true;
|
|
121
|
+
const evaluateUnchanged = request.evaluateUnchanged === true;
|
|
122
|
+
|
|
123
|
+
if (guards.played && !deps.hasEvaluated()) return refused('not-played');
|
|
124
|
+
if (guards.waiting && deps.pending() !== null) return refused('waiting');
|
|
125
|
+
if (guards.socket && !deps.socketOpen()) return refused('closed');
|
|
126
|
+
if (guards.parser && !deps.parserReady()) return refused('no-parser');
|
|
127
|
+
const base = request.base === undefined ? deps.playingCode() : request.base;
|
|
128
|
+
if (guards.dirty && editor.code !== base) return refused('dirty');
|
|
129
|
+
|
|
130
|
+
const built = request.build(base);
|
|
131
|
+
if (built === null || typeof built !== 'object' || typeof built.code !== 'string') {
|
|
132
|
+
throw new Error('Moontide: the build of a page rewrite must return { code, edits }.');
|
|
133
|
+
}
|
|
134
|
+
const code = built.code;
|
|
135
|
+
const edits = Array.isArray(built.edits) ? built.edits : null;
|
|
136
|
+
if (code === base && !evaluateUnchanged) {
|
|
137
|
+
return Object.freeze({ status: 'unchanged', code });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (write === 'dispatch') {
|
|
141
|
+
if (edits === null && code !== base) {
|
|
142
|
+
throw new Error('Moontide: a page rewrite that writes by edits needs an edit list from its build.');
|
|
143
|
+
}
|
|
144
|
+
if (edits !== null && edits.length > 0) editor.dispatch(edits);
|
|
145
|
+
if (editor.code !== code) {
|
|
146
|
+
editor.setCode(base);
|
|
147
|
+
return refused('write-mismatch');
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
editor.setCode(code);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let run;
|
|
154
|
+
try {
|
|
155
|
+
run = await deps.evaluate({ code, timing, autostart, flash });
|
|
156
|
+
} catch (err) {
|
|
157
|
+
// The evaluation itself threw (not a program error, which is read from evalError below): nothing is armed, so the
|
|
158
|
+
// text written above would sit in the editor as typing that was never evaluated. It is taken back, unless the
|
|
159
|
+
// person typed over it meanwhile; the error goes on to the caller, marked `restored` when the editor holds the base
|
|
160
|
+
// text again, so the caller can say the code is as it was.
|
|
161
|
+
if (code !== base && editor.code === code) editor.setCode(base);
|
|
162
|
+
if (editor.code === base && err !== null && typeof err === 'object' && Object.isExtensible(err)) err.restored = true;
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
if (run === null || typeof run !== 'object') {
|
|
166
|
+
throw new Error('Moontide: the evaluation of a page rewrite must give back its run.');
|
|
167
|
+
}
|
|
168
|
+
const evalError = deps.evalError();
|
|
169
|
+
if (evalError) {
|
|
170
|
+
const { errorText, line } = deps.revertAndReport(evalError);
|
|
171
|
+
return Object.freeze({ status: 'failed', errorText, line });
|
|
172
|
+
}
|
|
173
|
+
// A change the page makes by itself has no Claude evaluate id: at once when it plays at once, otherwise at
|
|
174
|
+
// the moment it takes over (the old code, and its id, keep playing until then).
|
|
175
|
+
if (run.deferred === null || run.deferred === undefined) {
|
|
176
|
+
deps.markPageOrigin();
|
|
177
|
+
} else {
|
|
178
|
+
deps.whenFinished(run.deferred, deps.markPageOriginAtTakeover);
|
|
179
|
+
}
|
|
180
|
+
return Object.freeze({ status: 'played', code, run, edits });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return Object.freeze({ apply });
|
|
184
|
+
}
|
package/page/query-check.js
CHANGED
|
@@ -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
|
+
}
|