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.
@@ -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
+ }