@michelj/context-guard 0.4.4 → 0.6.2

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 (101) hide show
  1. package/Coordinator.md +88 -0
  2. package/Executor.md +53 -0
  3. package/README.md +72 -102
  4. package/README.zh-CN.md +72 -102
  5. package/SKILL.md +26 -33
  6. package/THIRD_PARTY_NOTICES.md +47 -0
  7. package/Tester.md +53 -0
  8. package/bin/build-runtime.mjs +96 -0
  9. package/bin/context-guard-skill.js +287 -69
  10. package/bin/postinstall.js +1 -1
  11. package/hooks.json +80 -4
  12. package/licenses/JSONParse-MIT.txt +24 -0
  13. package/licenses/Marked-MIT.txt +44 -0
  14. package/licenses/Portless-Apache-2.0.txt +201 -0
  15. package/package.json +31 -5
  16. package/prototype/LICENSES/Marked-MIT.txt +44 -0
  17. package/prototype/LICENSES/Ready-redistribution.txt +14 -0
  18. package/prototype/attachments.mjs +75 -0
  19. package/prototype/coordinator-markdown.mjs +283 -0
  20. package/prototype/coordinator-working-blot.mjs +124 -0
  21. package/prototype/vendor/marked.mjs +2189 -0
  22. package/prototype/workbench-app.js +5197 -0
  23. package/prototype/workbench-data.js +33 -0
  24. package/prototype/workbench-sync.mjs +898 -0
  25. package/prototype/workbench.css +1050 -0
  26. package/prototype/workbench.html +139 -4861
  27. package/prototype/working-blot-atlas.png +0 -0
  28. package/references/agent-handoff.md +40 -0
  29. package/references/claude-runtime.md +120 -0
  30. package/references/cloud-sync-interface.md +66 -0
  31. package/references/design-current.md +14 -0
  32. package/references/map-mount.md +41 -0
  33. package/references/map-read.md +50 -0
  34. package/references/memory-definition.md +120 -0
  35. package/references/memory-filesystem-v2/Bug.en.md +162 -0
  36. package/references/memory-filesystem-v2/Bug.md +162 -0
  37. package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
  38. package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
  39. package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
  40. package/references/memory-filesystem-v2/Idea.en.md +36 -0
  41. package/references/memory-filesystem-v2/Idea.md +36 -0
  42. package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
  43. package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
  44. package/references/memory-filesystem-v2/README.md +60 -0
  45. package/references/memory-filesystem-v2/Todo.en.md +137 -0
  46. package/references/memory-filesystem-v2/Todo.md +137 -0
  47. package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
  48. package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
  49. package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
  50. package/references/named-workbench.md +124 -0
  51. package/references/plan-review.md +12 -0
  52. package/references/server-memory.md +276 -0
  53. package/references/test-check.md +7 -0
  54. package/references/user-reply.md +38 -0
  55. package/references/workbench-interface.md +531 -0
  56. package/roles.md +13 -0
  57. package/scripts/context_guard.py +1163 -321
  58. package/scripts/context_guard_hook.py +1864 -63
  59. package/scripts/map_owns.py +68 -138
  60. package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
  61. package/scripts/shared/filesystem-v2.mjs +430 -0
  62. package/scripts/shared/io.mjs +117 -0
  63. package/scripts/shared/map-model.mjs +506 -0
  64. package/scripts/shared/memory-schema.mjs +13 -0
  65. package/scripts/shared/protocol-blobs.mjs +112 -0
  66. package/scripts/shared/protocol-map.mjs +146 -0
  67. package/scripts/shared/protocol-snapshots.mjs +84 -0
  68. package/scripts/shared/protocol-store.mjs +624 -0
  69. package/scripts/shared/protocol-workflow.mjs +226 -0
  70. package/scripts/shared/protocol.mjs +125 -0
  71. package/scripts/shared/vendor/jsonparse.cjs +413 -0
  72. package/scripts/workbench/access.mjs +496 -0
  73. package/scripts/workbench/attachments.mjs +92 -0
  74. package/scripts/workbench/browser-login.mjs +78 -0
  75. package/scripts/workbench/claude-runtime.mjs +372 -0
  76. package/scripts/workbench/cli.mjs +980 -0
  77. package/scripts/workbench/device-heartbeat.mjs +72 -0
  78. package/scripts/workbench/hook-status.mjs +38 -0
  79. package/scripts/workbench/inbox.mjs +155 -0
  80. package/scripts/workbench/journal.mjs +56 -0
  81. package/scripts/workbench/memory-merge.mjs +65 -0
  82. package/scripts/workbench/memory.mjs +252 -0
  83. package/scripts/workbench/named-proxy.mjs +108 -0
  84. package/scripts/workbench/named.mjs +152 -0
  85. package/scripts/workbench/portless-routes.mjs +51 -0
  86. package/scripts/workbench/project.mjs +327 -0
  87. package/scripts/workbench/projections.mjs +68 -0
  88. package/scripts/workbench/protocol-client.mjs +165 -0
  89. package/scripts/workbench/protocol-delivery.mjs +133 -0
  90. package/scripts/workbench/protocol-device.mjs +316 -0
  91. package/scripts/workbench/protocol-events.mjs +53 -0
  92. package/scripts/workbench/protocol-repository.mjs +58 -0
  93. package/scripts/workbench/reconcile.mjs +244 -0
  94. package/scripts/workbench/registry.mjs +111 -0
  95. package/scripts/workbench/runtime.mjs +54 -0
  96. package/scripts/workbench/server.mjs +1171 -0
  97. package/scripts/workbench/store.mjs +243 -0
  98. package/scripts/workbench/sync-coordinator.mjs +518 -0
  99. package/scripts/workbench/sync.mjs +86 -0
  100. package/references/bug-record-template.md +0 -37
  101. package/references/context-template.md +0 -19
