@1agh/maude 0.30.0 → 0.32.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 (93) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +788 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +796 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +598 -0
  23. package/apps/studio/client/panels/ReadinessList.jsx +189 -0
  24. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  25. package/apps/studio/client/panels/acp-runtime.js +286 -0
  26. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  27. package/apps/studio/client/panels/git-grouping.js +86 -0
  28. package/apps/studio/client/styles/0-reset.css +4 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +625 -3
  30. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +821 -0
  32. package/apps/studio/client/styles/_index.css +2 -0
  33. package/apps/studio/client/tour/collab-tour.js +61 -0
  34. package/apps/studio/client/tour/overlay.jsx +11 -1
  35. package/apps/studio/client/whats-new.jsx +25 -10
  36. package/apps/studio/collab/registry.ts +13 -0
  37. package/apps/studio/collab/room.ts +36 -0
  38. package/apps/studio/cursors-overlay.tsx +17 -1
  39. package/apps/studio/dist/client.bundle.js +29097 -1534
  40. package/apps/studio/dist/comment-mount.js +4 -2
  41. package/apps/studio/dist/styles.css +5816 -1614
  42. package/apps/studio/git/endpoints.ts +338 -0
  43. package/apps/studio/git/service.ts +1334 -0
  44. package/apps/studio/git/watch.ts +97 -0
  45. package/apps/studio/github/endpoints.ts +358 -0
  46. package/apps/studio/github/service.ts +231 -0
  47. package/apps/studio/github/token.ts +53 -0
  48. package/apps/studio/hmr-broadcast.ts +9 -2
  49. package/apps/studio/http.ts +399 -1
  50. package/apps/studio/participants-chrome.tsx +69 -9
  51. package/apps/studio/paths.ts +12 -0
  52. package/apps/studio/readiness.ts +220 -0
  53. package/apps/studio/scaffold-design.ts +57 -0
  54. package/apps/studio/server.ts +65 -2
  55. package/apps/studio/sync/agent.ts +81 -1
  56. package/apps/studio/sync/codec.ts +24 -0
  57. package/apps/studio/sync/cold-start.ts +40 -0
  58. package/apps/studio/sync/hub-link.ts +137 -0
  59. package/apps/studio/test/acp-bridge.test.ts +127 -0
  60. package/apps/studio/test/acp-env.test.ts +65 -0
  61. package/apps/studio/test/acp-origin-gate.test.ts +95 -0
  62. package/apps/studio/test/acp-transcript.test.ts +112 -0
  63. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  64. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  65. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  66. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  67. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  68. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  69. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  70. package/apps/studio/test/editing-presence.test.ts +103 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  72. package/apps/studio/test/git-api.test.ts +0 -0
  73. package/apps/studio/test/git-branches.test.ts +106 -0
  74. package/apps/studio/test/git-grouping.test.ts +106 -0
  75. package/apps/studio/test/git-watch.test.ts +97 -0
  76. package/apps/studio/test/github-api.test.ts +465 -0
  77. package/apps/studio/test/hub-link.test.ts +69 -0
  78. package/apps/studio/test/participants-chrome.test.ts +36 -1
  79. package/apps/studio/test/readiness.test.ts +127 -0
  80. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  81. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  82. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  83. package/apps/studio/tool-palette.tsx +18 -9
  84. package/apps/studio/use-chrome-visibility.tsx +66 -0
  85. package/apps/studio/use-collab.tsx +414 -187
  86. package/apps/studio/whats-new.json +82 -0
  87. package/apps/studio/ws.ts +44 -1
  88. package/cli/commands/design.mjs +1 -0
  89. package/cli/lib/gitignore-block.mjs +16 -3
  90. package/cli/lib/gitignore-block.test.mjs +13 -1
  91. package/package.json +11 -9
  92. package/plugins/design/dependencies.json +17 -0
  93. package/plugins/design/templates/_shell.html +30 -8