@@ -0,0 +1,283 @@
1
+ import { lexer } from './vendor/marked.mjs';
2
+
3
+ // Ready-style reveal: wait for a complete Markdown block before painting it.
4
+ // The returned offset follows a paragraph, heading, list item, or fence boundary.
5
+ export function nextRevealSegmentEnd(source, shown, done = false) {
6
+ const text = String(source || '');
7
+ if (shown >= text.length) return shown;
8
+ const openFence = (text.slice(0, shown).match(/```/g) || []).length % 2;
9
+ if (openFence) return shown;
10
+ let start = shown;
11
+ while (text[start] === '\n') start++;
12
+ if (start >= text.length) return done ? text.length : shown;
13
+ const firstNewline = text.indexOf('\n', start);
14
+ const firstLine = text.slice(start, firstNewline < 0 ? undefined : firstNewline);
15
+ const nextLineEnd = from => text.indexOf('\n', from);
16
+ if (/^\s*```/.test(firstLine)) {
17
+ const close = text.indexOf('```', start + firstLine.indexOf('```') + 3);
18
+ if (close < 0) return done ? text.length : shown;
19
+ const end = close + 3;
20
+ return text[end] === '\n' ? end + 1 : done ? end : shown;
21
+ }
22
+ if (/^#{1,6}\s/.test(firstLine)) return firstNewline < 0 ? done ? text.length : shown : firstNewline + 1;
23
+ const listIndent = line => {
24
+ const match = /^( *)(?:[-*+]|\d+[.)])\s/.exec(line);
25
+ return match && match[1].length < 4 ? match[1].length : null;
26
+ };
27
+ const indent = listIndent(firstLine);
28
+ if (indent !== null) {
29
+ if (firstNewline < 0) return done ? text.length : shown;
30
+ let cursor = firstNewline + 1;
31
+ while (cursor < text.length) {
32
+ const newline = nextLineEnd(cursor);
33
+ const line = text.slice(cursor, newline < 0 ? undefined : newline);
34
+ if (!line.trim()) return newline < 0 ? done ? text.length : shown : newline + 1;
35
+ const nextIndent = listIndent(line);
36
+ if ((nextIndent !== null && nextIndent <= indent) || /^#{1,6}\s|^\s*```/.test(line)) return cursor;
37
+ if (newline < 0) return done ? text.length : shown;
38
+ cursor = newline + 1;
39
+ }
40
+ return done ? text.length : shown;
41
+ }
42
+ let cursor = start;
43
+ while (cursor < text.length) {
44
+ const newline = nextLineEnd(cursor);
45
+ if (newline < 0) return done ? text.length : shown;
46
+ const after = newline + 1;
47
+ if (text[after] === '\n') return after + 1;
48
+ if (after >= text.length) return done ? text.length : shown;
49
+ const nextNewline = nextLineEnd(after);
50
+ const nextLine = text.slice(after, nextNewline < 0 ? undefined : nextNewline);
51
+ if (/^#{1,6}\s|^\s*```/.test(nextLine) || listIndent(nextLine) !== null) return after;
52
+ cursor = after;
53
+ }
54
+ return done ? text.length : shown;
55
+ }
56
+
57
+ // Keep long plain-language replies readable even when the model returns one
58
+ // giant paragraph. Split at sentence punctuation (or a bounded fallback),
59
+ // while leaving Markdown blocks and fenced code untouched.
60
+ function paragraphize(source, limit = 60) {
61
+ const lines = String(source || '').replace(/\r\n?/g, '\n').split('\n');
62
+ const output = [];
63
+ let fence = false;
64
+ for (const line of lines) {
65
+ if (/^\s*```/.test(line)) { fence = !fence; output.push(line); continue; }
66
+ // Inline code is semantic Markdown, not plain prose. A synthetic paragraph
67
+ // boundary inside a backtick span turns the remaining delimiters into
68
+ // visible text, so preserve the model-authored paragraph and let CSS wrap it.
69
+ if (fence || line.length <= limit || line.includes('`') || /^\s*(?:#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|\|)/.test(line)) { output.push(line); continue; }
70
+ let rest = line;
71
+ while (rest.length > limit) {
72
+ const boundary = Math.min(rest.length - 1, limit + 20);
73
+ let cut = -1;
74
+ for (let i = boundary; i >= Math.max(0, limit - 20); i--) {
75
+ if (/[。!?;.!?;::]/.test(rest[i])) { cut = i + 1; break; }
76
+ }
77
+ if (cut < 1) cut = limit;
78
+ output.push(rest.slice(0, cut)); output.push(''); rest = rest.slice(cut).trimStart();
79
+ }
80
+ output.push(rest);
81
+ }
82
+ return output.join('\n');
83
+ }
84
+
85
+ // Older Coordinator turns sometimes wrote a confirmation heading followed by
86
+ // a numbered list instead of calling ask_user. Keep those turns actionable by
87
+ // recognizing only an explicit question/confirmation list; ordinary numbered
88
+ // Markdown lists remain unchanged.
89
+ export function legacyQuestionList(source) {
90
+ const text = String(source || '').replace(/\r\n?/g, '\n');
91
+ const heading = text.match(/(^|\n)([^\n]*(?:问题|确认)[^\n]*(?:如下|分别|需要)[^\n]*:?)[ \t]*\n[ \t]*\n/m);
92
+ if (!heading) return null;
93
+ const start = heading.index + heading[0].lastIndexOf('\n') + 1;
94
+ const list = text.slice(start);
95
+ const matches = [...list.matchAll(/^\s*\d+[.)、]\s+(.+?)(?=\n\s*\d+[.)、]\s+|\n\s*\n|$)/gms)];
96
+ if (matches.length < 2) return null;
97
+ const first = start + matches[0].index;
98
+ const last = start + matches.at(-1).index + matches.at(-1)[0].length;
99
+ const before = text.slice(0, first).trim();
100
+ const after = text.slice(last).trim();
101
+ return { before, after, items: matches.map((match, index) => ({ id: `legacy-question-${index + 1}`, text: match[1].trim() })) };
102
+ }
103
+
104
+ // Build a restricted DOM from Markdown tokens. Never insert model-provided HTML,
105
+ // fetch remote images, or attach an unvalidated URL to an active element.
106
+ // Presentation-only compatibility for old assistant lists. Keep user messages,
107
+ // code, links and the persisted transcript verbatim; never infer a task identity.
108
+ function readableWorkItemLists(source) {
109
+ let fence = null;
110
+ const id = '(?:B\\d{6,}|TD-[A-Za-z0-9]{8,})';
111
+ const label = '(?:(?:Bug|TODO|任务|缺陷)\\s+)?' + id;
112
+ const prefix = new RegExp('^( {0,3}(?:[-*+]|\\d+[.)])\\s+)(?:\\*\\*' + label + '\\*\\*|' + label + ')\\s*[::||]\\s*(?=\\S)', 'i');
113
+ return String(source || '').split('\n').map(line => {
114
+ const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
115
+ if (marker) {
116
+ if (!fence) fence = marker;
117
+ else if (marker[0] === fence[0] && marker.length >= fence.length && /^\s*(`+|~+)\s*$/.test(line)) fence = null;
118
+ return line;
119
+ }
120
+ return fence ? line : line.replace(prefix, '$1');
121
+ }).join('\n');
122
+ }
123
+ export function markdownFragment(text, doc = document, { readableWorkItems = false } = {}) {
124
+ const root = doc.createDocumentFragment();
125
+ const append = (parent, tokens, depth = 0) => {
126
+ for (const token of tokens || []) {
127
+ const element = name => doc.createElement(name);
128
+ let node;
129
+ if (depth > 40) { parent.append(doc.createTextNode(token.raw || token.text || '')); continue; }
130
+ switch (token.type) {
131
+ case 'space': case 'def': continue;
132
+ case 'paragraph': node = element('p'); break;
133
+ case 'heading': node = element(`h${Math.min(6, Math.max(2, token.depth + 1))}`); break;
134
+ case 'strong': node = element('strong'); break;
135
+ case 'em': node = element('em'); break;
136
+ case 'del': node = element('del'); break;
137
+ case 'blockquote': node = element('blockquote'); break;
138
+ case 'br': parent.append(element('br')); continue;
139
+ case 'hr': parent.append(element('hr')); continue;
140
+ case 'code': {
141
+ node = element('pre'); const code = element('code'); code.textContent = token.text; node.append(code); parent.append(node); continue;
142
+ }
143
+ case 'codespan': node = element('code'); node.textContent = token.text; parent.append(node); continue;
144
+ case 'link': {
145
+ let url; try { url = new URL(token.href, doc.baseURI); } catch {}
146
+ node = element(url && ['http:', 'https:', 'mailto:'].includes(url.protocol) ? 'a' : 'span');
147
+ if (node.tagName === 'A') { node.href = url.href; node.rel = 'noopener noreferrer'; node.target = '_blank'; }
148
+ break;
149
+ }
150
+ case 'list': {
151
+ node = element(token.ordered ? 'ol' : 'ul');
152
+ if (token.ordered && Number.isSafeInteger(token.start) && token.start > 0) node.start = token.start;
153
+ for (const item of token.items) {
154
+ const li = element('li');
155
+ if (item.task) { const box = element('input'); box.type = 'checkbox'; box.disabled = true; box.checked = item.checked; li.append(box); }
156
+ append(li, item.tokens, depth + 1); node.append(li);
157
+ }
158
+ parent.append(node); continue;
159
+ }
160
+ case 'table': {
161
+ const wrap = element('div'); wrap.className = 'coordinator-table'; node = element('table');
162
+ const head = element('thead'), body = element('tbody');
163
+ const row = (cells, tag) => { const tr = element('tr'); for (const cell of cells) { const td = element(tag); append(td, cell.tokens, depth + 1); tr.append(td); } return tr; };
164
+ head.append(row(token.header, 'th')); for (const cells of token.rows) body.append(row(cells, 'td'));
165
+ node.append(head, body); wrap.append(node); parent.append(wrap); continue;
166
+ }
167
+ case 'text': if (token.tokens) { append(parent, token.tokens, depth + 1); continue; } // fall through
168
+ default: parent.append(doc.createTextNode(token.text || token.raw || '')); continue;
169
+ }
170
+ append(node, token.tokens || [{ type: 'text', text: token.text || '' }], depth + 1); parent.append(node);
171
+ }
172
+ };
173
+ const source = paragraphize(readableWorkItems ? readableWorkItemLists(text) : String(text || ''));
174
+ try { if (source.length > 64000) throw new Error('Large response'); append(root, lexer(source, { gfm: true })); }
175
+ catch { root.replaceChildren(doc.createTextNode(source)); }
176
+ return root;
177
+ }
178
+
179
+ export function conversationFragments(messages, doc = document, { nodes = [], onNode, onConversation, onAnswer, attachmentUrl, questionDrafts = new Map(), canAnswer = false, activeTurnId, running = false } = {}) {
180
+ const body = doc.createDocumentFragment();
181
+ const answerComposer = (question, draft, answerValue) => {
182
+ const compose = doc.createElement('div'); compose.className = 'coordinator-answer-compose';
183
+ const input = doc.createElement('textarea'); input.rows = 2; input.maxLength = 6000;
184
+ input.placeholder = '在此回答,或补充说明…'; input.setAttribute('aria-label', '回答:' + question.text); input.value = draft.text;
185
+ const send = doc.createElement('button'); send.type = 'button'; send.textContent = '发送'; send.setAttribute('aria-label', '发送“' + question.text + '”');
186
+ const update = () => { send.disabled = !canAnswer || !answerValue(); };
187
+ const commit = () => { const answer = answerValue(); if (canAnswer && answer) onAnswer?.(question, answer); };
188
+ input.addEventListener('input', () => { draft.text = input.value; update(); });
189
+ input.addEventListener('keydown', event => {
190
+ if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return;
191
+ event.preventDefault(); if (!send.disabled) commit();
192
+ });
193
+ send.addEventListener('click', commit); update(); compose.append(input, send);
194
+ return { compose, update, commit };
195
+ };
196
+ for (const message of messages) {
197
+ const workflow = message.role === 'user' && (message.text || '').startsWith('[服务器工作流事件,不是新的用户授权]\n');
198
+ const visibleAction = message.actions?.some(action => !['node-navigation', 'node-tour', 'node-read'].includes(action.kind));
199
+ if (workflow || (!message.text && !message.attachments?.length && !message.questions?.length && !visibleAction)) continue;
200
+ const row = doc.createElement('article'); row.className = `coordinator-message ${message.role === 'assistant' ? 'assistant' : 'user'}`;
201
+ const content = doc.createElement('div'); content.className = 'coordinator-markdown';
202
+ if (message.partial) {
203
+ const notice = doc.createElement('p'); notice.className = 'coordinator-question-status';
204
+ notice.textContent = '部分回复(未完成)'; content.append(notice);
205
+ }
206
+ const cleanText = String(message.text || '').replace(/^\[实验:模拟人工输入\]\n/, '');
207
+ const legacy = !message.questions?.length && message.role === 'assistant' ? legacyQuestionList(cleanText) : null;
208
+ const lead = message.questionOnly ? '' : cleanText;
209
+ const replyOptions = { readableWorkItems: message.role === 'assistant' };
210
+ if (lead && !legacy) content.append(markdownFragment(lead, doc, replyOptions));
211
+ for (const attachment of message.attachments || []) {
212
+ const url = attachmentUrl?.(attachment);
213
+ if (typeof url !== 'string' || !url.startsWith('/api/workbench/')) continue;
214
+ const link = doc.createElement('a'); link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer';
215
+ link.textContent = attachment.filename || '附件';
216
+ if (['image/png', 'image/jpeg', 'image/webp'].includes(attachment.mimeType)) {
217
+ const image = doc.createElement('img'); image.src = url; image.alt = attachment.filename || '截图';
218
+ image.style.maxWidth = '100%'; image.style.maxHeight = '320px'; image.loading = 'lazy'; link.append(image);
219
+ }
220
+ const line = doc.createElement('p'); line.append(link); content.append(line);
221
+ }
222
+ if (legacy?.before) content.append(markdownFragment(legacy.before, doc, replyOptions));
223
+ for (const question of legacy?.items || []) {
224
+ const card = doc.createElement('section'); card.className = 'coordinator-question coordinator-legacy-question'; card.dataset.questionId = question.id;
225
+ const title = doc.createElement('div'); title.append(markdownFragment(question.text, doc, replyOptions)); card.append(title);
226
+ const draft = questionDrafts.get(question.id) || { option: '', text: '' }; questionDrafts.set(question.id, draft);
227
+ const answerQuestion = { ...question, legacy: true };
228
+ card.append(answerComposer(answerQuestion, draft, () => draft.text.trim()).compose); content.append(card);
229
+ }
230
+ if (legacy?.after) content.append(markdownFragment(legacy.after, doc, replyOptions));
231
+ for (const question of message.questions || []) {
232
+ const card = doc.createElement('section'); card.className = 'coordinator-question'; card.dataset.questionId = question.id;
233
+ const prompt = String(question.text || '').trim();
234
+ if (!prompt || !lead.trimEnd().endsWith(prompt)) {
235
+ const title = doc.createElement('div'); title.append(markdownFragment(question.text, doc, replyOptions)); card.append(title);
236
+ }
237
+ const activity = doc.createElement('p'); activity.className = 'coordinator-question-status'; activity.setAttribute('role', 'status');
238
+ activity.textContent = '正在回复…'; activity.hidden = !(running && question.answer?.requestId === activeTurnId);
239
+ if (question.answer) {
240
+ const answer = doc.createElement('p'); answer.className = 'coordinator-answer'; answer.textContent = '你的回答:' + question.answer.text; card.append(answer);
241
+ } else {
242
+ const draft = questionDrafts.get(question.id) || { option: '', text: '' }; questionDrafts.set(question.id, draft);
243
+ const choices = doc.createElement('div'); choices.className = 'coordinator-choices';
244
+ const optionButtons = [];
245
+ const choiceItems = [
246
+ ...(question.nodes || []).slice(0, 3).map(node => ({ label: node.title, nodeId: node.id })),
247
+ ...(question.options || []).map(label => ({ label })),
248
+ ];
249
+ const answerValue = () => [draft.option, draft.text.trim()].filter(Boolean).join('\n\n');
250
+ const composer = answerComposer(question, draft, answerValue);
251
+ for (const option of choiceItems) {
252
+ const button = doc.createElement('button'); button.type = 'button'; button.textContent = option.label; button.disabled = !canAnswer;
253
+ if (option.nodeId) { button.classList.add('coordinator-node-link'); button.dataset.nodeId = option.nodeId; }
254
+ button.setAttribute('aria-pressed', String(draft.option === option.label)); optionButtons.push(button);
255
+ button.addEventListener('click', () => {
256
+ if (draft.option === option.label) { composer.commit(); return; }
257
+ draft.option = option.label;
258
+ for (const item of optionButtons) item.setAttribute('aria-pressed', String(item.textContent === draft.option));
259
+ composer.update();
260
+ }); choices.append(button);
261
+ }
262
+ card.append(choices, composer.compose);
263
+ }
264
+ card.append(activity); content.append(card);
265
+ }
266
+ for (const action of message.actions || []) {
267
+ if (action.kind === 'node-navigation' || action.kind === 'node-tour' || action.kind === 'node-read') continue;
268
+ const actions = doc.createElement('div'); actions.className = 'coordinator-actions';
269
+ if (action.message && action.message !== cleanText) { const label = doc.createElement('p'); label.textContent = action.message; actions.append(label); }
270
+ for (const node of (action.nodes || (action.node ? [action.node] : [])).slice(0, 3)) {
271
+ const button = doc.createElement('button'); button.type = 'button'; button.className = 'coordinator-node-link';
272
+ button.textContent = node.title; button.dataset.nodeId = node.id; button.addEventListener('click', () => onNode?.(node.id)); actions.append(button);
273
+ }
274
+ if (action.kind === 'conversation-mounted' && action.conversationId) {
275
+ const button = doc.createElement('button'); button.type = 'button'; button.textContent = '继续这个事项';
276
+ button.addEventListener('click', () => onConversation?.(action.conversationId)); actions.append(button);
277
+ }
278
+ content.append(actions);
279
+ }
280
+ row.append(content); body.append(row);
281
+ }
282
+ return { body };
283
+ }
@@ -0,0 +1,124 @@
1
+ // Adapted from Ready@d0771a1c8dc8086f49fbe924c2b5cbb621d0fd8b:
2
+ // platform/frontend/src/components/WorkingBlot.tsx and its atlas asset.
3
+ // Keep its original 48-frame, 2-second ink motion and 16-second hue cycle.
4
+ const FRAME_MS = [
5
+ 40, 40, 50, 40, 40, 40, 40, 40, 50, 40, 40, 40, 40, 40, 50, 40, 40, 40, 40, 40,
6
+ 50, 40, 40, 40, 40, 40, 50, 40, 40, 40, 40, 40, 50, 40, 40, 40, 40, 40, 50, 40,
7
+ 40, 40, 40, 40, 50, 40, 40, 40,
8
+ ];
9
+ const TILE = 160;
10
+ const COLS = 8;
11
+ const HUE_PERIOD_MS = 16_000;
12
+ const HSV_S = 0.84;
13
+ const HSV_V = 0.74;
14
+ const motionMs = FRAME_MS.reduce((total, duration) => total + duration, 0);
15
+ const cumulative = [];
16
+ let elapsed = 0;
17
+ for (const duration of FRAME_MS) cumulative.push(elapsed += duration);
18
+ let atlasPromise;
19
+ let alphaFrames;
20
+
21
+ function frameAt(elapsed) {
22
+ const time = ((elapsed % motionMs) + motionMs) % motionMs;
23
+ return cumulative.findIndex(end => time < end);
24
+ }
25
+
26
+ function hsvToRgb(hue, saturation, value) {
27
+ const channel = number => {
28
+ const key = (number + hue / 60) % 6;
29
+ return value - value * saturation * Math.max(Math.min(key, 4 - key, 1), 0);
30
+ };
31
+ return [Math.round(channel(5) * 255), Math.round(channel(3) * 255), Math.round(channel(1) * 255)];
32
+ }
33
+
34
+ function loadFrames(doc) {
35
+ if (alphaFrames) return Promise.resolve(alphaFrames);
36
+ atlasPromise ||= new Promise((resolve, reject) => {
37
+ const image = new Image();
38
+ const source = new URL('./working-blot-atlas.png', import.meta.url);
39
+ let refreshed = false;
40
+ image.decoding = 'async';
41
+ image.onload = () => {
42
+ if (image.naturalWidth === COLS * TILE && image.naturalHeight === 6 * TILE) resolve(image);
43
+ else { atlasPromise = null; reject(new Error('Coordinator working animation has an invalid atlas')); }
44
+ };
45
+ image.onerror = () => {
46
+ // A tab can outlive a deployment and its content-versioned asset URL.
47
+ // The atlas layout is fixed; retry the authenticated current asset once.
48
+ if (!refreshed && /^\/assets\/[a-f0-9]{16}\//.test(source.pathname)) {
49
+ refreshed = true;
50
+ image.src = new URL(source.pathname.replace(/^\/assets\/[a-f0-9]{16}/, ''), source.origin).href;
51
+ return;
52
+ }
53
+ atlasPromise = null;
54
+ reject(new Error('Coordinator working animation could not load'));
55
+ };
56
+ image.src = source.href;
57
+ });
58
+ return atlasPromise.then(image => {
59
+ if (alphaFrames) return alphaFrames;
60
+ const scratch = doc.createElement('canvas');
61
+ scratch.width = scratch.height = TILE;
62
+ const context = scratch.getContext('2d', { willReadFrequently: true });
63
+ if (!context) return [];
64
+ alphaFrames = FRAME_MS.map((_, index) => {
65
+ context.clearRect(0, 0, TILE, TILE);
66
+ context.drawImage(image, (index % COLS) * TILE, Math.floor(index / COLS) * TILE, TILE, TILE, 0, 0, TILE, TILE);
67
+ const pixels = context.getImageData(0, 0, TILE, TILE).data;
68
+ const alpha = new Uint8Array(TILE * TILE);
69
+ for (let pixel = 0; pixel < alpha.length; pixel++) alpha[pixel] = pixels[pixel * 4 + 3];
70
+ return alpha;
71
+ });
72
+ return alphaFrames;
73
+ });
74
+ }
75
+
76
+ export function createCoordinatorWorkingBlot(doc = document, onError = () => {}) {
77
+ const canvas = doc.createElement('canvas');
78
+ canvas.className = 'coordinator-working-blot';
79
+ canvas.width = canvas.height = TILE;
80
+ canvas.setAttribute('aria-hidden', 'true');
81
+ const context = canvas.getContext('2d');
82
+ let active = false, generation = 0, frame = 0;
83
+ const pixels = context?.createImageData(TILE, TILE);
84
+
85
+ const paint = (frames, elapsed, reduced) => {
86
+ if (!context || !pixels) return;
87
+ const alpha = frames[reduced ? 0 : frameAt(elapsed)];
88
+ const [red, green, blue] = hsvToRgb(reduced ? 0 : ((elapsed / HUE_PERIOD_MS) % 1) * 360, HSV_S, HSV_V);
89
+ for (let pixel = 0; pixel < alpha.length; pixel++) {
90
+ const offset = pixel * 4;
91
+ pixels.data[offset] = red;
92
+ pixels.data[offset + 1] = green;
93
+ pixels.data[offset + 2] = blue;
94
+ pixels.data[offset + 3] = alpha[pixel];
95
+ }
96
+ context.putImageData(pixels, 0, 0);
97
+ };
98
+ const stop = () => {
99
+ active = false;
100
+ generation++;
101
+ cancelAnimationFrame(frame);
102
+ frame = 0;
103
+ };
104
+ const start = onFirstFrame => {
105
+ if (active || !context) return;
106
+ active = true;
107
+ const current = ++generation;
108
+ const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
109
+ void loadFrames(doc).then(frames => {
110
+ if (!active || current !== generation || !frames.length) return;
111
+ if (reduced) { paint(frames, 0, true); onFirstFrame?.(); return; }
112
+ const started = performance.now();
113
+ let first = true;
114
+ const tick = now => {
115
+ if (!active || current !== generation) return;
116
+ paint(frames, now - started, false);
117
+ if (first) { first = false; onFirstFrame?.(); }
118
+ frame = requestAnimationFrame(tick);
119
+ };
120
+ frame = requestAnimationFrame(tick);
121
+ }).catch(() => { if (active && current === generation) onError(); });
122
+ };
123
+ return { canvas, start, stop };
124
+ }