@@ -0,0 +1,286 @@
1
+ // Phase 31 (DDR-123) — client glue between assistant-ui's local runtime and the
2
+ // dev-server ACP bridge (`/_ws/acp`). One persistent socket per ChatPanel mount;
3
+ // the bridge lazy-spawns the user's `claude` on the first prompt, so just opening
4
+ // the panel costs nothing. A custom `ChatModelAdapter` translates the bridge's
5
+ // JSON frames into the streamed assistant message parts assistant-ui renders.
6
+
7
+ const WS_PATH = '/_ws/acp';
8
+
9
+ /** Connection wrapper around the loopback `/_ws/acp` socket. */
10
+ export function createAcpConnection() {
11
+ let ws = null;
12
+ let openPromise = null;
13
+ let turnHandler = null; // the in-flight run()'s frame sink
14
+ const statusListeners = new Set();
15
+ const status = { available: null, reason: undefined, ready: false };
16
+
17
+ // Live "what's running now" — in-flight tool calls (a tool_call with no
18
+ // completed/failed tool_call_update yet). Surfaced as the activity bar so
19
+ // background work is visible at a glance, not only buried in the text.
20
+ const activityListeners = new Set();
21
+ const activeTools = new Map(); // toolCallId → { title, kind }
22
+
23
+ // Turn-busy signal — true while a prompt turn is in flight. Drives the
24
+ // menubar Assistant badge + the "finished" notification.
25
+ const busyListeners = new Set();
26
+ let busy = false;
27
+
28
+ function emitStatus() {
29
+ for (const fn of statusListeners) fn({ ...status });
30
+ }
31
+
32
+ function setBusy(next) {
33
+ if (busy === next) return;
34
+ busy = next;
35
+ for (const fn of busyListeners) fn(busy);
36
+ }
37
+
38
+ function emitActivity() {
39
+ const snapshot = [...activeTools.values()];
40
+ for (const fn of activityListeners) fn(snapshot);
41
+ }
42
+
43
+ function trackActivity(frame) {
44
+ if (frame.t === 'update') {
45
+ const u = frame.update;
46
+ if (u.sessionUpdate === 'tool_call') {
47
+ activeTools.set(u.toolCallId, { title: u.title || u.kind || 'tool', kind: u.kind });
48
+ emitActivity();
49
+ } else if (
50
+ u.sessionUpdate === 'tool_call_update' &&
51
+ (u.status === 'completed' || u.status === 'failed')
52
+ ) {
53
+ if (activeTools.delete(u.toolCallId)) emitActivity();
54
+ }
55
+ } else if (frame.t === 'turn-end' || frame.t === 'error') {
56
+ if (activeTools.size) {
57
+ activeTools.clear();
58
+ emitActivity();
59
+ }
60
+ }
61
+ }
62
+
63
+ function onFrame(frame) {
64
+ if (frame.t === 'ready') {
65
+ status.ready = true;
66
+ status.available = frame.available;
67
+ status.reason = frame.reason;
68
+ emitStatus();
69
+ return;
70
+ }
71
+ // Everything else belongs to the active prompt turn.
72
+ trackActivity(frame);
73
+ if (turnHandler) turnHandler(frame);
74
+ }
75
+
76
+ function ensureOpen() {
77
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
78
+ return openPromise;
79
+ }
80
+ openPromise = new Promise((resolve, reject) => {
81
+ const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
82
+ const sock = new WebSocket(`${scheme}://${location.host}${WS_PATH}`);
83
+ ws = sock;
84
+ sock.onopen = () => resolve();
85
+ sock.onerror = () => reject(new Error('Could not reach the Claude bridge.'));
86
+ sock.onclose = () => {
87
+ ws = null;
88
+ status.ready = false;
89
+ emitStatus();
90
+ };
91
+ sock.onmessage = (e) => {
92
+ try {
93
+ onFrame(JSON.parse(e.data));
94
+ } catch {
95
+ /* ignore malformed frame */
96
+ }
97
+ };
98
+ });
99
+ return openPromise;
100
+ }
101
+
102
+ return {
103
+ onStatus(fn) {
104
+ statusListeners.add(fn);
105
+ fn({ ...status });
106
+ return () => statusListeners.delete(fn);
107
+ },
108
+
109
+ onActivity(fn) {
110
+ activityListeners.add(fn);
111
+ fn([...activeTools.values()]);
112
+ return () => activityListeners.delete(fn);
113
+ },
114
+
115
+ /**
116
+ * Drive one prompt turn. Async-generates the bridge's `update` frames until
117
+ * `turn-end`; throws on `error`; sends `cancel` when `abortSignal` aborts.
118
+ */
119
+ async *prompt(text, chatId, abortSignal, model, effort) {
120
+ await ensureOpen();
121
+ const queue = [];
122
+ let wake = null;
123
+ let ended = false;
124
+ let failure = null;
125
+ turnHandler = (frame) => {
126
+ if (frame.t === 'turn-end') ended = true;
127
+ else if (frame.t === 'error') failure = frame.message || 'The Claude bridge errored.';
128
+ else queue.push(frame); // update / connected / permission
129
+ if (wake) {
130
+ const w = wake;
131
+ wake = null;
132
+ w();
133
+ }
134
+ };
135
+ const cancel = () => {
136
+ try {
137
+ ws?.send(JSON.stringify({ t: 'cancel' }));
138
+ } catch {
139
+ /* socket already gone */
140
+ }
141
+ };
142
+ abortSignal?.addEventListener('abort', cancel, { once: true });
143
+ setBusy(true);
144
+ try {
145
+ ws.send(
146
+ JSON.stringify({
147
+ t: 'prompt',
148
+ text,
149
+ chat: chatId || undefined,
150
+ model: model || undefined,
151
+ effort: effort || undefined,
152
+ })
153
+ );
154
+ for (;;) {
155
+ while (queue.length) yield queue.shift();
156
+ if (failure) throw new Error(failure);
157
+ if (ended) return;
158
+ await new Promise((r) => {
159
+ wake = r;
160
+ });
161
+ }
162
+ } finally {
163
+ turnHandler = null;
164
+ setBusy(false);
165
+ abortSignal?.removeEventListener('abort', cancel);
166
+ }
167
+ },
168
+
169
+ onBusy(fn) {
170
+ busyListeners.add(fn);
171
+ fn(busy);
172
+ return () => busyListeners.delete(fn);
173
+ },
174
+
175
+ get busy() {
176
+ return busy;
177
+ },
178
+
179
+ close() {
180
+ try {
181
+ ws?.close();
182
+ } catch {
183
+ /* already closed */
184
+ }
185
+ ws = null;
186
+ },
187
+ };
188
+ }
189
+
190
+ function lastUserText(messages) {
191
+ for (let i = messages.length - 1; i >= 0; i--) {
192
+ const m = messages[i];
193
+ if (m.role !== 'user') continue;
194
+ return (m.content || [])
195
+ .filter((p) => p.type === 'text')
196
+ .map((p) => p.text)
197
+ .join('')
198
+ .trim();
199
+ }
200
+ return '';
201
+ }
202
+
203
+ function safeJson(value) {
204
+ try {
205
+ return JSON.stringify(value ?? {});
206
+ } catch {
207
+ return '{}';
208
+ }
209
+ }
210
+
211
+ /**
212
+ * assistant-ui `ChatModelAdapter` over the ACP bridge. Streams text + tool-call
213
+ * parts, preserving the order in which the agent emits them. `available_commands_update`
214
+ * and `usage_update` are intentionally dropped (chrome noise, not chat content).
215
+ */
216
+ export function makeAcpAdapter(conn, getChatId, getModel, getEffort) {
217
+ return {
218
+ async *run({ messages, abortSignal }) {
219
+ const text = lastUserText(messages);
220
+ if (!text) return;
221
+
222
+ const parts = [];
223
+ const toolIndex = new Map();
224
+
225
+ for await (const frame of conn.prompt(
226
+ text,
227
+ getChatId(),
228
+ abortSignal,
229
+ getModel?.(),
230
+ getEffort?.()
231
+ )) {
232
+ if (frame.t !== 'update') continue;
233
+ const u = frame.update;
234
+ switch (u.sessionUpdate) {
235
+ case 'agent_message_chunk': {
236
+ if (u.content?.type !== 'text') break;
237
+ const last = parts[parts.length - 1];
238
+ if (last && last.type === 'text') {
239
+ parts[parts.length - 1] = { ...last, text: last.text + u.content.text };
240
+ } else {
241
+ parts.push({ type: 'text', text: u.content.text });
242
+ }
243
+ break;
244
+ }
245
+ case 'agent_thought_chunk': {
246
+ // Extended-thinking — rendered as a collapsed "Thinking" disclosure
247
+ // (assistant-ui reasoning part) so it's available but not in the way.
248
+ if (u.content?.type !== 'text') break;
249
+ const last = parts[parts.length - 1];
250
+ if (last && last.type === 'reasoning') {
251
+ parts[parts.length - 1] = { ...last, text: last.text + u.content.text };
252
+ } else {
253
+ parts.push({ type: 'reasoning', text: u.content.text });
254
+ }
255
+ break;
256
+ }
257
+ case 'tool_call': {
258
+ toolIndex.set(u.toolCallId, parts.length);
259
+ parts.push({
260
+ type: 'tool-call',
261
+ toolCallId: u.toolCallId,
262
+ toolName: u.title || u.kind || 'tool',
263
+ args: u.rawInput ?? {},
264
+ argsText: safeJson(u.rawInput),
265
+ result: undefined,
266
+ });
267
+ break;
268
+ }
269
+ case 'tool_call_update': {
270
+ const idx = toolIndex.get(u.toolCallId);
271
+ if (idx == null) break;
272
+ parts[idx] = {
273
+ ...parts[idx],
274
+ result: u.rawOutput ?? parts[idx].result,
275
+ isError: u.status === 'failed',
276
+ };
277
+ break;
278
+ }
279
+ default:
280
+ break; // plan / thought / commands / usage — not rendered in v1
281
+ }
282
+ yield { content: parts.slice() };
283
+ }
284
+ },
285
+ };
286
+ }
@@ -0,0 +1,138 @@
1
+ // Compact, XSS-safe markdown renderer for the ACP chat feed. Builds React nodes
2
+ // directly (never innerHTML) so agent output can't inject markup. Covers the
3
+ // subset a coding agent actually emits: fenced code blocks, headings, bullet +
4
+ // numbered lists, bold / italic / inline code, and http(s) links. Anything else
5
+ // falls through as plain (pre-wrapped) text. No new dependency.
6
+
7
+ function safeHref(url) {
8
+ // Only allow http(s) — never javascript:/data:/etc.
9
+ return /^https?:\/\//i.test(url) ? url : null;
10
+ }
11
+
12
+ // Inline tokenizer: `code`, **bold**, *italic*, [text](url).
13
+ const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)\s]+\))/g;
14
+
15
+ function inline(text) {
16
+ const out = [];
17
+ let last = 0;
18
+ let key = 0;
19
+ let m;
20
+ INLINE_RE.lastIndex = 0;
21
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop
22
+ while ((m = INLINE_RE.exec(text)) !== null) {
23
+ if (m.index > last) out.push(text.slice(last, m.index));
24
+ const tok = m[0];
25
+ if (tok.startsWith('`')) {
26
+ out.push(<code key={key++}>{tok.slice(1, -1)}</code>);
27
+ } else if (tok.startsWith('**')) {
28
+ out.push(<strong key={key++}>{tok.slice(2, -2)}</strong>);
29
+ } else if (tok.startsWith('*')) {
30
+ out.push(<em key={key++}>{tok.slice(1, -1)}</em>);
31
+ } else {
32
+ const link = /\[([^\]]+)\]\(([^)\s]+)\)/.exec(tok);
33
+ const href = link && safeHref(link[2]);
34
+ out.push(
35
+ href ? (
36
+ <a key={key++} href={href} target="_blank" rel="noreferrer noopener">
37
+ {link[1]}
38
+ </a>
39
+ ) : (
40
+ link ? link[1] : tok
41
+ )
42
+ );
43
+ }
44
+ last = m.index + tok.length;
45
+ }
46
+ if (last < text.length) out.push(text.slice(last));
47
+ return out;
48
+ }
49
+
50
+ export function Markdown({ text }) {
51
+ const lines = String(text).split('\n');
52
+ const blocks = [];
53
+ let i = 0;
54
+ let key = 0;
55
+
56
+ const isList = (l) => /^\s*[-*]\s/.test(l) || /^\s*\d+\.\s/.test(l);
57
+
58
+ while (i < lines.length) {
59
+ const line = lines[i];
60
+
61
+ // Fenced code block.
62
+ if (line.trimStart().startsWith('```')) {
63
+ const buf = [];
64
+ i++;
65
+ while (i < lines.length && !lines[i].trimStart().startsWith('```')) {
66
+ buf.push(lines[i]);
67
+ i++;
68
+ }
69
+ i++; // closing fence
70
+ blocks.push(
71
+ <pre key={key++} className="chat-code">
72
+ <code>{buf.join('\n')}</code>
73
+ </pre>
74
+ );
75
+ continue;
76
+ }
77
+
78
+ // Heading → a styled paragraph (chat doesn't want giant h1s).
79
+ if (/^#{1,6}\s/.test(line)) {
80
+ blocks.push(
81
+ <p key={key++} className="chat-md-h">
82
+ {inline(line.replace(/^#{1,6}\s/, ''))}
83
+ </p>
84
+ );
85
+ i++;
86
+ continue;
87
+ }
88
+
89
+ // List (consecutive list lines).
90
+ if (isList(line)) {
91
+ const ordered = /^\s*\d+\.\s/.test(line);
92
+ const items = [];
93
+ while (i < lines.length && isList(lines[i])) {
94
+ items.push(<li key={items.length}>{inline(lines[i].replace(/^\s*(?:[-*]|\d+\.)\s/, ''))}</li>);
95
+ i++;
96
+ }
97
+ blocks.push(
98
+ ordered ? (
99
+ <ol key={key++} className="chat-md-list">
100
+ {items}
101
+ </ol>
102
+ ) : (
103
+ <ul key={key++} className="chat-md-list">
104
+ {items}
105
+ </ul>
106
+ )
107
+ );
108
+ continue;
109
+ }
110
+
111
+ // Blank line → paragraph separator.
112
+ if (line.trim() === '') {
113
+ i++;
114
+ continue;
115
+ }
116
+
117
+ // Paragraph: gather consecutive non-special lines.
118
+ const buf = [line];
119
+ i++;
120
+ while (
121
+ i < lines.length &&
122
+ lines[i].trim() !== '' &&
123
+ !lines[i].trimStart().startsWith('```') &&
124
+ !/^#{1,6}\s/.test(lines[i]) &&
125
+ !isList(lines[i])
126
+ ) {
127
+ buf.push(lines[i]);
128
+ i++;
129
+ }
130
+ blocks.push(
131
+ <p key={key++} className="chat-md-p">
132
+ {inline(buf.join('\n'))}
133
+ </p>
134
+ );
135
+ }
136
+
137
+ return <>{blocks}</>;
138
+ }
@@ -0,0 +1,86 @@
1
+ // Pure grouping helpers for the Changes panel (GitPanel.jsx) — framework-free so
2
+ // they unit-test without a DOM. DDR-115 follow-up: group changed files by parent
3
+ // canvas + its supporting sidecars (same-stem `.meta.json`, slug-matched
4
+ // `.annotations.svg`), mirroring the server's `expandSidecars` notion of "travels
5
+ // with the canvas" so the visual grouping is honest with what a unit commits.
6
+
7
+ export const CANVAS_RE = /\.(tsx|html)$/i;
8
+ export const META_RE = /\.meta\.json$/i;
9
+ export const ANNOT_RE = /\.annotations\.svg$/i;
10
+
11
+ /** Display name for a file (last path segment, sidecar/canvas extension stripped). */
12
+ export function baseName(p) {
13
+ const s = (String(p).split('/').pop() || String(p)).replace(ANNOT_RE, '').replace(META_RE, '');
14
+ return s.replace(/\.(tsx|html|css|svg|json)$/i, '');
15
+ }
16
+
17
+ /** Friendly label for a collapsed supporting file under a canvas. */
18
+ export function supportLabel(p) {
19
+ if (META_RE.test(p)) return 'Layout & settings';
20
+ if (ANNOT_RE.test(p)) return 'Annotations';
21
+ return baseName(p);
22
+ }
23
+
24
+ export function stripDesignRel(p, designRel) {
25
+ const s = String(p).replace(/^\/+|\/+$/g, '');
26
+ const prefix = `${(designRel || '.design').replace(/^\/+|\/+$/g, '')}/`;
27
+ return s.startsWith(prefix) ? s.slice(prefix.length) : s;
28
+ }
29
+
30
+ /** Mirror api.ts `fileSlug`: repo-relative canvas path → annotation slug, so the
31
+ * slug-named `<slug>.annotations.svg` (which is NOT a same-stem sibling) can be
32
+ * attached to its parent canvas. */
33
+ export function canvasSlug(p, designRel) {
34
+ return stripDesignRel(p, designRel)
35
+ .replace(/\//g, '-')
36
+ .replace(/\s+/g, '_')
37
+ .replace(CANVAS_RE, '')
38
+ .replace(/^\.+/, '')
39
+ .toLowerCase();
40
+ }
41
+
42
+ /**
43
+ * Group changed files into canvas "units" — a parent canvas + its supporting
44
+ * sidecars. Files with no parent canvas in the changeset become standalone
45
+ * "other" units (config, DS tokens, an orphan sidecar).
46
+ *
47
+ * @param {{path: string, status: string}[]} files
48
+ * @param {string} designRel
49
+ * @returns {{ canvasUnits: Unit[], otherUnits: Unit[] }}
50
+ * where Unit = { key, kind: 'canvas'|'other', primary, supporting: file[] }
51
+ */
52
+ export function buildUnits(files, designRel) {
53
+ const canvases = files.filter((f) => CANVAS_RE.test(f.path));
54
+ const canvasByBase = new Map(); // base path (no ext) → canvas file
55
+ const canvasBySlug = new Map(); // annotation slug → canvas file
56
+ for (const c of canvases) {
57
+ canvasByBase.set(c.path.replace(CANVAS_RE, ''), c);
58
+ canvasBySlug.set(canvasSlug(c.path, designRel), c);
59
+ }
60
+ const supporting = new Map(); // canvas.path → sidecar[]
61
+ const consumed = new Set(); // sidecar paths attached to a canvas
62
+ const attach = (canvas, f) => {
63
+ const arr = supporting.get(canvas.path);
64
+ if (arr) arr.push(f);
65
+ else supporting.set(canvas.path, [f]);
66
+ consumed.add(f.path);
67
+ };
68
+ for (const f of files) {
69
+ if (META_RE.test(f.path)) {
70
+ const c = canvasByBase.get(f.path.replace(META_RE, ''));
71
+ if (c) attach(c, f);
72
+ } else if (ANNOT_RE.test(f.path)) {
73
+ const c = canvasBySlug.get(stripDesignRel(f.path, designRel).replace(ANNOT_RE, '').toLowerCase());
74
+ if (c) attach(c, f);
75
+ }
76
+ }
77
+ const byName = (a, b) => baseName(a.primary.path).localeCompare(baseName(b.primary.path));
78
+ const canvasUnits = canvases
79
+ .map((c) => ({ key: c.path, kind: 'canvas', primary: c, supporting: supporting.get(c.path) ?? [] }))
80
+ .sort(byName);
81
+ const otherUnits = files
82
+ .filter((f) => !CANVAS_RE.test(f.path) && !consumed.has(f.path))
83
+ .map((f) => ({ key: f.path, kind: 'other', primary: f, supporting: [] }))
84
+ .sort(byName);
85
+ return { canvasUnits, otherUnits };
86
+ }
@@ -11,6 +11,10 @@ html, body {
11
11
  line-height: 1.45;
12
12
  -webkit-font-smoothing: antialiased;
13
13
  -moz-osx-font-smoothing: grayscale;
14
+ /* WebKit (Safari + WKWebView) text inflation can re-grow text that the canvas
15
+ scales down during zoom-out, undoing the shrink. Pin it off. No-op on Blink. */
16
+ -webkit-text-size-adjust: 100%;
17
+ text-size-adjust: 100%;
14
18
  }
15
19
 
16
20
  button { font: inherit; }