@1agh/maude 0.23.0 → 0.25.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 +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +32 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +126 -2
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +74 -10
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dependencies.json +30 -2
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
- package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
- package/plugins/design/dev-server/api.ts +123 -4
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
- package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +208 -23
- package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
- package/plugins/design/dev-server/canvas-cursors.ts +173 -0
- package/plugins/design/dev-server/canvas-icons.tsx +299 -0
- package/plugins/design/dev-server/canvas-lib.tsx +25 -21
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +404 -66
- package/plugins/design/dev-server/client/app.jsx +1231 -416
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +80 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +24 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +364 -29
- package/plugins/design/dev-server/dist/comment-mount.js +78 -33
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +208 -18
- package/plugins/design/dev-server/exporters/html.ts +4 -1
- package/plugins/design/dev-server/exporters/index.ts +4 -1
- package/plugins/design/dev-server/exporters/pdf.ts +7 -1
- package/plugins/design/dev-server/exporters/png.ts +21 -2
- package/plugins/design/dev-server/exporters/pptx.ts +330 -201
- package/plugins/design/dev-server/exporters/scope.ts +107 -11
- package/plugins/design/dev-server/exporters/svg.ts +4 -1
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/http.ts +299 -28
- package/plugins/design/dev-server/input-router.tsx +35 -5
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +123 -1
- package/plugins/design/dev-server/sync/agent.ts +95 -0
- package/plugins/design/dev-server/sync/codec.ts +155 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/index.ts +479 -35
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
- package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
- package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +32 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
- package/plugins/design/dev-server/tool-palette.tsx +155 -24
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +85 -10
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
// Renders: file tree, tabs, viewport (iframes), status bar, design-system view, comments.
|
|
4
4
|
// Universal — no project tokens needed; styling lives in client/styles/.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
7
7
|
import { createRoot } from 'react-dom/client';
|
|
8
8
|
|
|
9
|
+
// Trusted tool→cursor resolver (shares the single TOOL_CURSORS source with the
|
|
10
|
+
// canvas runtime). canvas-cursors.ts is dependency-free (a type-only Tool
|
|
11
|
+
// import that Bun erases), so this pulls only string constants into the client
|
|
12
|
+
// bundle — no React, no input-router. See the tool-cursor handler below.
|
|
13
|
+
import { resolveToolCursor } from '../canvas-cursors.ts';
|
|
14
|
+
|
|
9
15
|
const SYSTEM_TAB = '__system__';
|
|
10
16
|
const THEME_STORE = 'mdcc-theme';
|
|
11
17
|
const SHOW_HIDDEN_STORE = 'mdcc-show-hidden';
|
|
@@ -41,7 +47,9 @@ function readJsonStore(key, fallback) {
|
|
|
41
47
|
try {
|
|
42
48
|
const v = localStorage.getItem(key);
|
|
43
49
|
return v ? JSON.parse(v) : fallback;
|
|
44
|
-
} catch {
|
|
50
|
+
} catch {
|
|
51
|
+
return fallback;
|
|
52
|
+
}
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
// Section default-open: working sections (project + non-DS canvas groups)
|
|
@@ -98,15 +106,15 @@ function canvasUrl(p, cfg, opts) {
|
|
|
98
106
|
// tokens file. The top-level cfg.tokensCssRel is the legacy default
|
|
99
107
|
// (`system/colors_and_type.css`) and usually doesn't exist post-bootstrap.
|
|
100
108
|
const tokens = specMatch
|
|
101
|
-
?
|
|
102
|
-
:
|
|
109
|
+
? specDsEntry?.tokensCssRel || `system/${specMatch[1]}/colors_and_type.css`
|
|
110
|
+
: ds0?.tokensCssRel || cfg?.tokensCssRel;
|
|
103
111
|
if (tokens) params.set('tokens', tokens);
|
|
104
112
|
if (cfg?.componentsCssRel) params.set('components', cfg.componentsCssRel);
|
|
105
113
|
if (specMatch) {
|
|
106
|
-
const
|
|
107
|
-
params.set('layout', `system/${
|
|
114
|
+
const dsName = specMatch[1];
|
|
115
|
+
params.set('layout', `system/${dsName}/preview/_layout.css`);
|
|
108
116
|
if (!cfg?.componentsCssRel) {
|
|
109
|
-
params.set('components', `system/${
|
|
117
|
+
params.set('components', `system/${dsName}/preview/_components.css`);
|
|
110
118
|
}
|
|
111
119
|
} else if (ds0?.path) {
|
|
112
120
|
// UI canvas — load the project DS's `_components.css` so the dc-canvas /
|
|
@@ -116,7 +124,11 @@ function canvasUrl(p, cfg, opts) {
|
|
|
116
124
|
params.set('components', `${ds0.path}/preview/_components.css`);
|
|
117
125
|
}
|
|
118
126
|
}
|
|
119
|
-
|
|
127
|
+
// T2 (9.1-A) — load the iframe from the segregated canvas origin when the
|
|
128
|
+
// server advertises one; fall back to a same-origin relative URL otherwise
|
|
129
|
+
// (older server, tests). The trailing path + query are identical either way.
|
|
130
|
+
const origin = cfg?.canvasOrigin || '';
|
|
131
|
+
return `${origin}/_canvas-shell.html?${params.toString()}`;
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
function basename(p) {
|
|
@@ -188,7 +200,9 @@ function groupBySidecar(files) {
|
|
|
188
200
|
function buildTree(paths, stripPrefix) {
|
|
189
201
|
const root = {};
|
|
190
202
|
for (const p of paths) {
|
|
191
|
-
const stripped = p.startsWith(stripPrefix)
|
|
203
|
+
const stripped = p.startsWith(stripPrefix)
|
|
204
|
+
? p.slice(stripPrefix.length).replace(/^\/+/, '')
|
|
205
|
+
: p;
|
|
192
206
|
const parts = stripped.split('/');
|
|
193
207
|
let node = root;
|
|
194
208
|
for (let i = 0; i < parts.length; i++) {
|
|
@@ -211,22 +225,28 @@ function filterTree(node, query) {
|
|
|
211
225
|
const q = query.toLowerCase();
|
|
212
226
|
const out = {};
|
|
213
227
|
let any = false;
|
|
214
|
-
const dirs = Object.keys(node).filter(k => k !== '_files');
|
|
228
|
+
const dirs = Object.keys(node).filter((k) => k !== '_files');
|
|
215
229
|
for (const d of dirs) {
|
|
216
230
|
const filtered = filterTree(node[d], query);
|
|
217
|
-
if (filtered) {
|
|
231
|
+
if (filtered) {
|
|
232
|
+
out[d] = filtered;
|
|
233
|
+
any = true;
|
|
234
|
+
}
|
|
218
235
|
}
|
|
219
236
|
if (node._files) {
|
|
220
|
-
const files = node._files.filter(
|
|
221
|
-
f.name.toLowerCase().includes(q) || f.path.toLowerCase().includes(q)
|
|
237
|
+
const files = node._files.filter(
|
|
238
|
+
(f) => f.name.toLowerCase().includes(q) || f.path.toLowerCase().includes(q)
|
|
222
239
|
);
|
|
223
|
-
if (files.length) {
|
|
240
|
+
if (files.length) {
|
|
241
|
+
out._files = files;
|
|
242
|
+
any = true;
|
|
243
|
+
}
|
|
224
244
|
}
|
|
225
245
|
return any ? out : null;
|
|
226
246
|
}
|
|
227
247
|
|
|
228
248
|
function openCount(comments) {
|
|
229
|
-
return (comments || []).filter(c => c.status !== 'resolved').length;
|
|
249
|
+
return (comments || []).filter((c) => c.status !== 'resolved').length;
|
|
230
250
|
}
|
|
231
251
|
|
|
232
252
|
function timeAgo(iso) {
|
|
@@ -245,7 +265,9 @@ function timeAgo(iso) {
|
|
|
245
265
|
}
|
|
246
266
|
|
|
247
267
|
function totalCounts(commentsByFile) {
|
|
248
|
-
let all = 0,
|
|
268
|
+
let all = 0,
|
|
269
|
+
open = 0,
|
|
270
|
+
resolved = 0;
|
|
249
271
|
for (const list of Object.values(commentsByFile || {})) {
|
|
250
272
|
for (const c of list || []) {
|
|
251
273
|
all++;
|
|
@@ -260,7 +282,17 @@ function totalCounts(commentsByFile) {
|
|
|
260
282
|
|
|
261
283
|
function Icon({ d, size = 14, color }) {
|
|
262
284
|
return (
|
|
263
|
-
<svg
|
|
285
|
+
<svg
|
|
286
|
+
width={size}
|
|
287
|
+
height={size}
|
|
288
|
+
viewBox="0 0 24 24"
|
|
289
|
+
fill="none"
|
|
290
|
+
stroke={color || 'currentColor'}
|
|
291
|
+
strokeWidth="1.5"
|
|
292
|
+
strokeLinecap="round"
|
|
293
|
+
strokeLinejoin="round"
|
|
294
|
+
style={{ flex: 'none' }}
|
|
295
|
+
>
|
|
264
296
|
<path d={d} />
|
|
265
297
|
</svg>
|
|
266
298
|
);
|
|
@@ -287,9 +319,11 @@ function DirRow({ name, depth, defaultOpen, children }) {
|
|
|
287
319
|
tabIndex={-1}
|
|
288
320
|
className="tp-row dir"
|
|
289
321
|
style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
|
|
290
|
-
onClick={() => setOpen(v => !v)}
|
|
322
|
+
onClick={() => setOpen((v) => !v)}
|
|
291
323
|
>
|
|
292
|
-
<span className="glyph" aria-hidden="true">
|
|
324
|
+
<span className="glyph" aria-hidden="true">
|
|
325
|
+
{open ? '▾' : '▸'}
|
|
326
|
+
</span>
|
|
293
327
|
<span className="name">{name}</span>
|
|
294
328
|
</button>
|
|
295
329
|
{open && children}
|
|
@@ -318,7 +352,9 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
|
|
|
318
352
|
aria-label={open ? 'Collapse design system' : 'Expand design system'}
|
|
319
353
|
title={open ? 'Collapse' : 'Expand'}
|
|
320
354
|
>
|
|
321
|
-
<span className="glyph" aria-hidden="true">
|
|
355
|
+
<span className="glyph" aria-hidden="true">
|
|
356
|
+
{open ? '▾' : '▸'}
|
|
357
|
+
</span>
|
|
322
358
|
</button>
|
|
323
359
|
<button
|
|
324
360
|
type="button"
|
|
@@ -350,19 +386,38 @@ function FileRow({ file, activePath, onOpen, openCount: oc, depth, kind, sidecar
|
|
|
350
386
|
aria-selected={isSel}
|
|
351
387
|
aria-disabled={inert ? 'true' : undefined}
|
|
352
388
|
tabIndex={isSel ? 0 : -1}
|
|
353
|
-
className={
|
|
389
|
+
className={
|
|
390
|
+
'tp-row' +
|
|
391
|
+
(isSel ? ' sel' : '') +
|
|
392
|
+
(kind === 'runtime' ? ' muted' : '') +
|
|
393
|
+
(sidecar ? ' sidecar' : '')
|
|
394
|
+
}
|
|
354
395
|
style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
|
|
355
|
-
title={file.path + (oc ? ` — ${oc} open` :
|
|
356
|
-
onClick={() => {
|
|
396
|
+
title={file.path + (oc ? ` — ${oc} open` : inert ? ' (file index only)' : '')}
|
|
397
|
+
onClick={() => {
|
|
398
|
+
if (!inert) onOpen(file.path);
|
|
399
|
+
}}
|
|
357
400
|
>
|
|
358
|
-
<span className="glyph" aria-hidden="true">
|
|
401
|
+
<span className="glyph" aria-hidden="true">
|
|
402
|
+
{isSel ? '▸' : '·'}
|
|
403
|
+
</span>
|
|
359
404
|
<span className="name">{label}</span>
|
|
360
405
|
{oc > 0 && <span className="badge">{oc}</span>}
|
|
361
406
|
</button>
|
|
362
407
|
);
|
|
363
408
|
}
|
|
364
409
|
|
|
365
|
-
function CanvasRow({
|
|
410
|
+
function CanvasRow({
|
|
411
|
+
primary,
|
|
412
|
+
sidecars,
|
|
413
|
+
depth,
|
|
414
|
+
kind,
|
|
415
|
+
activePath,
|
|
416
|
+
onOpen,
|
|
417
|
+
openCount: oc,
|
|
418
|
+
showHidden,
|
|
419
|
+
forceOpen,
|
|
420
|
+
}) {
|
|
366
421
|
const hasSidecars = sidecars.length > 0;
|
|
367
422
|
const [openState, setOpenState] = useState(false);
|
|
368
423
|
// Sidecars are only revealed when the user opts in via `showHidden` — the
|
|
@@ -416,24 +471,39 @@ function CanvasRow({ primary, sidecars, depth, kind, activePath, onOpen, openCou
|
|
|
416
471
|
<span className="name">{displayName(primary.name)}</span>
|
|
417
472
|
{oc > 0 && <span className="badge">{oc}</span>}
|
|
418
473
|
</button>
|
|
419
|
-
{open &&
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
474
|
+
{open &&
|
|
475
|
+
sidecars.map((sc) => (
|
|
476
|
+
<FileRow
|
|
477
|
+
key={sc.path}
|
|
478
|
+
file={sc}
|
|
479
|
+
activePath={activePath}
|
|
480
|
+
onOpen={onOpen}
|
|
481
|
+
openCount={0}
|
|
482
|
+
depth={depth + 1}
|
|
483
|
+
kind={kind}
|
|
484
|
+
sidecar
|
|
485
|
+
/>
|
|
486
|
+
))}
|
|
431
487
|
</Fragment>
|
|
432
488
|
);
|
|
433
489
|
}
|
|
434
490
|
|
|
435
|
-
function Tree({
|
|
436
|
-
|
|
491
|
+
function Tree({
|
|
492
|
+
node,
|
|
493
|
+
activePath,
|
|
494
|
+
onOpen,
|
|
495
|
+
commentsByFile,
|
|
496
|
+
depth = 1,
|
|
497
|
+
kind,
|
|
498
|
+
showHidden,
|
|
499
|
+
search,
|
|
500
|
+
dsFolders,
|
|
501
|
+
activeDsName,
|
|
502
|
+
onOpenSystem,
|
|
503
|
+
}) {
|
|
504
|
+
const dirs = Object.keys(node)
|
|
505
|
+
.filter((k) => k !== '_files')
|
|
506
|
+
.sort();
|
|
437
507
|
const files = node._files || [];
|
|
438
508
|
// VS Code-style sidecar grouping. Canvas (`.tsx`/`.html`) becomes the primary
|
|
439
509
|
// row; same-basename non-canvas files (`.meta.json`, `.css`, …) collapse
|
|
@@ -452,10 +522,12 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
|
|
|
452
522
|
return (
|
|
453
523
|
<Fragment>
|
|
454
524
|
{canvases.map((entry) => {
|
|
455
|
-
const forceOpen =
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
525
|
+
const forceOpen =
|
|
526
|
+
hasSearch &&
|
|
527
|
+
entry.sidecars.some((sc) => {
|
|
528
|
+
const q = search.toLowerCase();
|
|
529
|
+
return sc.name.toLowerCase().includes(q) || sc.path.toLowerCase().includes(q);
|
|
530
|
+
});
|
|
459
531
|
return (
|
|
460
532
|
<CanvasRow
|
|
461
533
|
key={entry.primary.path}
|
|
@@ -471,18 +543,19 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
|
|
|
471
543
|
/>
|
|
472
544
|
);
|
|
473
545
|
})}
|
|
474
|
-
{showHidden &&
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
546
|
+
{showHidden &&
|
|
547
|
+
orphans.map((entry) => (
|
|
548
|
+
<FileRow
|
|
549
|
+
key={entry.primary.path}
|
|
550
|
+
file={entry.primary}
|
|
551
|
+
activePath={activePath}
|
|
552
|
+
onOpen={onOpen}
|
|
553
|
+
openCount={openCount(commentsByFile[entry.primary.path])}
|
|
554
|
+
depth={depth}
|
|
555
|
+
kind={kind}
|
|
556
|
+
/>
|
|
557
|
+
))}
|
|
558
|
+
{dirs.map((d) => {
|
|
486
559
|
const dsMatch = dsFolderByName?.get(d);
|
|
487
560
|
const childTree = (
|
|
488
561
|
<Tree
|
|
@@ -527,12 +600,12 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
|
|
|
527
600
|
// project / DS identity; the mock keeps these tight (1 line). Labels are
|
|
528
601
|
// keyed by the server-provided `kind` (PROJECT / DS / UI / RUNTIME).
|
|
529
602
|
const SECTION_META = {
|
|
530
|
-
project:
|
|
603
|
+
project: { title: 'PROJECT', pillFromCount: false },
|
|
531
604
|
// Design-system group: pill shows the number of DSes (one row per DS folder
|
|
532
605
|
// inside). Computed in Sidebar from `g.dsFolders.length`.
|
|
533
|
-
ds:
|
|
534
|
-
canvas:
|
|
535
|
-
runtime:
|
|
606
|
+
ds: { title: 'DESIGN SYSTEM', pillFromDsCount: true },
|
|
607
|
+
canvas: { title: 'UI CANVASES', pillFromCount: true },
|
|
608
|
+
runtime: { title: 'RUNTIME · GITIGNORED', pillFromCount: true },
|
|
536
609
|
};
|
|
537
610
|
|
|
538
611
|
function sectionMetaFor(g) {
|
|
@@ -540,14 +613,27 @@ function sectionMetaFor(g) {
|
|
|
540
613
|
if (g.kind === 'runtime') return SECTION_META.runtime;
|
|
541
614
|
// canvas-kind groups: "Design system" → ds, anything else → canvas label
|
|
542
615
|
if (g.label === 'Design system') return SECTION_META.ds;
|
|
543
|
-
if (g.label === 'UI kit')
|
|
616
|
+
if (g.label === 'UI kit') return SECTION_META.canvas;
|
|
544
617
|
return { title: g.label.toUpperCase(), pillFromCount: true };
|
|
545
618
|
}
|
|
546
619
|
|
|
547
|
-
function Sidebar({
|
|
620
|
+
function Sidebar({
|
|
621
|
+
groups,
|
|
622
|
+
activePath,
|
|
623
|
+
activeDsName,
|
|
624
|
+
onOpen,
|
|
625
|
+
onOpenSystem,
|
|
626
|
+
wsConnected,
|
|
627
|
+
search,
|
|
628
|
+
setSearch,
|
|
629
|
+
commentsByFile,
|
|
630
|
+
showHidden,
|
|
631
|
+
sectionsExpanded,
|
|
632
|
+
onToggleSection,
|
|
633
|
+
}) {
|
|
548
634
|
const filteredGroups = useMemo(() => {
|
|
549
635
|
if (!search) return groups;
|
|
550
|
-
return groups.map(g => ({ ...g, tree: filterTree(g.tree, search), filtered: !!search }));
|
|
636
|
+
return groups.map((g) => ({ ...g, tree: filterTree(g.tree, search), filtered: !!search }));
|
|
551
637
|
}, [groups, search]);
|
|
552
638
|
|
|
553
639
|
// Mock uses `42 / 42` — total openable canvases, not every listed file.
|
|
@@ -560,7 +646,8 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
|
|
|
560
646
|
}, [groups]);
|
|
561
647
|
const htmlShown = useMemo(() => {
|
|
562
648
|
let total = 0;
|
|
563
|
-
for (const g of filteredGroups)
|
|
649
|
+
for (const g of filteredGroups)
|
|
650
|
+
for (const p of g.paths || []) if (CANVAS_EXT_RE.test(p)) total++;
|
|
564
651
|
return total;
|
|
565
652
|
}, [filteredGroups]);
|
|
566
653
|
|
|
@@ -580,18 +667,27 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
|
|
|
580
667
|
type="search"
|
|
581
668
|
placeholder="filter (⌘F)"
|
|
582
669
|
value={search}
|
|
583
|
-
onChange={e => setSearch(e.target.value)}
|
|
670
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
584
671
|
aria-label="Filter files"
|
|
585
672
|
/>
|
|
586
673
|
{search ? (
|
|
587
|
-
<button
|
|
674
|
+
<button
|
|
675
|
+
className="search-clear"
|
|
676
|
+
onClick={() => setSearch('')}
|
|
677
|
+
title="Clear (Esc)"
|
|
678
|
+
aria-label="Clear search"
|
|
679
|
+
>
|
|
680
|
+
×
|
|
681
|
+
</button>
|
|
588
682
|
) : (
|
|
589
|
-
<span className="search-kbd" aria-hidden="true"
|
|
683
|
+
<span className="search-kbd" aria-hidden="true">
|
|
684
|
+
/
|
|
685
|
+
</span>
|
|
590
686
|
)}
|
|
591
687
|
</div>
|
|
592
688
|
|
|
593
689
|
<div className="tree-panel-body" role="tree" aria-label="Project file tree">
|
|
594
|
-
{filteredGroups.map(g => {
|
|
690
|
+
{filteredGroups.map((g) => {
|
|
595
691
|
// Hide gitignored runtime / orphan-only project sections by default.
|
|
596
692
|
// Active search overrides — if the user typed a query, they want hits
|
|
597
693
|
// wherever they live.
|
|
@@ -600,9 +696,10 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
|
|
|
600
696
|
// Counter pill counts canvases only — sidecars + orphans inflate the
|
|
601
697
|
// raw `paths.length` and the FILES header already filters this way.
|
|
602
698
|
const canvasCount = (g.paths || []).filter((p) => CANVAS_EXT_RE.test(p)).length;
|
|
603
|
-
const pill =
|
|
604
|
-
|
|
605
|
-
|
|
699
|
+
const pill =
|
|
700
|
+
meta.pill ||
|
|
701
|
+
(meta.pillFromDsCount ? String(g.dsFolders?.length || 0) : null) ||
|
|
702
|
+
(meta.pillFromCount ? String(canvasCount || g.paths?.length || 0) : null);
|
|
606
703
|
const hasItems = g.tree && Object.keys(g.tree).length > 0;
|
|
607
704
|
const isDs = g.label === 'Design system';
|
|
608
705
|
const isProject = g.kind === 'project';
|
|
@@ -624,29 +721,30 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
|
|
|
624
721
|
aria-expanded={sectionOpen}
|
|
625
722
|
title={sectionOpen ? 'Collapse section' : 'Expand section'}
|
|
626
723
|
>
|
|
627
|
-
<span className="chev" aria-hidden="true">
|
|
724
|
+
<span className="chev" aria-hidden="true">
|
|
725
|
+
{chev}
|
|
726
|
+
</span>
|
|
628
727
|
<span className="section-label">{meta.title}</span>
|
|
629
728
|
{pill && <span className="pill">{pill}</span>}
|
|
630
729
|
</button>
|
|
631
|
-
{sectionOpen &&
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
{search ? 'No matches.' : 'Empty.'}
|
|
648
|
-
|
|
649
|
-
))}
|
|
730
|
+
{sectionOpen &&
|
|
731
|
+
(hasItems ? (
|
|
732
|
+
<Tree
|
|
733
|
+
node={g.tree}
|
|
734
|
+
activePath={activePath}
|
|
735
|
+
onOpen={onOpen}
|
|
736
|
+
commentsByFile={commentsByFile}
|
|
737
|
+
depth={1}
|
|
738
|
+
kind={g.kind}
|
|
739
|
+
showHidden={showHidden}
|
|
740
|
+
search={search}
|
|
741
|
+
dsFolders={g.dsFolders}
|
|
742
|
+
activeDsName={activeDsName}
|
|
743
|
+
onOpenSystem={isDs ? onOpenSystem : undefined}
|
|
744
|
+
/>
|
|
745
|
+
) : (
|
|
746
|
+
<div className="tp-empty">{search ? 'No matches.' : 'Empty.'}</div>
|
|
747
|
+
))}
|
|
650
748
|
</Fragment>
|
|
651
749
|
);
|
|
652
750
|
})}
|
|
@@ -660,7 +758,9 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
|
|
|
660
758
|
function HelpModal({ open, onClose }) {
|
|
661
759
|
useEffect(() => {
|
|
662
760
|
if (!open) return;
|
|
663
|
-
function onKey(e) {
|
|
761
|
+
function onKey(e) {
|
|
762
|
+
if (e.key === 'Escape') onClose();
|
|
763
|
+
}
|
|
664
764
|
window.addEventListener('keydown', onKey);
|
|
665
765
|
return () => window.removeEventListener('keydown', onKey);
|
|
666
766
|
}, [open, onClose]);
|
|
@@ -669,112 +769,286 @@ function HelpModal({ open, onClose }) {
|
|
|
669
769
|
<div
|
|
670
770
|
className="help-modal-backdrop"
|
|
671
771
|
role="presentation"
|
|
672
|
-
onMouseDown={e => {
|
|
772
|
+
onMouseDown={(e) => {
|
|
773
|
+
if (e.target === e.currentTarget) onClose();
|
|
774
|
+
}}
|
|
673
775
|
>
|
|
674
|
-
<div
|
|
776
|
+
<div
|
|
777
|
+
className="help-modal"
|
|
778
|
+
role="dialog"
|
|
779
|
+
aria-modal="true"
|
|
780
|
+
aria-labelledby="help-modal-title"
|
|
781
|
+
>
|
|
675
782
|
<header className="help-modal-hd">
|
|
676
|
-
<span className="title" id="help-modal-title">
|
|
783
|
+
<span className="title" id="help-modal-title">
|
|
784
|
+
Help · shortcuts & commands
|
|
785
|
+
</span>
|
|
677
786
|
<span className="sku">MDCC-DEV-SRV / v{MDCC_VERSION}</span>
|
|
678
|
-
<button
|
|
787
|
+
<button
|
|
788
|
+
type="button"
|
|
789
|
+
className="help-modal-close"
|
|
790
|
+
aria-label="Close (Esc)"
|
|
791
|
+
onClick={onClose}
|
|
792
|
+
>
|
|
793
|
+
×
|
|
794
|
+
</button>
|
|
679
795
|
</header>
|
|
680
796
|
<div className="help-modal-body">
|
|
681
797
|
<details open>
|
|
682
798
|
<summary>Canvas selection & tools</summary>
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
799
|
+
<ul>
|
|
800
|
+
<li>
|
|
801
|
+
<kbd>V</kbd> <span>move tool — Cmd+click to select, Cmd+Shift to multi</span>
|
|
802
|
+
</li>
|
|
803
|
+
<li>
|
|
804
|
+
<kbd>H</kbd> <span>hand tool — bare drag pans (no Space needed)</span>
|
|
805
|
+
</li>
|
|
806
|
+
<li>
|
|
807
|
+
<kbd>C</kbd> <span>comment tool — hover paints, click drops a pin</span>
|
|
808
|
+
</li>
|
|
809
|
+
<li>
|
|
810
|
+
<kbd>⌘</kbd> + hover <span>preview deepest element under cursor</span>
|
|
811
|
+
</li>
|
|
812
|
+
<li>
|
|
813
|
+
<kbd>⌘</kbd> + click <span>select that element (replace)</span>
|
|
814
|
+
</li>
|
|
815
|
+
<li>
|
|
816
|
+
<kbd>⌘⇧</kbd> + click <span>add deepest to selection (multi)</span>
|
|
817
|
+
</li>
|
|
818
|
+
<li>
|
|
819
|
+
right-click <span>context menu (Copy CSS / Fit / Reset...)</span>
|
|
820
|
+
</li>
|
|
821
|
+
<li>
|
|
822
|
+
<kbd>Esc</kbd> in canvas <span>clear selection + close menu</span>
|
|
823
|
+
</li>
|
|
824
|
+
</ul>
|
|
825
|
+
</details>
|
|
826
|
+
<details>
|
|
827
|
+
<summary>Annotation tools</summary>
|
|
828
|
+
<ul>
|
|
829
|
+
<li>
|
|
830
|
+
<kbd>B</kbd> <span>pen — freehand stroke</span>
|
|
831
|
+
</li>
|
|
832
|
+
<li>
|
|
833
|
+
<kbd>R</kbd> <span>rectangle — drag to define corners</span>
|
|
834
|
+
</li>
|
|
835
|
+
<li>
|
|
836
|
+
<kbd>O</kbd> <span>ellipse — drag from center outward</span>
|
|
837
|
+
</li>
|
|
838
|
+
<li>
|
|
839
|
+
<kbd>A</kbd> <span>arrow — drag tail → tip</span>
|
|
840
|
+
</li>
|
|
841
|
+
<li>
|
|
842
|
+
<kbd>E</kbd> <span>eraser — click or drag over strokes to remove</span>
|
|
843
|
+
</li>
|
|
844
|
+
<li>
|
|
845
|
+
<kbd>V</kbd> + click stroke <span>select annotation (Shift+click to multi)</span>
|
|
846
|
+
</li>
|
|
847
|
+
<li>
|
|
848
|
+
<kbd>V</kbd> + drag empty <span>marquee-select strokes that overlap</span>
|
|
849
|
+
</li>
|
|
850
|
+
<li>
|
|
851
|
+
double-click rect/ellipse <span>add text inside the shape</span>
|
|
852
|
+
</li>
|
|
853
|
+
<li>
|
|
854
|
+
arrow keys <span>nudge selected annotation 1 unit (Shift = 10)</span>
|
|
855
|
+
</li>
|
|
856
|
+
<li>
|
|
857
|
+
<kbd>Backspace</kbd> <span>delete selected annotations</span>
|
|
858
|
+
</li>
|
|
859
|
+
<li>
|
|
860
|
+
<kbd>⇧P</kbd> <span>presentation — hide annotations for clean screenshot</span>
|
|
861
|
+
</li>
|
|
862
|
+
</ul>
|
|
863
|
+
</details>
|
|
864
|
+
<details>
|
|
865
|
+
<summary>Tabs & canvas</summary>
|
|
866
|
+
<ul>
|
|
867
|
+
<li>
|
|
868
|
+
click in tree <span>open tab</span>
|
|
869
|
+
</li>
|
|
870
|
+
<li>
|
|
871
|
+
<kbd>×</kbd> on tab <span>close tab</span>
|
|
872
|
+
</li>
|
|
873
|
+
<li>
|
|
874
|
+
<kbd>⌘R</kbd> <span>reload iframe</span>
|
|
875
|
+
</li>
|
|
876
|
+
<li>
|
|
877
|
+
<kbd>/</kbd> <span>focus search</span>
|
|
878
|
+
</li>
|
|
879
|
+
<li>
|
|
880
|
+
<kbd>⌘⇧M</kbd> <span>toggle comments panel</span>
|
|
881
|
+
</li>
|
|
882
|
+
</ul>
|
|
883
|
+
</details>
|
|
884
|
+
<details>
|
|
885
|
+
<summary>Slash commands</summary>
|
|
886
|
+
<ul className="cmds">
|
|
887
|
+
<li>
|
|
888
|
+
<code>
|
|
889
|
+
/design:edit "<i>feedback</i>"
|
|
890
|
+
</code>
|
|
891
|
+
<span>edit + 4-iter multi-axis loop</span>
|
|
892
|
+
</li>
|
|
893
|
+
<li>
|
|
894
|
+
<code>
|
|
895
|
+
/design:edit "<i>…</i>" --perfect
|
|
896
|
+
</code>
|
|
897
|
+
<span>8-iter polish (4.5/5 aspiration)</span>
|
|
898
|
+
</li>
|
|
899
|
+
<li>
|
|
900
|
+
<code>
|
|
901
|
+
/design:edit "<i>…</i>" --no-critic
|
|
902
|
+
</code>
|
|
903
|
+
<span>raw edit, skip loop</span>
|
|
904
|
+
</li>
|
|
905
|
+
<li>
|
|
906
|
+
<code>
|
|
907
|
+
/design:edit "<i>…</i>" --opt-out=<i>scope</i>
|
|
908
|
+
</code>
|
|
909
|
+
<span>override DS scope (palette/aesthetic/full)</span>
|
|
910
|
+
</li>
|
|
911
|
+
<li>
|
|
912
|
+
<code>
|
|
913
|
+
/design:new "<i>Name</i>" "<i>brief</i>"
|
|
914
|
+
</code>
|
|
915
|
+
<span>scaffold canvas</span>
|
|
916
|
+
</li>
|
|
917
|
+
<li>
|
|
918
|
+
<code>
|
|
919
|
+
/design:new "<i>…</i>" --opt-out=aesthetic
|
|
920
|
+
</code>
|
|
921
|
+
<span>scaffold off-system canvas (gradients/radii/type free)</span>
|
|
922
|
+
</li>
|
|
923
|
+
<li>
|
|
924
|
+
<code>/design:critic</code>
|
|
925
|
+
<span>review panel (routed)</span>
|
|
926
|
+
</li>
|
|
927
|
+
<li>
|
|
928
|
+
<code>/design:critic --all</code>
|
|
929
|
+
<span>10-critic sweep</span>
|
|
930
|
+
</li>
|
|
931
|
+
<li>
|
|
932
|
+
<code>/design:critic --agent signature-moment-critic</code>
|
|
933
|
+
<span>aspiration axis only</span>
|
|
934
|
+
</li>
|
|
935
|
+
<li>
|
|
936
|
+
<code>/design:rollback</code>
|
|
937
|
+
<span>undo last edit</span>
|
|
938
|
+
</li>
|
|
939
|
+
<li>
|
|
940
|
+
<code>/design:screenshot</code>
|
|
941
|
+
<span>capture canvas</span>
|
|
942
|
+
</li>
|
|
943
|
+
<li>
|
|
944
|
+
<code>/design:setup-docs</code>
|
|
945
|
+
<span>refresh README + INDEX</span>
|
|
946
|
+
</li>
|
|
947
|
+
<li>
|
|
948
|
+
<code>/design:handoff</code>
|
|
949
|
+
<span>migrate to apps/</span>
|
|
950
|
+
</li>
|
|
951
|
+
</ul>
|
|
952
|
+
</details>
|
|
953
|
+
<details>
|
|
954
|
+
<summary>Opt-out scope</summary>
|
|
955
|
+
<ul>
|
|
956
|
+
<li>
|
|
957
|
+
<strong>palette</strong>{' '}
|
|
958
|
+
<span>
|
|
959
|
+
default — tokens + rootClass kept; local namespace overrides colors only. DS
|
|
960
|
+
aesthetic still enforced.
|
|
961
|
+
</span>
|
|
962
|
+
</li>
|
|
963
|
+
<li>
|
|
964
|
+
<strong>aesthetic</strong>{' '}
|
|
965
|
+
<span>
|
|
966
|
+
palette + gradients/off-ladder radii/alt type/decorative SVG flags allowed.
|
|
967
|
+
</span>
|
|
968
|
+
</li>
|
|
969
|
+
<li>
|
|
970
|
+
<strong>full</strong>{' '}
|
|
971
|
+
<span>DS treated as advisory. Type/radii/aesthetic up to canvas.</span>
|
|
972
|
+
</li>
|
|
973
|
+
<li>
|
|
974
|
+
<em>A11y enforced at every scope</em>{' '}
|
|
975
|
+
<span>contrast, focus, semantics, motion, touch targets — never relaxed.</span>
|
|
976
|
+
</li>
|
|
977
|
+
<li>
|
|
978
|
+
Persisted on canvas's <code>.meta.json</code> <code>opt_out_scope</code> field —
|
|
979
|
+
subsequent <code>/design:edit</code> iterations inherit.
|
|
980
|
+
</li>
|
|
981
|
+
<li>
|
|
982
|
+
Inferred from brief ("modern", "vibrant", "off-system") with one-shot
|
|
983
|
+
AskUserQuestion before iter-1 critics fire.
|
|
984
|
+
</li>
|
|
985
|
+
</ul>
|
|
986
|
+
</details>
|
|
987
|
+
<details>
|
|
988
|
+
<summary>Auto-critic loop</summary>
|
|
989
|
+
<ul>
|
|
990
|
+
<li>
|
|
991
|
+
<strong>Default</strong>{' '}
|
|
992
|
+
<span>4 iter · aspiration ≥ 4.0 · stable-but-bland exit</span>
|
|
993
|
+
</li>
|
|
994
|
+
<li>
|
|
995
|
+
<strong>--perfect</strong>{' '}
|
|
996
|
+
<span>8 iter · aspiration ≥ 4.5 · broader divergence tolerance</span>
|
|
997
|
+
</li>
|
|
998
|
+
<li>
|
|
999
|
+
<strong>--perfect --all</strong>{' '}
|
|
1000
|
+
<span>every critic incl. aspiration · portfolio-grade</span>
|
|
1001
|
+
</li>
|
|
1002
|
+
<li>
|
|
1003
|
+
Exit: <code>solid</code> · <code>stable-but-bland</code> · <code>max-reached</code>{' '}
|
|
1004
|
+
· <code>divergent</code>
|
|
1005
|
+
</li>
|
|
1006
|
+
<li>
|
|
1007
|
+
<em>stable-but-bland</em> = correctness clean, aspiration plateau — surface for
|
|
1008
|
+
review with lowest 2 axes named
|
|
1009
|
+
</li>
|
|
1010
|
+
<li>
|
|
1011
|
+
When <code>opt_out_scope ∈ {aesthetic, full}</code>: iter-1 checkpoint
|
|
1012
|
+
fires — pick (a) run loop, (b) skip auto-loop and review iter 1, (c) a11y-only
|
|
1013
|
+
check.
|
|
1014
|
+
</li>
|
|
1015
|
+
</ul>
|
|
1016
|
+
</details>
|
|
1017
|
+
<details>
|
|
1018
|
+
<summary>Pin-to-element flow</summary>
|
|
1019
|
+
<ol>
|
|
1020
|
+
<li>Open canvas tab</li>
|
|
1021
|
+
<li>
|
|
1022
|
+
<kbd>⌘</kbd>+click element
|
|
1023
|
+
</li>
|
|
1024
|
+
<li>Status bar shows ● selector</li>
|
|
1025
|
+
<li>
|
|
1026
|
+
Run{' '}
|
|
1027
|
+
<code>
|
|
1028
|
+
/design:edit "<i>change just this</i>"
|
|
1029
|
+
</code>
|
|
1030
|
+
</li>
|
|
1031
|
+
<li>
|
|
1032
|
+
Reload iframe (<kbd>⌘R</kbd>)
|
|
1033
|
+
</li>
|
|
1034
|
+
</ol>
|
|
1035
|
+
</details>
|
|
770
1036
|
<details>
|
|
771
1037
|
<summary>Comments</summary>
|
|
772
1038
|
<ol>
|
|
773
|
-
<li
|
|
1039
|
+
<li>
|
|
1040
|
+
<kbd>⌘</kbd>+click element, then <kbd>⌘C</kbd> <span>or ⌘⇧+click</span>
|
|
1041
|
+
</li>
|
|
774
1042
|
<li>Numbered pin appears on canvas</li>
|
|
775
|
-
<li
|
|
776
|
-
|
|
777
|
-
|
|
1043
|
+
<li>
|
|
1044
|
+
<kbd>⌘⇧M</kbd> <span>opens panel — All / Open / Resolved</span>
|
|
1045
|
+
</li>
|
|
1046
|
+
<li>
|
|
1047
|
+
Click row in panel <span>jumps to that file + pin</span>
|
|
1048
|
+
</li>
|
|
1049
|
+
<li>
|
|
1050
|
+
Claude reads <code>_comments/<slug>.json</code> on next <code>/design</code>
|
|
1051
|
+
</li>
|
|
778
1052
|
</ol>
|
|
779
1053
|
</details>
|
|
780
1054
|
</div>
|
|
@@ -795,7 +1069,9 @@ const MENU_NAMES = ['File', 'Edit', 'View', 'Selection', 'Tools', 'Help'];
|
|
|
795
1069
|
|
|
796
1070
|
function ViewDropdown({ panels, onToggle, onClose }) {
|
|
797
1071
|
useEffect(() => {
|
|
798
|
-
function onKey(e) {
|
|
1072
|
+
function onKey(e) {
|
|
1073
|
+
if (e.key === 'Escape') onClose();
|
|
1074
|
+
}
|
|
799
1075
|
function onDocClick(e) {
|
|
800
1076
|
if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
|
|
801
1077
|
}
|
|
@@ -810,32 +1086,39 @@ function ViewDropdown({ panels, onToggle, onClose }) {
|
|
|
810
1086
|
return (
|
|
811
1087
|
<div className="mb-dropdown" role="menu" aria-label="View" style={{ left: '146px' }}>
|
|
812
1088
|
<div className="mb-dd-hd">Panels</div>
|
|
813
|
-
{panels.map(p => (
|
|
1089
|
+
{panels.map((p) => (
|
|
814
1090
|
<button
|
|
815
1091
|
key={p.id}
|
|
816
1092
|
type="button"
|
|
817
1093
|
role="menuitem"
|
|
818
1094
|
className={'mb-dd-item' + (p.checked ? ' active' : '')}
|
|
819
1095
|
aria-disabled={p.disabled ? 'true' : undefined}
|
|
820
|
-
onClick={() => {
|
|
1096
|
+
onClick={() => {
|
|
1097
|
+
if (!p.disabled) {
|
|
1098
|
+
onToggle(p.id);
|
|
1099
|
+
onClose();
|
|
1100
|
+
}
|
|
1101
|
+
}}
|
|
821
1102
|
>
|
|
822
1103
|
<span className="lbl">
|
|
823
1104
|
<span className="check">{p.checked ? '✓' : ''}</span>
|
|
824
1105
|
<span>{p.label}</span>
|
|
825
1106
|
</span>
|
|
826
|
-
{p.phase
|
|
827
|
-
|
|
828
|
-
|
|
1107
|
+
{p.phase ? (
|
|
1108
|
+
<span className="phase-tag">{p.phase}</span>
|
|
1109
|
+
) : (
|
|
1110
|
+
<span className="shortcut">{p.shortcut || ''}</span>
|
|
1111
|
+
)}
|
|
829
1112
|
</button>
|
|
830
1113
|
))}
|
|
831
1114
|
<div className="mb-dd-sep" />
|
|
832
1115
|
<div className="mb-dd-hd">Zoom</div>
|
|
833
1116
|
{[
|
|
834
|
-
{ label: 'Zoom In',
|
|
835
|
-
{ label: 'Zoom Out',
|
|
1117
|
+
{ label: 'Zoom In', shortcut: '⌘ +' },
|
|
1118
|
+
{ label: 'Zoom Out', shortcut: '⌘ −' },
|
|
836
1119
|
{ label: 'Fit to Screen', shortcut: '⌘ 0' },
|
|
837
1120
|
{ label: 'Actual Size · 100 %', shortcut: '⌥ ⌘ 0' },
|
|
838
|
-
].map(z => (
|
|
1121
|
+
].map((z) => (
|
|
839
1122
|
<button
|
|
840
1123
|
key={z.label}
|
|
841
1124
|
type="button"
|
|
@@ -843,7 +1126,10 @@ function ViewDropdown({ panels, onToggle, onClose }) {
|
|
|
843
1126
|
className="mb-dd-item"
|
|
844
1127
|
aria-disabled="true"
|
|
845
1128
|
>
|
|
846
|
-
<span className="lbl"
|
|
1129
|
+
<span className="lbl">
|
|
1130
|
+
<span className="check" />
|
|
1131
|
+
<span>{z.label}</span>
|
|
1132
|
+
</span>
|
|
847
1133
|
<span className="phase-tag">Phase 4</span>
|
|
848
1134
|
</button>
|
|
849
1135
|
))}
|
|
@@ -856,7 +1142,9 @@ function ViewDropdown({ panels, onToggle, onClose }) {
|
|
|
856
1142
|
|
|
857
1143
|
function SelectionDropdown({ onAction, onClose }) {
|
|
858
1144
|
useEffect(() => {
|
|
859
|
-
function onKey(e) {
|
|
1145
|
+
function onKey(e) {
|
|
1146
|
+
if (e.key === 'Escape') onClose();
|
|
1147
|
+
}
|
|
860
1148
|
function onDocClick(e) {
|
|
861
1149
|
if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
|
|
862
1150
|
}
|
|
@@ -868,20 +1156,26 @@ function SelectionDropdown({ onAction, onClose }) {
|
|
|
868
1156
|
};
|
|
869
1157
|
}, [onClose]);
|
|
870
1158
|
const items = [
|
|
871
|
-
{ id: 'deselect-all',
|
|
1159
|
+
{ id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
|
|
872
1160
|
{ id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⌘ ⇧ A' },
|
|
873
1161
|
];
|
|
874
1162
|
return (
|
|
875
1163
|
<div className="mb-dropdown" role="menu" aria-label="Selection" style={{ left: '195px' }}>
|
|
876
|
-
{items.map(it => (
|
|
1164
|
+
{items.map((it) => (
|
|
877
1165
|
<button
|
|
878
1166
|
key={it.id}
|
|
879
1167
|
type="button"
|
|
880
1168
|
role="menuitem"
|
|
881
1169
|
className="mb-dd-item"
|
|
882
|
-
onClick={() => {
|
|
1170
|
+
onClick={() => {
|
|
1171
|
+
onAction(it.id);
|
|
1172
|
+
onClose();
|
|
1173
|
+
}}
|
|
883
1174
|
>
|
|
884
|
-
<span className="lbl"
|
|
1175
|
+
<span className="lbl">
|
|
1176
|
+
<span className="check" />
|
|
1177
|
+
<span>{it.label}</span>
|
|
1178
|
+
</span>
|
|
885
1179
|
<span className="shortcut">{it.shortcut}</span>
|
|
886
1180
|
</button>
|
|
887
1181
|
))}
|
|
@@ -891,7 +1185,9 @@ function SelectionDropdown({ onAction, onClose }) {
|
|
|
891
1185
|
|
|
892
1186
|
function ToolsDropdown({ onAction, onClose }) {
|
|
893
1187
|
useEffect(() => {
|
|
894
|
-
function onKey(e) {
|
|
1188
|
+
function onKey(e) {
|
|
1189
|
+
if (e.key === 'Escape') onClose();
|
|
1190
|
+
}
|
|
895
1191
|
function onDocClick(e) {
|
|
896
1192
|
if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
|
|
897
1193
|
}
|
|
@@ -906,26 +1202,34 @@ function ToolsDropdown({ onAction, onClose }) {
|
|
|
906
1202
|
// kept in sync by hand because the menubar lives in the dev-server shell
|
|
907
1203
|
// (no shared bundle with the canvas iframes).
|
|
908
1204
|
const tools = [
|
|
909
|
-
{ id: 'move',
|
|
910
|
-
{ id: 'hand',
|
|
1205
|
+
{ id: 'move', label: 'Move', shortcut: 'V' },
|
|
1206
|
+
{ id: 'hand', label: 'Hand', shortcut: 'H' },
|
|
911
1207
|
{ id: 'comment', label: 'Comment', shortcut: 'C' },
|
|
912
|
-
{ id: 'pen',
|
|
913
|
-
{ id: 'rect',
|
|
1208
|
+
{ id: 'pen', label: 'Pen', shortcut: 'B' },
|
|
1209
|
+
{ id: 'rect', label: 'Rect', shortcut: 'R' },
|
|
914
1210
|
{ id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
|
|
915
|
-
{ id: '
|
|
916
|
-
{ id: '
|
|
1211
|
+
{ id: 'sticky', label: 'Sticky', shortcut: 'N' },
|
|
1212
|
+
{ id: 'arrow', label: 'Arrow', shortcut: 'A' },
|
|
1213
|
+
{ id: 'text', label: 'Text', shortcut: 'T' },
|
|
1214
|
+
{ id: 'eraser', label: 'Eraser', shortcut: 'E' },
|
|
917
1215
|
];
|
|
918
1216
|
return (
|
|
919
1217
|
<div className="mb-dropdown" role="menu" aria-label="Tools" style={{ left: '253px' }}>
|
|
920
|
-
{tools.map(t => (
|
|
1218
|
+
{tools.map((t) => (
|
|
921
1219
|
<button
|
|
922
1220
|
key={t.id}
|
|
923
1221
|
type="button"
|
|
924
1222
|
role="menuitem"
|
|
925
1223
|
className="mb-dd-item"
|
|
926
|
-
onClick={() => {
|
|
1224
|
+
onClick={() => {
|
|
1225
|
+
onAction(t.id);
|
|
1226
|
+
onClose();
|
|
1227
|
+
}}
|
|
927
1228
|
>
|
|
928
|
-
<span className="lbl"
|
|
1229
|
+
<span className="lbl">
|
|
1230
|
+
<span className="check" />
|
|
1231
|
+
<span>{t.label}</span>
|
|
1232
|
+
</span>
|
|
929
1233
|
<span className="shortcut">{t.shortcut}</span>
|
|
930
1234
|
</button>
|
|
931
1235
|
))}
|
|
@@ -933,21 +1237,62 @@ function ToolsDropdown({ onAction, onClose }) {
|
|
|
933
1237
|
);
|
|
934
1238
|
}
|
|
935
1239
|
|
|
936
|
-
function Menubar({
|
|
1240
|
+
function Menubar({
|
|
1241
|
+
activePath,
|
|
1242
|
+
project,
|
|
1243
|
+
tabsCount,
|
|
1244
|
+
openMenu,
|
|
1245
|
+
setOpenMenu,
|
|
1246
|
+
commentsPanelOpen,
|
|
1247
|
+
onToggleComments,
|
|
1248
|
+
onOpenSystem,
|
|
1249
|
+
sidebarOpen,
|
|
1250
|
+
onToggleSidebar,
|
|
1251
|
+
showHidden,
|
|
1252
|
+
onToggleShowHidden,
|
|
1253
|
+
onOpenHelp,
|
|
1254
|
+
annotationsVisible,
|
|
1255
|
+
onToggleAnnotations,
|
|
1256
|
+
postToActiveCanvas,
|
|
1257
|
+
}) {
|
|
937
1258
|
const isSystem = activePath === SYSTEM_TAB;
|
|
938
|
-
const stamp = isSystem ? 'SYSTEM' :
|
|
939
|
-
const fileLabel = isSystem
|
|
940
|
-
|
|
941
|
-
|
|
1259
|
+
const stamp = isSystem ? 'SYSTEM' : activePath ? 'CANVAS' : 'IDLE';
|
|
1260
|
+
const fileLabel = isSystem ? (
|
|
1261
|
+
<b>design system</b>
|
|
1262
|
+
) : activePath ? (
|
|
1263
|
+
<>
|
|
1264
|
+
{activePath.split('/').slice(0, -1).join('/')}/<b>{displayName(basename(activePath))}</b>
|
|
1265
|
+
</>
|
|
1266
|
+
) : (
|
|
1267
|
+
<span style={{ color: 'var(--u-fg-3)' }}>no canvas open</span>
|
|
1268
|
+
);
|
|
942
1269
|
|
|
943
1270
|
const panels = [
|
|
944
|
-
{ id: 'tree',
|
|
945
|
-
{
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1271
|
+
{ id: 'tree', label: 'Project Tree', shortcut: 'T', checked: sidebarOpen, disabled: false },
|
|
1272
|
+
{
|
|
1273
|
+
id: 'comments',
|
|
1274
|
+
label: 'Comments Sidebar',
|
|
1275
|
+
shortcut: '⌘ ⇧ M',
|
|
1276
|
+
checked: commentsPanelOpen,
|
|
1277
|
+
disabled: false,
|
|
1278
|
+
},
|
|
1279
|
+
{
|
|
1280
|
+
id: 'hidden',
|
|
1281
|
+
label: 'Show hidden files',
|
|
1282
|
+
shortcut: 'H',
|
|
1283
|
+
checked: showHidden,
|
|
1284
|
+
disabled: false,
|
|
1285
|
+
},
|
|
1286
|
+
{ id: 'layers', label: 'Layers Panel', phase: 'Phase 12', disabled: true },
|
|
1287
|
+
{ id: 'inspector', label: 'Inspector', phase: 'Phase 12', disabled: true },
|
|
1288
|
+
{
|
|
1289
|
+
id: 'annotate',
|
|
1290
|
+
label: 'Annotations',
|
|
1291
|
+
shortcut: '⇧ P',
|
|
1292
|
+
checked: annotationsVisible,
|
|
1293
|
+
disabled: false,
|
|
1294
|
+
},
|
|
1295
|
+
{ id: 'present', label: 'Presentation Mode', phase: 'Phase 6', disabled: true },
|
|
951
1296
|
];
|
|
952
1297
|
|
|
953
1298
|
function onMenuClick(key) {
|
|
@@ -966,7 +1311,7 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
|
|
|
966
1311
|
<span>maude</span>
|
|
967
1312
|
</span>
|
|
968
1313
|
<nav className="mb-menus" aria-label="Application menus">
|
|
969
|
-
{MENU_NAMES.map(name => {
|
|
1314
|
+
{MENU_NAMES.map((name) => {
|
|
970
1315
|
const key = name.toLowerCase();
|
|
971
1316
|
const hasDropdown = key === 'view' || key === 'selection' || key === 'tools';
|
|
972
1317
|
const interactive = hasDropdown || key === 'help';
|
|
@@ -991,7 +1336,7 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
|
|
|
991
1336
|
{openMenu === 'view' && (
|
|
992
1337
|
<ViewDropdown
|
|
993
1338
|
panels={panels}
|
|
994
|
-
onToggle={id => {
|
|
1339
|
+
onToggle={(id) => {
|
|
995
1340
|
if (id === 'tree') onToggleSidebar();
|
|
996
1341
|
else if (id === 'comments') onToggleComments();
|
|
997
1342
|
else if (id === 'hidden') onToggleShowHidden();
|
|
@@ -1004,7 +1349,8 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
|
|
|
1004
1349
|
<SelectionDropdown
|
|
1005
1350
|
onAction={(id) => {
|
|
1006
1351
|
if (id === 'deselect-all') postToActiveCanvas({ dgn: 'selection-clear' });
|
|
1007
|
-
else if (id === 'select-all-annotations')
|
|
1352
|
+
else if (id === 'select-all-annotations')
|
|
1353
|
+
postToActiveCanvas({ dgn: 'annotation-select-all' });
|
|
1008
1354
|
}}
|
|
1009
1355
|
onClose={() => setOpenMenu(null)}
|
|
1010
1356
|
/>
|
|
@@ -1018,13 +1364,21 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
|
|
|
1018
1364
|
<div className="mb-spacer" />
|
|
1019
1365
|
<div className="mb-status">
|
|
1020
1366
|
<span className="cv-stamp">{stamp}</span>
|
|
1021
|
-
<span className="file" title={activePath || ''}>
|
|
1367
|
+
<span className="file" title={activePath || ''}>
|
|
1368
|
+
{fileLabel}
|
|
1369
|
+
</span>
|
|
1022
1370
|
<span className="sep" />
|
|
1023
|
-
<span
|
|
1371
|
+
<span>
|
|
1372
|
+
<span className="accent-dot">●</span> <b>{tabsCount}</b> ARTBOARDS
|
|
1373
|
+
</span>
|
|
1024
1374
|
<span className="sep" />
|
|
1025
|
-
<span title="Pan/zoom in Phase 4">
|
|
1375
|
+
<span title="Pan/zoom in Phase 4">
|
|
1376
|
+
ZOOM <b>100%</b>
|
|
1377
|
+
</span>
|
|
1026
1378
|
<span className="sep" />
|
|
1027
|
-
<span className="ok"
|
|
1379
|
+
<span className="ok">
|
|
1380
|
+
<b>{project || 'MDCC'}</b>
|
|
1381
|
+
</span>
|
|
1028
1382
|
</div>
|
|
1029
1383
|
</header>
|
|
1030
1384
|
);
|
|
@@ -1033,7 +1387,8 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
|
|
|
1033
1387
|
function ThemeToggle({ theme, onToggle }) {
|
|
1034
1388
|
// Show the icon of the theme you'll switch TO — clearer affordance than current state.
|
|
1035
1389
|
// Sun + Moon paths are condensed Lucide-style (single-path so the existing <Icon> works).
|
|
1036
|
-
const sun =
|
|
1390
|
+
const sun =
|
|
1391
|
+
'M12 7a5 5 0 100 10 5 5 0 000-10z M12 3v1 M12 20v1 M21 12h-1 M4 12H3 M16.95 7.05l-.71.71 M7.05 16.95l-.71.71 M16.95 16.95l-.71-.71 M7.05 7.05l-.71-.71';
|
|
1037
1392
|
const moon = 'M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z';
|
|
1038
1393
|
const next = theme === 'dark' ? 'light' : 'dark';
|
|
1039
1394
|
return (
|
|
@@ -1065,41 +1420,75 @@ function Wordmark({ project, port, version }) {
|
|
|
1065
1420
|
);
|
|
1066
1421
|
}
|
|
1067
1422
|
|
|
1068
|
-
function Viewport({
|
|
1423
|
+
function Viewport({
|
|
1424
|
+
tabs,
|
|
1425
|
+
activePath,
|
|
1426
|
+
registerIframe,
|
|
1427
|
+
systemData,
|
|
1428
|
+
onOpenFromSystem,
|
|
1429
|
+
onSelectDs,
|
|
1430
|
+
project,
|
|
1431
|
+
cfg,
|
|
1432
|
+
}) {
|
|
1069
1433
|
return (
|
|
1070
1434
|
<div className="viewport">
|
|
1071
1435
|
{tabs.length === 0 && (
|
|
1072
1436
|
<>
|
|
1073
|
-
<Wordmark
|
|
1437
|
+
<Wordmark
|
|
1438
|
+
project={project}
|
|
1439
|
+
port={typeof window !== 'undefined' ? window.location.port : ''}
|
|
1440
|
+
version={MDCC_VERSION}
|
|
1441
|
+
/>
|
|
1074
1442
|
<div className="empty-state">
|
|
1075
1443
|
<div className="big">No mock open</div>
|
|
1076
1444
|
<div className="small">
|
|
1077
|
-
← Click a <code>.tsx</code> (or legacy <code>.html</code>) file in the tree, or open
|
|
1078
|
-
<
|
|
1079
|
-
|
|
1080
|
-
<br
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1445
|
+
← Click a <code>.tsx</code> (or legacy <code>.html</code>) file in the tree, or open
|
|
1446
|
+
the <strong>Design system</strong> view above it.
|
|
1447
|
+
<br />
|
|
1448
|
+
<br />
|
|
1449
|
+
Tabs work like in an editor — close with the × on each tab. <kbd>⌘R</kbd> reloads the
|
|
1450
|
+
active iframe.
|
|
1451
|
+
<br />
|
|
1452
|
+
<br />
|
|
1453
|
+
<strong>Element selection:</strong> hold <kbd>⌘</kbd> inside the canvas and hover for
|
|
1454
|
+
a preview, click to select. <kbd>⌘⇧</kbd>+click adds to a multi-selection.{' '}
|
|
1455
|
+
<kbd>V</kbd>/<kbd>H</kbd>/<kbd>C</kbd> swap tool; right-click opens the context menu.
|
|
1456
|
+
<br />
|
|
1457
|
+
<br />
|
|
1458
|
+
Active file, selection, and comments are tracked in <code>_active.json</code> +{' '}
|
|
1459
|
+
<code>_comments/</code> — Claude reads them when you run <code>/design</code>.
|
|
1084
1460
|
</div>
|
|
1085
1461
|
</div>
|
|
1086
1462
|
</>
|
|
1087
1463
|
)}
|
|
1088
|
-
{tabs.map(t => {
|
|
1464
|
+
{tabs.map((t) => {
|
|
1089
1465
|
if (t.path === SYSTEM_TAB) {
|
|
1090
1466
|
return (
|
|
1091
1467
|
<div key={t.path} className={'system-view' + (t.path === activePath ? ' active' : '')}>
|
|
1092
|
-
<SystemView
|
|
1468
|
+
<SystemView
|
|
1469
|
+
data={systemData}
|
|
1470
|
+
onOpen={onOpenFromSystem}
|
|
1471
|
+
cfg={cfg}
|
|
1472
|
+
onSelectDs={onSelectDs}
|
|
1473
|
+
/>
|
|
1093
1474
|
</div>
|
|
1094
1475
|
);
|
|
1095
1476
|
}
|
|
1096
1477
|
return (
|
|
1097
1478
|
<iframe
|
|
1098
1479
|
key={t.path}
|
|
1099
|
-
ref={el => registerIframe(t.path, el)}
|
|
1480
|
+
ref={(el) => registerIframe(t.path, el)}
|
|
1100
1481
|
src={canvasUrl(t.path, cfg)}
|
|
1101
1482
|
className={t.path === activePath ? 'active' : ''}
|
|
1102
1483
|
data-path={t.path}
|
|
1484
|
+
// T2 (9.1-A) — only sandbox + delegate clipboard when the canvas is
|
|
1485
|
+
// served cross-origin (canvasOrigin present = the split is on). In
|
|
1486
|
+
// the default same-origin mode these attrs are omitted so behavior
|
|
1487
|
+
// is identical to pre-9.1. allow-same-origin gives the cross-origin
|
|
1488
|
+
// frame its OWN origin (own WS/fetch/storage), NOT the parent's.
|
|
1489
|
+
{...(cfg?.canvasOrigin
|
|
1490
|
+
? { sandbox: 'allow-scripts allow-same-origin', allow: 'clipboard-write' }
|
|
1491
|
+
: {})}
|
|
1103
1492
|
/>
|
|
1104
1493
|
);
|
|
1105
1494
|
})}
|
|
@@ -1119,7 +1508,15 @@ function Viewport({ tabs, activePath, registerIframe, systemData, onOpenFromSyst
|
|
|
1119
1508
|
// Order kinds match the typical reading flow of a tokens file. Unknown kinds
|
|
1120
1509
|
// fall through to `other` so a custom token group still renders, just last.
|
|
1121
1510
|
const TOKEN_GROUP_ORDER = [
|
|
1122
|
-
'color',
|
|
1511
|
+
'color',
|
|
1512
|
+
'space',
|
|
1513
|
+
'radius',
|
|
1514
|
+
'shadow',
|
|
1515
|
+
'leading',
|
|
1516
|
+
'weight',
|
|
1517
|
+
'motion',
|
|
1518
|
+
'font',
|
|
1519
|
+
'other',
|
|
1123
1520
|
];
|
|
1124
1521
|
const TOKEN_GROUP_LABELS = {
|
|
1125
1522
|
color: 'colors',
|
|
@@ -1133,17 +1530,22 @@ const TOKEN_GROUP_LABELS = {
|
|
|
1133
1530
|
other: 'other',
|
|
1134
1531
|
};
|
|
1135
1532
|
|
|
1136
|
-
function isSwatchKind(kind) {
|
|
1533
|
+
function isSwatchKind(kind) {
|
|
1534
|
+
return kind === 'color';
|
|
1535
|
+
}
|
|
1137
1536
|
|
|
1138
1537
|
function TokenLadder({ tokens, tokenGroups, tokensPath }) {
|
|
1139
1538
|
if (!tokens || tokens.length === 0) {
|
|
1140
1539
|
return (
|
|
1141
1540
|
<section className="sv-section sv-section-tokens">
|
|
1142
|
-
<h2>
|
|
1541
|
+
<h2>
|
|
1542
|
+
tokens<span className="sv-h-num">0</span>
|
|
1543
|
+
</h2>
|
|
1143
1544
|
<div className="sv-empty">
|
|
1144
1545
|
<p>
|
|
1145
|
-
No tokens parsed from
|
|
1146
|
-
|
|
1546
|
+
No tokens parsed from{' '}
|
|
1547
|
+
{tokensPath ? <code>{tokensPath}</code> : 'the configured tokens file'}. Does the file
|
|
1548
|
+
exist and contain CSS custom properties (<code>--name: value;</code>)?
|
|
1147
1549
|
</p>
|
|
1148
1550
|
</div>
|
|
1149
1551
|
</section>
|
|
@@ -1151,9 +1553,9 @@ function TokenLadder({ tokens, tokenGroups, tokensPath }) {
|
|
|
1151
1553
|
}
|
|
1152
1554
|
|
|
1153
1555
|
const groups = tokenGroups || {};
|
|
1154
|
-
const kinds = Array.from(
|
|
1155
|
-
|
|
1156
|
-
)
|
|
1556
|
+
const kinds = Array.from(new Set([...TOKEN_GROUP_ORDER, ...Object.keys(groups)])).filter(
|
|
1557
|
+
(k) => groups[k]?.length
|
|
1558
|
+
);
|
|
1157
1559
|
|
|
1158
1560
|
return (
|
|
1159
1561
|
<>
|
|
@@ -1192,9 +1594,11 @@ function findLeadingFor(typeToken, leadingTokens) {
|
|
|
1192
1594
|
const m = typeToken.name.match(/^--(?:type|fs|text)-(.+)$/);
|
|
1193
1595
|
if (!m) return null;
|
|
1194
1596
|
const suffix = m[1];
|
|
1195
|
-
return (
|
|
1196
|
-
|
|
1197
|
-
|
|
1597
|
+
return (
|
|
1598
|
+
(leadingTokens || []).find(
|
|
1599
|
+
(t) => /^--(?:lh|leading|line-height)-/.test(t.name) && t.name.endsWith('-' + suffix)
|
|
1600
|
+
)?.value ?? null
|
|
1601
|
+
);
|
|
1198
1602
|
}
|
|
1199
1603
|
|
|
1200
1604
|
// Best-effort sample font: prefer body / sans / display tokens, fall back to
|
|
@@ -1243,7 +1647,11 @@ function TypeLadder({ tokenGroups }) {
|
|
|
1243
1647
|
|
|
1244
1648
|
function SystemView({ data, onOpen, cfg, onSelectDs }) {
|
|
1245
1649
|
if (!data) {
|
|
1246
|
-
return
|
|
1650
|
+
return (
|
|
1651
|
+
<div className="sv-empty">
|
|
1652
|
+
<p>Loading design system…</p>
|
|
1653
|
+
</div>
|
|
1654
|
+
);
|
|
1247
1655
|
}
|
|
1248
1656
|
const {
|
|
1249
1657
|
previewGallery,
|
|
@@ -1255,7 +1663,8 @@ function SystemView({ data, onOpen, cfg, onSelectDs }) {
|
|
|
1255
1663
|
ds,
|
|
1256
1664
|
availableDesignSystems,
|
|
1257
1665
|
} = data;
|
|
1258
|
-
const empty =
|
|
1666
|
+
const empty =
|
|
1667
|
+
(!previewGallery || !previewGallery.length) && (!uiKitsGallery || !uiKitsGallery.length);
|
|
1259
1668
|
const hasPicker = Array.isArray(availableDesignSystems) && availableDesignSystems.length > 1;
|
|
1260
1669
|
const selectedName = ds?.name ?? availableDesignSystems?.[0]?.name ?? '';
|
|
1261
1670
|
|
|
@@ -1267,34 +1676,42 @@ function SystemView({ data, onOpen, cfg, onSelectDs }) {
|
|
|
1267
1676
|
{hasPicker ? (
|
|
1268
1677
|
<label className="sv-ds-picker">
|
|
1269
1678
|
<span className="sv-ds-picker-label">DS</span>
|
|
1270
|
-
<select
|
|
1271
|
-
value={selectedName}
|
|
1272
|
-
onChange={(e) => onSelectDs && onSelectDs(e.target.value)}
|
|
1273
|
-
>
|
|
1679
|
+
<select value={selectedName} onChange={(e) => onSelectDs && onSelectDs(e.target.value)}>
|
|
1274
1680
|
{availableDesignSystems.map((d) => (
|
|
1275
|
-
<option key={d.name} value={d.name}>
|
|
1681
|
+
<option key={d.name} value={d.name}>
|
|
1682
|
+
{d.name}
|
|
1683
|
+
</option>
|
|
1276
1684
|
))}
|
|
1277
1685
|
</select>
|
|
1278
1686
|
</label>
|
|
1279
1687
|
) : null}
|
|
1280
|
-
<span className="sv-loc"
|
|
1688
|
+
<span className="sv-loc">
|
|
1689
|
+
<code>{systemDir}</code>
|
|
1690
|
+
</span>
|
|
1281
1691
|
</header>
|
|
1282
1692
|
|
|
1283
|
-
{ds?.description ?
|
|
1284
|
-
<p className="sv-ds-description">{ds.description}</p>
|
|
1285
|
-
) : null}
|
|
1693
|
+
{ds?.description ? <p className="sv-ds-description">{ds.description}</p> : null}
|
|
1286
1694
|
|
|
1287
1695
|
<TokenLadder tokens={tokens} tokenGroups={tokenGroups} tokensPath={tokensPath} />
|
|
1288
1696
|
<TypeLadder tokenGroups={tokenGroups} />
|
|
1289
1697
|
|
|
1290
1698
|
{empty ? (
|
|
1291
1699
|
<div className="sv-empty">
|
|
1292
|
-
<p>
|
|
1700
|
+
<p>
|
|
1701
|
+
No <code>preview/</code> or <code>ui_kits/</code> folders found under{' '}
|
|
1702
|
+
<code>{systemDir}</code>.
|
|
1703
|
+
</p>
|
|
1293
1704
|
</div>
|
|
1294
1705
|
) : (
|
|
1295
1706
|
<>
|
|
1296
|
-
<Gallery
|
|
1297
|
-
|
|
1707
|
+
<Gallery
|
|
1708
|
+
title="preview"
|
|
1709
|
+
items={previewGallery}
|
|
1710
|
+
onOpen={onOpen}
|
|
1711
|
+
kind="preview"
|
|
1712
|
+
cfg={cfg}
|
|
1713
|
+
/>
|
|
1714
|
+
<Gallery title="ui kits" items={uiKitsGallery} onOpen={onOpen} kind="ui_kits" cfg={cfg} />
|
|
1298
1715
|
</>
|
|
1299
1716
|
)}
|
|
1300
1717
|
</div>
|
|
@@ -1305,12 +1722,19 @@ function Gallery({ title, items, onOpen, kind, cfg }) {
|
|
|
1305
1722
|
if (!items || items.length === 0) return null;
|
|
1306
1723
|
return (
|
|
1307
1724
|
<section className="sv-section">
|
|
1308
|
-
<h2>
|
|
1725
|
+
<h2>
|
|
1726
|
+
{title} <span className="sv-count">{items.length}</span>
|
|
1727
|
+
</h2>
|
|
1309
1728
|
<div className={'sv-previews sv-previews-' + kind}>
|
|
1310
|
-
{items.map(p => (
|
|
1729
|
+
{items.map((p) => (
|
|
1311
1730
|
<article key={p.path} className="sv-preview-card" onClick={() => onOpen(p.path)}>
|
|
1312
1731
|
<div className="sv-preview-frame">
|
|
1313
|
-
<iframe
|
|
1732
|
+
<iframe
|
|
1733
|
+
src={canvasUrl(p.path, cfg, { thumbnail: true })}
|
|
1734
|
+
title={p.label}
|
|
1735
|
+
scrolling="no"
|
|
1736
|
+
{...(cfg?.canvasOrigin ? { sandbox: 'allow-scripts allow-same-origin' } : {})}
|
|
1737
|
+
/>
|
|
1314
1738
|
</div>
|
|
1315
1739
|
<div className="sv-preview-foot">
|
|
1316
1740
|
<strong>{p.label}</strong>
|
|
@@ -1331,7 +1755,7 @@ function CommentBar({ activePath, comments }) {
|
|
|
1331
1755
|
// bubbles + thread popover. BottomBar shrinks to a live open-count summary
|
|
1332
1756
|
// so the shell still surfaces total review activity at a glance.
|
|
1333
1757
|
if (!activePath) return null;
|
|
1334
|
-
const openComments = (comments || []).filter(c => c.status !== 'resolved');
|
|
1758
|
+
const openComments = (comments || []).filter((c) => c.status !== 'resolved');
|
|
1335
1759
|
if (openComments.length === 0) return null;
|
|
1336
1760
|
return (
|
|
1337
1761
|
<div className="comment-bar">
|
|
@@ -1352,26 +1776,53 @@ function StatusBarSlot({ label, children, className = '' }) {
|
|
|
1352
1776
|
);
|
|
1353
1777
|
}
|
|
1354
1778
|
|
|
1355
|
-
function StatusBar({
|
|
1779
|
+
function StatusBar({
|
|
1780
|
+
activePath,
|
|
1781
|
+
selected,
|
|
1782
|
+
wsConnected,
|
|
1783
|
+
openCount,
|
|
1784
|
+
theme,
|
|
1785
|
+
onToggleTheme,
|
|
1786
|
+
onClearSelected,
|
|
1787
|
+
syncStatus,
|
|
1788
|
+
}) {
|
|
1356
1789
|
const isSystem = activePath === SYSTEM_TAB;
|
|
1357
|
-
const text =
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1790
|
+
const text =
|
|
1791
|
+
selected && selected.selector
|
|
1792
|
+
? selected.selector + (selected.text ? ` — "${selected.text.slice(0, 60)}"` : '')
|
|
1793
|
+
: '';
|
|
1794
|
+
const title =
|
|
1795
|
+
selected && selected.dom_path
|
|
1796
|
+
? selected.dom_path.join(' > ')
|
|
1797
|
+
: selected
|
|
1798
|
+
? selected.selector
|
|
1799
|
+
: '';
|
|
1361
1800
|
return (
|
|
1362
1801
|
<div className="statusbar" role="contentinfo">
|
|
1363
1802
|
<StatusBarSlot label="Active file" className="sb-active">
|
|
1364
1803
|
<span className="sb-key">active</span>
|
|
1365
1804
|
<span className="sb-file" title={activePath || ''}>
|
|
1366
|
-
{isSystem ? '▦ design system' :
|
|
1805
|
+
{isSystem ? '▦ design system' : activePath || '—'}
|
|
1367
1806
|
</span>
|
|
1368
1807
|
</StatusBarSlot>
|
|
1369
1808
|
|
|
1370
1809
|
{selected && selected.selector && !isSystem && (
|
|
1371
1810
|
<StatusBarSlot label="Selected element" className="sb-selected">
|
|
1372
|
-
<span className="sb-dot" aria-hidden="true"
|
|
1373
|
-
|
|
1374
|
-
|
|
1811
|
+
<span className="sb-dot" aria-hidden="true">
|
|
1812
|
+
●
|
|
1813
|
+
</span>
|
|
1814
|
+
<span className="sb-sel-text" title={title}>
|
|
1815
|
+
{text}
|
|
1816
|
+
</span>
|
|
1817
|
+
<button
|
|
1818
|
+
type="button"
|
|
1819
|
+
className="sb-clear-sel"
|
|
1820
|
+
onClick={onClearSelected}
|
|
1821
|
+
title="Clear (Esc inside iframe)"
|
|
1822
|
+
aria-label="Clear selection"
|
|
1823
|
+
>
|
|
1824
|
+
×
|
|
1825
|
+
</button>
|
|
1375
1826
|
</StatusBarSlot>
|
|
1376
1827
|
)}
|
|
1377
1828
|
|
|
@@ -1385,6 +1836,21 @@ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onTogg
|
|
|
1385
1836
|
<span className="sb-key">{wsConnected ? 'live' : 'reconnecting'}</span>
|
|
1386
1837
|
</StatusBarSlot>
|
|
1387
1838
|
|
|
1839
|
+
{/* DDR-060 / 9.1-D — linked-to-hub-but-nothing-syncs state lives here in the
|
|
1840
|
+
status bar (DS-styled, hover for detail) instead of a floating off-brand
|
|
1841
|
+
pill. Only shown when the project is linked + has 0 syncable canvases. */}
|
|
1842
|
+
{syncStatus?.notSyncable && (
|
|
1843
|
+
<StatusBarSlot label="Hub sync" className="sb-sync">
|
|
1844
|
+
<span className="sb-sync-dot" aria-hidden="true" />
|
|
1845
|
+
<span
|
|
1846
|
+
className="sb-key"
|
|
1847
|
+
title={syncStatus.reason || 'Linked to a hub, but no canvases are syncable.'}
|
|
1848
|
+
>
|
|
1849
|
+
0 syncable{syncStatus.tsxCount > 0 ? ` · ${syncStatus.tsxCount} tsx` : ''}
|
|
1850
|
+
</span>
|
|
1851
|
+
</StatusBarSlot>
|
|
1852
|
+
)}
|
|
1853
|
+
|
|
1388
1854
|
<span className="sb-spacer" />
|
|
1389
1855
|
|
|
1390
1856
|
<StatusBarSlot label="Theme" className="sb-theme">
|
|
@@ -1396,26 +1862,36 @@ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onTogg
|
|
|
1396
1862
|
|
|
1397
1863
|
// ---------- Right sidebar — Comments panel ----------
|
|
1398
1864
|
|
|
1399
|
-
function CommentsPanel({
|
|
1865
|
+
function CommentsPanel({
|
|
1866
|
+
commentsByFile,
|
|
1867
|
+
filter,
|
|
1868
|
+
setFilter,
|
|
1869
|
+
activePath,
|
|
1870
|
+
focusedId,
|
|
1871
|
+
onJump,
|
|
1872
|
+
onResolve,
|
|
1873
|
+
onReopen,
|
|
1874
|
+
onDelete,
|
|
1875
|
+
}) {
|
|
1400
1876
|
const counts = totalCounts(commentsByFile);
|
|
1401
1877
|
// Build groups: [{ file, comments: filtered }]
|
|
1402
1878
|
const files = Object.keys(commentsByFile || {}).sort();
|
|
1403
1879
|
const groups = [];
|
|
1404
1880
|
for (const f of files) {
|
|
1405
1881
|
const all = commentsByFile[f] || [];
|
|
1406
|
-
const filtered = all.filter(c => {
|
|
1882
|
+
const filtered = all.filter((c) => {
|
|
1407
1883
|
if (filter === 'open') return c.status !== 'resolved';
|
|
1408
1884
|
if (filter === 'resolved') return c.status === 'resolved';
|
|
1409
1885
|
return true;
|
|
1410
1886
|
});
|
|
1411
1887
|
if (filtered.length === 0) continue;
|
|
1412
1888
|
// Number is fixed by all-list order so it matches pin numbers (which are based on position in the array of selector-having comments)
|
|
1413
|
-
const numberedAll = all.filter(c => c.selector);
|
|
1889
|
+
const numberedAll = all.filter((c) => c.selector);
|
|
1414
1890
|
groups.push({
|
|
1415
1891
|
file: f,
|
|
1416
|
-
comments: filtered.map(c => ({
|
|
1892
|
+
comments: filtered.map((c) => ({
|
|
1417
1893
|
...c,
|
|
1418
|
-
n: numberedAll.findIndex(x => x.id === c.id) + 1,
|
|
1894
|
+
n: numberedAll.findIndex((x) => x.id === c.id) + 1,
|
|
1419
1895
|
})),
|
|
1420
1896
|
});
|
|
1421
1897
|
}
|
|
@@ -1430,66 +1906,177 @@ function CommentsPanel({ commentsByFile, filter, setFilter, activePath, focusedI
|
|
|
1430
1906
|
<div className="rsidebar-filters" role="tablist">
|
|
1431
1907
|
<button
|
|
1432
1908
|
className={'rsidebar-filter' + (filter === 'all' ? ' active' : '')}
|
|
1433
|
-
role="tab"
|
|
1909
|
+
role="tab"
|
|
1910
|
+
aria-selected={filter === 'all'}
|
|
1434
1911
|
onClick={() => setFilter('all')}
|
|
1435
|
-
>
|
|
1912
|
+
>
|
|
1913
|
+
All <span className="fc">{counts.all}</span>
|
|
1914
|
+
</button>
|
|
1436
1915
|
<button
|
|
1437
1916
|
className={'rsidebar-filter' + (filter === 'open' ? ' active' : '')}
|
|
1438
|
-
role="tab"
|
|
1917
|
+
role="tab"
|
|
1918
|
+
aria-selected={filter === 'open'}
|
|
1439
1919
|
onClick={() => setFilter('open')}
|
|
1440
|
-
>
|
|
1920
|
+
>
|
|
1921
|
+
Open <span className="fc">{counts.open}</span>
|
|
1922
|
+
</button>
|
|
1441
1923
|
<button
|
|
1442
1924
|
className={'rsidebar-filter' + (filter === 'resolved' ? ' active' : '')}
|
|
1443
|
-
role="tab"
|
|
1925
|
+
role="tab"
|
|
1926
|
+
aria-selected={filter === 'resolved'}
|
|
1444
1927
|
onClick={() => setFilter('resolved')}
|
|
1445
|
-
>
|
|
1928
|
+
>
|
|
1929
|
+
Resolved <span className="fc">{counts.resolved}</span>
|
|
1930
|
+
</button>
|
|
1446
1931
|
</div>
|
|
1447
1932
|
</div>
|
|
1448
1933
|
<div className="rsidebar-body">
|
|
1449
1934
|
{groups.length === 0 ? (
|
|
1450
1935
|
<div className="rsidebar-empty">
|
|
1451
1936
|
<p>No comments {filter !== 'all' ? `with status “${filter}”` : 'yet'}.</p>
|
|
1452
|
-
<p style={{ marginTop: 12 }}>
|
|
1937
|
+
<p style={{ marginTop: 12 }}>
|
|
1938
|
+
Open a canvas, hold <kbd>⌘</kbd> and click an element, then press <kbd>C</kbd> — or
|
|
1939
|
+
hold <kbd>⌘⇧</kbd> and click directly.
|
|
1940
|
+
</p>
|
|
1453
1941
|
</div>
|
|
1454
|
-
) :
|
|
1455
|
-
|
|
1456
|
-
<
|
|
1457
|
-
className="rs-group-h"
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
<
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
<
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1942
|
+
) : (
|
|
1943
|
+
groups.map((g) => (
|
|
1944
|
+
<div key={g.file} className="rs-group">
|
|
1945
|
+
<button className="rs-group-h" onClick={() => onJump(g.file, null)} title={g.file}>
|
|
1946
|
+
<span className="rs-group-name">{displayName(basename(g.file))}</span>
|
|
1947
|
+
<span className="rs-group-count">{g.comments.length}</span>
|
|
1948
|
+
</button>
|
|
1949
|
+
{g.comments.map((c) => (
|
|
1950
|
+
<div
|
|
1951
|
+
key={c.id}
|
|
1952
|
+
className={
|
|
1953
|
+
'rs-comment' +
|
|
1954
|
+
(c.status === 'resolved' ? ' resolved' : '') +
|
|
1955
|
+
(c.id === focusedId ? ' active-pin' : '')
|
|
1956
|
+
}
|
|
1957
|
+
onClick={() => onJump(g.file, c.id)}
|
|
1958
|
+
>
|
|
1959
|
+
<div className="rs-comment-head">
|
|
1960
|
+
<span className="rs-num">{c.n || '·'}</span>
|
|
1961
|
+
<span className="rs-time">{timeAgo(c.created)}</span>
|
|
1962
|
+
</div>
|
|
1963
|
+
<div className="rs-comment-text">{c.text}</div>
|
|
1964
|
+
<div className="rs-comment-foot">
|
|
1965
|
+
<code title={(c.dom_path || []).join(' > ')}>{c.selector || '—'}</code>
|
|
1966
|
+
<div className="rs-comment-actions">
|
|
1967
|
+
{c.status === 'resolved' ? (
|
|
1968
|
+
<button
|
|
1969
|
+
className="rs-act"
|
|
1970
|
+
onClick={(e) => {
|
|
1971
|
+
e.stopPropagation();
|
|
1972
|
+
onReopen(c.id);
|
|
1973
|
+
}}
|
|
1974
|
+
>
|
|
1975
|
+
↺
|
|
1976
|
+
</button>
|
|
1977
|
+
) : (
|
|
1978
|
+
<button
|
|
1979
|
+
className="rs-act"
|
|
1980
|
+
onClick={(e) => {
|
|
1981
|
+
e.stopPropagation();
|
|
1982
|
+
onResolve(c.id);
|
|
1983
|
+
}}
|
|
1984
|
+
>
|
|
1985
|
+
✓
|
|
1986
|
+
</button>
|
|
1987
|
+
)}
|
|
1988
|
+
<button
|
|
1989
|
+
className="rs-act danger"
|
|
1990
|
+
onClick={(e) => {
|
|
1991
|
+
e.stopPropagation();
|
|
1992
|
+
onDelete(c.id);
|
|
1993
|
+
}}
|
|
1994
|
+
>
|
|
1995
|
+
×
|
|
1996
|
+
</button>
|
|
1997
|
+
</div>
|
|
1482
1998
|
</div>
|
|
1483
1999
|
</div>
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
)
|
|
2000
|
+
))}
|
|
2001
|
+
</div>
|
|
2002
|
+
))
|
|
2003
|
+
)}
|
|
1488
2004
|
</div>
|
|
1489
2005
|
</aside>
|
|
1490
2006
|
);
|
|
1491
2007
|
}
|
|
1492
2008
|
|
|
2009
|
+
// ---------- Sync banner (Phase 9 Task 8 — hub-down offline mode) ----------
|
|
2010
|
+
|
|
2011
|
+
// Renders nothing when online with no flash (the common case). Yellow strip
|
|
2012
|
+
// while offline (with queued-edit count), red when offline > 24h, green flash
|
|
2013
|
+
// for 3s right after a reconnect. Driven entirely by the 'sync:status' payload
|
|
2014
|
+
// the dev-server's linked-mode sync runtime broadcasts.
|
|
2015
|
+
function SyncBanner({ status }) {
|
|
2016
|
+
if (!status || status.linked === false) return null;
|
|
2017
|
+
// DDR-060 / 9.1-D — the "linked but 0 syncable" state is surfaced in the
|
|
2018
|
+
// status bar (sb-sync slot), NOT as a floating banner. This component owns
|
|
2019
|
+
// only the transient offline / reconnect-flash banner (Task 8).
|
|
2020
|
+
if (status.notSyncable) return null;
|
|
2021
|
+
const { state, queuedOps, flash, conflicts } = status;
|
|
2022
|
+
const showFlash = flash === 'synced';
|
|
2023
|
+
const offline = state === 'offline' || state === 'offline-long';
|
|
2024
|
+
if (!offline && !showFlash) return null;
|
|
2025
|
+
|
|
2026
|
+
let bg;
|
|
2027
|
+
let fg;
|
|
2028
|
+
let border;
|
|
2029
|
+
let text;
|
|
2030
|
+
if (showFlash) {
|
|
2031
|
+
bg = '#dcfce7';
|
|
2032
|
+
fg = '#166534';
|
|
2033
|
+
border = '#86efac';
|
|
2034
|
+
text = 'Synced with hub';
|
|
2035
|
+
} else if (state === 'offline-long') {
|
|
2036
|
+
bg = '#fee2e2';
|
|
2037
|
+
fg = '#991b1b';
|
|
2038
|
+
border = '#fca5a5';
|
|
2039
|
+
text = `Long offline — ${queuedOps} edit(s) queued. Consider \`git commit && git push\` as backup.`;
|
|
2040
|
+
} else {
|
|
2041
|
+
bg = '#fef9c3';
|
|
2042
|
+
fg = '#854d0e';
|
|
2043
|
+
border = '#fde047';
|
|
2044
|
+
text = `Working offline · ${queuedOps} edit(s) queued · will sync when the hub reconnects.`;
|
|
2045
|
+
}
|
|
2046
|
+
const conflictNote =
|
|
2047
|
+
conflicts && conflicts.length > 0 ? ` (${conflicts.length} conflict notice(s))` : '';
|
|
2048
|
+
|
|
2049
|
+
return (
|
|
2050
|
+
<div
|
|
2051
|
+
role="status"
|
|
2052
|
+
aria-live="polite"
|
|
2053
|
+
style={{
|
|
2054
|
+
position: 'fixed',
|
|
2055
|
+
top: 12,
|
|
2056
|
+
left: '50%',
|
|
2057
|
+
transform: 'translateX(-50%)',
|
|
2058
|
+
zIndex: 10003,
|
|
2059
|
+
display: 'flex',
|
|
2060
|
+
alignItems: 'center',
|
|
2061
|
+
gap: 10,
|
|
2062
|
+
padding: '8px 14px',
|
|
2063
|
+
background: bg,
|
|
2064
|
+
color: fg,
|
|
2065
|
+
border: `1px solid ${border}`,
|
|
2066
|
+
borderRadius: 999,
|
|
2067
|
+
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
|
2068
|
+
font: '500 13px/1.2 system-ui, -apple-system, sans-serif',
|
|
2069
|
+
maxWidth: '80vw',
|
|
2070
|
+
}}
|
|
2071
|
+
>
|
|
2072
|
+
<span>
|
|
2073
|
+
{text}
|
|
2074
|
+
{conflictNote}
|
|
2075
|
+
</span>
|
|
2076
|
+
</div>
|
|
2077
|
+
);
|
|
2078
|
+
}
|
|
2079
|
+
|
|
1493
2080
|
// ---------- App ----------
|
|
1494
2081
|
|
|
1495
2082
|
function App() {
|
|
@@ -1503,6 +2090,9 @@ function App() {
|
|
|
1503
2090
|
// every dirty Y.Doc to disk by the time this state populates, so accepting
|
|
1504
2091
|
// the reload is data-loss-safe (DDR-051 §3).
|
|
1505
2092
|
const [gitLifecycle, setGitLifecycle] = useState(null);
|
|
2093
|
+
// Phase 9 Task 8 — hub-down offline mode banner. Driven by the 'sync:status'
|
|
2094
|
+
// WS message the linked-mode sync runtime emits. null in solo mode.
|
|
2095
|
+
const [syncStatus, setSyncStatus] = useState(null);
|
|
1506
2096
|
const [search, setSearch] = useState('');
|
|
1507
2097
|
const [systemData, setSystemData] = useState(null);
|
|
1508
2098
|
// Loaded once at boot from /_config — informs canvasUrl() so TSX iframes
|
|
@@ -1523,17 +2113,41 @@ function App() {
|
|
|
1523
2113
|
// legacy default; designSystems[0].tokensCssRel is the project's
|
|
1524
2114
|
// authoritative value (post DS-bootstrap).
|
|
1525
2115
|
designSystems: data.designSystems,
|
|
2116
|
+
// T2 (9.1-A) — segregated canvas-content origin. canvasUrl() prepends
|
|
2117
|
+
// it so iframes load cross-origin (hub-pushed JSX is then walled off
|
|
2118
|
+
// from the main origin's /_api). Absent on older servers → relative
|
|
2119
|
+
// URL fallback keeps same-origin behavior.
|
|
2120
|
+
canvasOrigin: data.canvasOrigin,
|
|
1526
2121
|
});
|
|
1527
2122
|
})
|
|
1528
2123
|
.catch(() => {});
|
|
1529
|
-
return () => {
|
|
2124
|
+
return () => {
|
|
2125
|
+
cancelled = true;
|
|
2126
|
+
};
|
|
1530
2127
|
}, []);
|
|
1531
|
-
|
|
2128
|
+
// Backfill the sync banner on mount from /_sync-status. The 'sync:status' WS
|
|
2129
|
+
// broadcast is one-shot for the zero-syncable case (DDR-060 / 9.1-D), so a
|
|
2130
|
+
// tab that connects after boot would otherwise miss it. {linked:false} (solo)
|
|
2131
|
+
// leaves the banner null.
|
|
2132
|
+
useEffect(() => {
|
|
2133
|
+
let cancelled = false;
|
|
2134
|
+
fetch('/_sync-status')
|
|
2135
|
+
.then((r) => r.json())
|
|
2136
|
+
.then((data) => {
|
|
2137
|
+
if (cancelled || !data || data.linked === false) return;
|
|
2138
|
+
setSyncStatus(data);
|
|
2139
|
+
})
|
|
2140
|
+
.catch(() => {});
|
|
2141
|
+
return () => {
|
|
2142
|
+
cancelled = true;
|
|
2143
|
+
};
|
|
2144
|
+
}, []);
|
|
2145
|
+
const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
|
|
1532
2146
|
// Phase 6 — the in-iframe composer owns drafting; the shell no longer holds
|
|
1533
2147
|
// a `draft` state. Mutations route through postMessage → WS instead.
|
|
1534
2148
|
const [focusedCommentId, setFocusedCommentId] = useState(null);
|
|
1535
2149
|
const [commentsPanelOpen, setCommentsPanelOpen] = useState(false);
|
|
1536
|
-
const [commentsFilter, setCommentsFilter] = useState('open');
|
|
2150
|
+
const [commentsFilter, setCommentsFilter] = useState('open'); // 'all' | 'open' | 'resolved'
|
|
1537
2151
|
const [theme, setTheme] = useState(readInitialTheme);
|
|
1538
2152
|
const [openMenu, setOpenMenu] = useState(null);
|
|
1539
2153
|
const [sidebarOpen, setSidebarOpen] = useState(() => readBoolStore(SIDEBAR_STORE, true));
|
|
@@ -1548,18 +2162,25 @@ function App() {
|
|
|
1548
2162
|
// The canvas-shell listens for these `dgn:*` messages and dispatches into the
|
|
1549
2163
|
// matching local provider (annotations visibility / both selection stores /
|
|
1550
2164
|
// tool mode). Mirrors the existing `force-clear` / `select-clear` channel.
|
|
1551
|
-
const postToActiveCanvas = useCallback(
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
2165
|
+
const postToActiveCanvas = useCallback(
|
|
2166
|
+
(payload) => {
|
|
2167
|
+
const el = activePath ? iframesRef.current.get(activePath) : null;
|
|
2168
|
+
if (!el || !el.contentWindow) return;
|
|
2169
|
+
try {
|
|
2170
|
+
el.contentWindow.postMessage(payload, '*');
|
|
2171
|
+
} catch {}
|
|
2172
|
+
},
|
|
2173
|
+
[activePath]
|
|
2174
|
+
);
|
|
1556
2175
|
|
|
1557
2176
|
const toggleAnnotations = useCallback(() => {
|
|
1558
2177
|
setAnnotationsVisible((v) => {
|
|
1559
2178
|
const next = !v;
|
|
1560
2179
|
const el = activePath ? iframesRef.current.get(activePath) : null;
|
|
1561
2180
|
if (el && el.contentWindow) {
|
|
1562
|
-
try {
|
|
2181
|
+
try {
|
|
2182
|
+
el.contentWindow.postMessage({ dgn: 'view-annotations', visible: next }, '*');
|
|
2183
|
+
} catch {}
|
|
1563
2184
|
}
|
|
1564
2185
|
return next;
|
|
1565
2186
|
});
|
|
@@ -1571,21 +2192,40 @@ function App() {
|
|
|
1571
2192
|
document.documentElement.setAttribute('data-theme', theme);
|
|
1572
2193
|
localStorage.setItem(THEME_STORE, theme);
|
|
1573
2194
|
} catch {}
|
|
2195
|
+
// System-review D9 — the canvas-shell chrome (workspace plane, floating
|
|
2196
|
+
// toolbar, minimap, zoom HUD, halos) follows the Maude theme. Broadcast to
|
|
2197
|
+
// EVERY open canvas iframe (not just activePath — several may be open); the
|
|
2198
|
+
// iframe's canvas-shell sets `data-maude-theme` and re-themes its floating
|
|
2199
|
+
// chrome via the --maude-chrome-* family. Artboards keep their DS theme.
|
|
2200
|
+
// Mirrors the git-lifecycle broadcast-to-all loop below. On the initial
|
|
2201
|
+
// mount run iframesRef is empty (no canvas open yet) — a freshly-loaded
|
|
2202
|
+
// iframe instead gets the current theme from the `dgn:'loaded'` handler.
|
|
2203
|
+
for (const el of iframesRef.current.values()) {
|
|
2204
|
+
try {
|
|
2205
|
+
el.contentWindow.postMessage({ dgn: 'theme', theme }, '*');
|
|
2206
|
+
} catch {}
|
|
2207
|
+
}
|
|
1574
2208
|
}, [theme]);
|
|
1575
2209
|
|
|
1576
2210
|
// Persist sidebar / hidden-files / DS-body toggles. Mirror theme pattern.
|
|
1577
2211
|
useEffect(() => {
|
|
1578
|
-
try {
|
|
2212
|
+
try {
|
|
2213
|
+
localStorage.setItem(SIDEBAR_STORE, sidebarOpen ? '1' : '0');
|
|
2214
|
+
} catch {}
|
|
1579
2215
|
}, [sidebarOpen]);
|
|
1580
2216
|
useEffect(() => {
|
|
1581
|
-
try {
|
|
2217
|
+
try {
|
|
2218
|
+
localStorage.setItem(SHOW_HIDDEN_STORE, showHidden ? '1' : '0');
|
|
2219
|
+
} catch {}
|
|
1582
2220
|
}, [showHidden]);
|
|
1583
2221
|
useEffect(() => {
|
|
1584
|
-
try {
|
|
2222
|
+
try {
|
|
2223
|
+
localStorage.setItem(SECTIONS_STORE, JSON.stringify(sectionsExpanded));
|
|
2224
|
+
} catch {}
|
|
1585
2225
|
}, [sectionsExpanded]);
|
|
1586
2226
|
|
|
1587
2227
|
const toggleSection = useCallback((label, defaultOpen) => {
|
|
1588
|
-
setSectionsExpanded(prev => {
|
|
2228
|
+
setSectionsExpanded((prev) => {
|
|
1589
2229
|
const cur = prev[label];
|
|
1590
2230
|
const isOpen = cur === undefined ? defaultOpen : cur;
|
|
1591
2231
|
return { ...prev, [label]: !isOpen };
|
|
@@ -1593,7 +2233,7 @@ function App() {
|
|
|
1593
2233
|
}, []);
|
|
1594
2234
|
|
|
1595
2235
|
const toggleTheme = useCallback(() => {
|
|
1596
|
-
setTheme(t => (t === 'dark' ? 'light' : 'dark'));
|
|
2236
|
+
setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
|
1597
2237
|
}, []);
|
|
1598
2238
|
|
|
1599
2239
|
// ----- Tree -----
|
|
@@ -1602,7 +2242,7 @@ function App() {
|
|
|
1602
2242
|
const r = await fetch('/_index-data');
|
|
1603
2243
|
const data = await r.json();
|
|
1604
2244
|
setProject(data.project || 'Design');
|
|
1605
|
-
const built = data.groups.map(g => ({
|
|
2245
|
+
const built = data.groups.map((g) => ({
|
|
1606
2246
|
...g,
|
|
1607
2247
|
tree: buildTree(g.paths, g.stripPrefix),
|
|
1608
2248
|
}));
|
|
@@ -1612,7 +2252,9 @@ function App() {
|
|
|
1612
2252
|
}
|
|
1613
2253
|
}, []);
|
|
1614
2254
|
|
|
1615
|
-
useEffect(() => {
|
|
2255
|
+
useEffect(() => {
|
|
2256
|
+
loadTree();
|
|
2257
|
+
}, [loadTree]);
|
|
1616
2258
|
|
|
1617
2259
|
// ----- System data (lazy) -----
|
|
1618
2260
|
// `dsName` scopes to a single design-system entry (DDR-048). The initial
|
|
@@ -1655,7 +2297,9 @@ function App() {
|
|
|
1655
2297
|
}
|
|
1656
2298
|
}, []);
|
|
1657
2299
|
|
|
1658
|
-
useEffect(() => {
|
|
2300
|
+
useEffect(() => {
|
|
2301
|
+
loadAllComments();
|
|
2302
|
+
}, [loadAllComments]);
|
|
1659
2303
|
|
|
1660
2304
|
// ----- WebSocket -----
|
|
1661
2305
|
useEffect(() => {
|
|
@@ -1669,7 +2313,7 @@ function App() {
|
|
|
1669
2313
|
setTimeout(connect, 1000);
|
|
1670
2314
|
});
|
|
1671
2315
|
ws.addEventListener('error', () => {});
|
|
1672
|
-
ws.addEventListener('message', e => {
|
|
2316
|
+
ws.addEventListener('message', (e) => {
|
|
1673
2317
|
try {
|
|
1674
2318
|
const m = JSON.parse(e.data);
|
|
1675
2319
|
if (m.type === 'snapshot' && m.state) {
|
|
@@ -1677,14 +2321,22 @@ function App() {
|
|
|
1677
2321
|
} else if (m.type === 'selected') {
|
|
1678
2322
|
setSelected(m.selected);
|
|
1679
2323
|
} else if (m.type === 'comments' && typeof m.file === 'string') {
|
|
1680
|
-
setCommentsByFile(prev => ({ ...prev, [m.file]: m.comments || [] }));
|
|
2324
|
+
setCommentsByFile((prev) => ({ ...prev, [m.file]: m.comments || [] }));
|
|
1681
2325
|
} else if (m.type === 'ai-activity' && typeof m.file === 'string') {
|
|
1682
2326
|
// Phase 8 Task 4 — relay to every open iframe; each canvas's
|
|
1683
2327
|
// AiBanner filters by its own file path. Lightweight broadcast
|
|
1684
2328
|
// (one envelope per change, not per iframe count).
|
|
1685
2329
|
for (const el of iframesRef.current.values()) {
|
|
1686
|
-
try {
|
|
2330
|
+
try {
|
|
2331
|
+
el.contentWindow.postMessage(
|
|
2332
|
+
{ dgn: 'ai-activity', file: m.file, entry: m.entry },
|
|
2333
|
+
'*'
|
|
2334
|
+
);
|
|
2335
|
+
} catch {}
|
|
1687
2336
|
}
|
|
2337
|
+
} else if (m.type === 'sync:status' && m.payload) {
|
|
2338
|
+
// Phase 9 Task 8 — hub connection state for the offline banner.
|
|
2339
|
+
setSyncStatus(m.payload);
|
|
1688
2340
|
} else if (m.type === 'git-lifecycle' && m.payload) {
|
|
1689
2341
|
// Phase 8 Task 7 — branch switch / pull mid-session. Server has
|
|
1690
2342
|
// already flushed every dirty Y.Doc to JSON; just prompt the user.
|
|
@@ -1693,7 +2345,9 @@ function App() {
|
|
|
1693
2345
|
// Also relay to iframes so canvas-level "Reload?" UI (if any)
|
|
1694
2346
|
// can react. Outer banner is the primary prompt.
|
|
1695
2347
|
for (const el of iframesRef.current.values()) {
|
|
1696
|
-
try {
|
|
2348
|
+
try {
|
|
2349
|
+
el.contentWindow.postMessage({ dgn: 'git-lifecycle', payload: m.payload }, '*');
|
|
2350
|
+
} catch {}
|
|
1697
2351
|
}
|
|
1698
2352
|
}
|
|
1699
2353
|
} catch {}
|
|
@@ -1705,7 +2359,9 @@ function App() {
|
|
|
1705
2359
|
|
|
1706
2360
|
function wsSend(obj) {
|
|
1707
2361
|
const ws = wsRef.current;
|
|
1708
|
-
try {
|
|
2362
|
+
try {
|
|
2363
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj));
|
|
2364
|
+
} catch {}
|
|
1709
2365
|
}
|
|
1710
2366
|
|
|
1711
2367
|
// ----- Tab management (single-canvas) -----
|
|
@@ -1714,7 +2370,7 @@ function App() {
|
|
|
1714
2370
|
// (iframesRef, comments push, WS `tabs` message) doesn't need refactoring.
|
|
1715
2371
|
// ARTBOARDS slot in the menubar reads `tabs.length` and reports 0 or 1.
|
|
1716
2372
|
const openTab = useCallback((path) => {
|
|
1717
|
-
setTabs(prev => {
|
|
2373
|
+
setTabs((prev) => {
|
|
1718
2374
|
// Drop the previously-open iframe so we don't leak DOM nodes.
|
|
1719
2375
|
for (const t of prev) if (t.path !== path) iframesRef.current.delete(t.path);
|
|
1720
2376
|
return [{ path }];
|
|
@@ -1723,18 +2379,21 @@ function App() {
|
|
|
1723
2379
|
setFocusedCommentId(null);
|
|
1724
2380
|
}, []);
|
|
1725
2381
|
|
|
1726
|
-
const openSystem = useCallback(
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
2382
|
+
const openSystem = useCallback(
|
|
2383
|
+
(dsName) => {
|
|
2384
|
+
// DsFolderRow passes the clicked DS name → scope the System view to it so
|
|
2385
|
+
// each folder shows its own tokens + previews. The no-arg callers (menubar,
|
|
2386
|
+
// keyboard reopen) only load default data on first open.
|
|
2387
|
+
const ds = typeof dsName === 'string' ? dsName : undefined;
|
|
2388
|
+
if (ds) loadSystemData(ds);
|
|
2389
|
+
else if (!systemData) loadSystemData();
|
|
2390
|
+
openTab(SYSTEM_TAB);
|
|
2391
|
+
},
|
|
2392
|
+
[systemData, loadSystemData, openTab]
|
|
2393
|
+
);
|
|
1735
2394
|
|
|
1736
2395
|
useEffect(() => {
|
|
1737
|
-
wsSend({ type: 'tabs', tabs: tabs.map(t => t.path).filter(p => p !== SYSTEM_TAB) });
|
|
2396
|
+
wsSend({ type: 'tabs', tabs: tabs.map((t) => t.path).filter((p) => p !== SYSTEM_TAB) });
|
|
1738
2397
|
}, [tabs]);
|
|
1739
2398
|
|
|
1740
2399
|
useEffect(() => {
|
|
@@ -1743,19 +2402,22 @@ function App() {
|
|
|
1743
2402
|
else wsSend({ type: 'active', file: '' });
|
|
1744
2403
|
}, [activePath]);
|
|
1745
2404
|
|
|
1746
|
-
const closeTab = useCallback(
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
if (
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
2405
|
+
const closeTab = useCallback(
|
|
2406
|
+
(path) => {
|
|
2407
|
+
setTabs((prev) => {
|
|
2408
|
+
const idx = prev.findIndex((t) => t.path === path);
|
|
2409
|
+
if (idx < 0) return prev;
|
|
2410
|
+
const next = prev.filter((t) => t.path !== path);
|
|
2411
|
+
if (path === activePath) {
|
|
2412
|
+
if (next.length === 0) setActivePath(null);
|
|
2413
|
+
else setActivePath(next[Math.max(0, idx - 1)].path);
|
|
2414
|
+
}
|
|
2415
|
+
return next;
|
|
2416
|
+
});
|
|
2417
|
+
iframesRef.current.delete(path);
|
|
2418
|
+
},
|
|
2419
|
+
[activePath]
|
|
2420
|
+
);
|
|
1759
2421
|
|
|
1760
2422
|
const reloadActive = useCallback(() => {
|
|
1761
2423
|
if (!activePath || activePath === SYSTEM_TAB) {
|
|
@@ -1774,7 +2436,9 @@ function App() {
|
|
|
1774
2436
|
if (activePath && activePath !== SYSTEM_TAB) {
|
|
1775
2437
|
const el = iframesRef.current.get(activePath);
|
|
1776
2438
|
if (el && el.contentWindow) {
|
|
1777
|
-
try {
|
|
2439
|
+
try {
|
|
2440
|
+
el.contentWindow.postMessage({ dgn: 'force-clear' }, '*');
|
|
2441
|
+
} catch {}
|
|
1778
2442
|
}
|
|
1779
2443
|
}
|
|
1780
2444
|
}, [activePath]);
|
|
@@ -1785,14 +2449,47 @@ function App() {
|
|
|
1785
2449
|
const el = iframesRef.current.get(activePath);
|
|
1786
2450
|
if (!el || !el.contentWindow) return;
|
|
1787
2451
|
const list = commentsByFile[activePath] || [];
|
|
1788
|
-
try {
|
|
2452
|
+
try {
|
|
2453
|
+
el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*');
|
|
2454
|
+
} catch {}
|
|
1789
2455
|
}, [activePath, commentsByFile]);
|
|
1790
2456
|
|
|
1791
2457
|
// ----- Inbound messages from iframes -----
|
|
1792
2458
|
useEffect(() => {
|
|
1793
2459
|
function onMessage(e) {
|
|
2460
|
+
// Cross-origin hardening (DDR-054): only accept dgn control messages from
|
|
2461
|
+
// the canvas-content origin — the split origin when on, else our own origin
|
|
2462
|
+
// for the same-origin iframe. Drops spoofed messages from any other window.
|
|
2463
|
+
// The handlers below relay to inert stores (comments / selection — the
|
|
2464
|
+
// "safe to sync" set), so the blast radius was small, but unchecked inbound
|
|
2465
|
+
// postMessage is a confused-deputy seam the F1 hardening should close.
|
|
2466
|
+
const expectedOrigin = cfg?.canvasOrigin || window.location.origin;
|
|
2467
|
+
if (e.origin !== expectedOrigin) return;
|
|
1794
2468
|
const m = e.data;
|
|
1795
2469
|
if (!m || typeof m !== 'object' || !m.dgn) return;
|
|
2470
|
+
if (m.dgn === 'tool-cursor') {
|
|
2471
|
+
// Phase 24 — show the active canvas tool's cursor across the WHOLE app
|
|
2472
|
+
// shell (sidebar, top bar, everything) so the custom cursor is visible
|
|
2473
|
+
// everywhere in maude, not just inside the canvas iframe. The canvas
|
|
2474
|
+
// sends only a tool TOKEN; we resolve it to a cursor string from our own
|
|
2475
|
+
// trusted map (resolveToolCursor) and apply THAT — never a raw
|
|
2476
|
+
// canvas-supplied value. A malicious synced canvas (DDR-054) can thus
|
|
2477
|
+
// only pick a known, always-visible glyph; it cannot inject an
|
|
2478
|
+
// invisible/displaced SVG cursor as a clickjacking aid over the un-CSP'd
|
|
2479
|
+
// shell (phase-24 ethical-hacker Finding 2; DDR-067).
|
|
2480
|
+
const cursor = resolveToolCursor(m.tool);
|
|
2481
|
+
if (cursor) {
|
|
2482
|
+
document.body.style.cursor = cursor;
|
|
2483
|
+
let el = document.getElementById('dc-app-cursor');
|
|
2484
|
+
if (!el) {
|
|
2485
|
+
el = document.createElement('style');
|
|
2486
|
+
el.id = 'dc-app-cursor';
|
|
2487
|
+
document.head.appendChild(el);
|
|
2488
|
+
}
|
|
2489
|
+
el.textContent = `* { cursor: ${cursor} !important; }`;
|
|
2490
|
+
}
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
1796
2493
|
if (m.dgn === 'select' && m.selection) {
|
|
1797
2494
|
wsSend({ type: 'select', selection: m.selection });
|
|
1798
2495
|
setSelected(m.selection);
|
|
@@ -1853,7 +2550,7 @@ function App() {
|
|
|
1853
2550
|
wsSend({ type: 'comments-patch', id: m.id, patch: m.patch });
|
|
1854
2551
|
} else if (m.dgn === 'comment-delete' && m.id) {
|
|
1855
2552
|
wsSend({ type: 'comments-delete', id: m.id });
|
|
1856
|
-
setFocusedCommentId(prev => (prev === m.id ? null : prev));
|
|
2553
|
+
setFocusedCommentId((prev) => (prev === m.id ? null : prev));
|
|
1857
2554
|
} else if (m.dgn === 'comment-click' && m.id) {
|
|
1858
2555
|
setFocusedCommentId(m.id);
|
|
1859
2556
|
} else if (m.dgn === 'loaded' && m.file) {
|
|
@@ -1861,16 +2558,90 @@ function App() {
|
|
|
1861
2558
|
const list = commentsByFile[m.file] || [];
|
|
1862
2559
|
const el = [...iframesRef.current.entries()].find(([k]) => k === m.file)?.[1];
|
|
1863
2560
|
if (el && el.contentWindow) {
|
|
1864
|
-
try {
|
|
1865
|
-
|
|
1866
|
-
|
|
2561
|
+
try {
|
|
2562
|
+
el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*');
|
|
2563
|
+
} catch {}
|
|
2564
|
+
// System-review D9 — seed the just-loaded canvas with the current
|
|
2565
|
+
// chrome theme so a canvas opened AFTER a theme toggle starts
|
|
2566
|
+
// correct (no flash from the dark default).
|
|
2567
|
+
try {
|
|
2568
|
+
el.contentWindow.postMessage({ dgn: 'theme', theme }, '*');
|
|
2569
|
+
} catch {}
|
|
2570
|
+
if (focusedCommentId && list.some((c) => c.id === focusedCommentId)) {
|
|
2571
|
+
try {
|
|
2572
|
+
el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*');
|
|
2573
|
+
} catch {}
|
|
1867
2574
|
}
|
|
1868
2575
|
}
|
|
2576
|
+
} else if (m.dgn === 'export-request' && m.id && m.payload) {
|
|
2577
|
+
// The export dialog renders inside the canvas iframe (canvas origin),
|
|
2578
|
+
// but /_api/export is a privileged MAIN-origin endpoint deliberately
|
|
2579
|
+
// kept off the canvas allowlist (DDR-060). A direct in-iframe fetch
|
|
2580
|
+
// therefore 403s ("Forbidden (canvas origin)"). Bridge it: run the
|
|
2581
|
+
// export here on the trusted main origin, stream the download, and
|
|
2582
|
+
// report status back to the iframe. Origin is already validated
|
|
2583
|
+
// (e.origin === expectedOrigin) above, so only the real canvas iframe
|
|
2584
|
+
// can ask — this is NOT a generic fetch proxy.
|
|
2585
|
+
void runBridgedExport(e.source, m.id, m.payload);
|
|
2586
|
+
} else if (m.dgn === 'export-history-request' && m.id) {
|
|
2587
|
+
// Same bridge for the dialog's Recent tab (/_api/export-history is
|
|
2588
|
+
// also main-origin-only).
|
|
2589
|
+
void runBridgedHistory(e.source, m.id);
|
|
1869
2590
|
}
|
|
1870
2591
|
}
|
|
2592
|
+
// Reply target for the export bridge: the canvas iframe's own origin.
|
|
2593
|
+
const replyOrigin = cfg?.canvasOrigin || window.location.origin;
|
|
2594
|
+
async function runBridgedExport(source, id, payload) {
|
|
2595
|
+
const reply = (msg) => {
|
|
2596
|
+
try {
|
|
2597
|
+
if (source) source.postMessage({ dgn: 'export-result', id, ...msg }, replyOrigin);
|
|
2598
|
+
} catch {}
|
|
2599
|
+
};
|
|
2600
|
+
try {
|
|
2601
|
+
const r = await fetch('/_api/export', {
|
|
2602
|
+
method: 'POST',
|
|
2603
|
+
headers: { 'content-type': 'application/json' },
|
|
2604
|
+
body: JSON.stringify(payload),
|
|
2605
|
+
});
|
|
2606
|
+
if (!r.ok) {
|
|
2607
|
+
reply({ ok: false, error: (await r.text()) || String(r.status) });
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
const disp = r.headers.get('Content-Disposition') || '';
|
|
2611
|
+
const fn = /filename="([^"]+)"/.exec(disp);
|
|
2612
|
+
const filename = (fn && fn[1]) || 'export';
|
|
2613
|
+
const blob = await r.blob();
|
|
2614
|
+
const url = URL.createObjectURL(blob);
|
|
2615
|
+
const a = document.createElement('a');
|
|
2616
|
+
a.href = url;
|
|
2617
|
+
a.download = filename;
|
|
2618
|
+
document.body.appendChild(a);
|
|
2619
|
+
a.click();
|
|
2620
|
+
a.remove();
|
|
2621
|
+
URL.revokeObjectURL(url);
|
|
2622
|
+
reply({ ok: true, filename });
|
|
2623
|
+
} catch (err) {
|
|
2624
|
+
reply({ ok: false, error: err && err.message ? err.message : String(err) });
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
async function runBridgedHistory(source, id) {
|
|
2628
|
+
let history = [];
|
|
2629
|
+
try {
|
|
2630
|
+
const r = await fetch('/_api/export-history');
|
|
2631
|
+
if (r.ok) {
|
|
2632
|
+
const data = await r.json();
|
|
2633
|
+
if (Array.isArray(data.history)) history = data.history;
|
|
2634
|
+
}
|
|
2635
|
+
} catch {
|
|
2636
|
+
/* best-effort — empty list */
|
|
2637
|
+
}
|
|
2638
|
+
try {
|
|
2639
|
+
if (source) source.postMessage({ dgn: 'export-history-result', id, history }, replyOrigin);
|
|
2640
|
+
} catch {}
|
|
2641
|
+
}
|
|
1871
2642
|
window.addEventListener('message', onMessage);
|
|
1872
2643
|
return () => window.removeEventListener('message', onMessage);
|
|
1873
|
-
}, [commentsByFile, focusedCommentId]);
|
|
2644
|
+
}, [commentsByFile, focusedCommentId, cfg, theme]);
|
|
1874
2645
|
|
|
1875
2646
|
// Tell the active canvas iframe to drop any persistent selection (canvas
|
|
1876
2647
|
// SelectionSet) — used when the comment composer closes via submit /
|
|
@@ -1880,7 +2651,9 @@ function App() {
|
|
|
1880
2651
|
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
1881
2652
|
const el = iframesRef.current.get(activePath);
|
|
1882
2653
|
if (el && el.contentWindow) {
|
|
1883
|
-
try {
|
|
2654
|
+
try {
|
|
2655
|
+
el.contentWindow.postMessage({ dgn: 'force-clear' }, '*');
|
|
2656
|
+
} catch {}
|
|
1884
2657
|
}
|
|
1885
2658
|
}, [activePath]);
|
|
1886
2659
|
|
|
@@ -1892,37 +2665,44 @@ function App() {
|
|
|
1892
2665
|
}, []);
|
|
1893
2666
|
const deleteComment = useCallback((id) => {
|
|
1894
2667
|
wsSend({ type: 'comments-delete', id });
|
|
1895
|
-
setFocusedCommentId(prev => (prev === id ? null : prev));
|
|
2668
|
+
setFocusedCommentId((prev) => (prev === id ? null : prev));
|
|
1896
2669
|
}, []);
|
|
1897
2670
|
|
|
1898
2671
|
// Jump from right-sidebar list to a comment: open file tab if needed, focus pin.
|
|
1899
2672
|
// The iframe may be freshly mounted; the loaded handler also re-sends focus if focusedCommentId matches.
|
|
1900
|
-
const jumpToComment = useCallback(
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
if (id == null) {
|
|
1906
|
-
setFocusedCommentId(null);
|
|
1907
|
-
return;
|
|
1908
|
-
}
|
|
1909
|
-
setFocusedCommentId(id);
|
|
1910
|
-
// Try sending focus immediately (existing iframe) and again after a short delay (newly opened tab).
|
|
1911
|
-
const send = () => {
|
|
1912
|
-
const el = iframesRef.current.get(file);
|
|
1913
|
-
if (el && el.contentWindow) {
|
|
1914
|
-
try { el.contentWindow.postMessage({ dgn: 'comment-focus', id }, '*'); } catch {}
|
|
2673
|
+
const jumpToComment = useCallback(
|
|
2674
|
+
(file, id) => {
|
|
2675
|
+
if (file && file !== activePath) {
|
|
2676
|
+
setTabs((prev) => (prev.find((t) => t.path === file) ? prev : [...prev, { path: file }]));
|
|
2677
|
+
setActivePath(file);
|
|
1915
2678
|
}
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
2679
|
+
if (id == null) {
|
|
2680
|
+
setFocusedCommentId(null);
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
setFocusedCommentId(id);
|
|
2684
|
+
// Try sending focus immediately (existing iframe) and again after a short delay (newly opened tab).
|
|
2685
|
+
const send = () => {
|
|
2686
|
+
const el = iframesRef.current.get(file);
|
|
2687
|
+
if (el && el.contentWindow) {
|
|
2688
|
+
try {
|
|
2689
|
+
el.contentWindow.postMessage({ dgn: 'comment-focus', id }, '*');
|
|
2690
|
+
} catch {}
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2693
|
+
send();
|
|
2694
|
+
setTimeout(send, 200);
|
|
2695
|
+
},
|
|
2696
|
+
[activePath]
|
|
2697
|
+
);
|
|
1920
2698
|
|
|
1921
2699
|
// ----- Keyboard shortcuts (no Cmd+W — let browser close the tab) -----
|
|
1922
2700
|
useEffect(() => {
|
|
1923
2701
|
function onKey(e) {
|
|
1924
2702
|
const meta = e.metaKey || e.ctrlKey;
|
|
1925
|
-
const inEditable =
|
|
2703
|
+
const inEditable =
|
|
2704
|
+
['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName) ||
|
|
2705
|
+
document.activeElement?.isContentEditable;
|
|
1926
2706
|
// Phase 4.1: shell-side letter shortcuts (H/T/S) must not double-fire
|
|
1927
2707
|
// inside a focused canvas iframe — the canvas input router owns those
|
|
1928
2708
|
// letters as tool-mode keys (V/H/C). Cmd-modified shortcuts (⌘R, ⌘⇧M,
|
|
@@ -1938,7 +2718,7 @@ function App() {
|
|
|
1938
2718
|
// Cmd+Shift+M / Ctrl+Shift+M — toggle right "Comments" panel
|
|
1939
2719
|
if (meta && e.shiftKey && (e.key === 'm' || e.key === 'M')) {
|
|
1940
2720
|
e.preventDefault();
|
|
1941
|
-
setCommentsPanelOpen(v => !v);
|
|
2721
|
+
setCommentsPanelOpen((v) => !v);
|
|
1942
2722
|
return;
|
|
1943
2723
|
}
|
|
1944
2724
|
// Cmd+C / Ctrl+C — Phase 4.1 removed the shell-side comment-drop chord.
|
|
@@ -1946,8 +2726,18 @@ function App() {
|
|
|
1946
2726
|
// then click the element) or right-click "Add comment". Cmd+C now
|
|
1947
2727
|
// reverts to native browser copy.
|
|
1948
2728
|
if (meta && !e.shiftKey && !e.altKey && (e.key === 'c' || e.key === 'C')) {
|
|
1949
|
-
if (
|
|
1950
|
-
|
|
2729
|
+
if (
|
|
2730
|
+
selected &&
|
|
2731
|
+
selected.selector &&
|
|
2732
|
+
activePath &&
|
|
2733
|
+
activePath !== SYSTEM_TAB &&
|
|
2734
|
+
!inEditable &&
|
|
2735
|
+
console &&
|
|
2736
|
+
console.warn
|
|
2737
|
+
) {
|
|
2738
|
+
console.warn(
|
|
2739
|
+
'Cmd+C comment-drop deprecated — press C inside the canvas to enter Comment tool, then click the element.'
|
|
2740
|
+
);
|
|
1951
2741
|
}
|
|
1952
2742
|
// Fall through to native copy.
|
|
1953
2743
|
}
|
|
@@ -1980,14 +2770,14 @@ function App() {
|
|
|
1980
2770
|
if (e.key === 't' || e.key === 'T') {
|
|
1981
2771
|
if (e.shiftKey || meta) return;
|
|
1982
2772
|
e.preventDefault();
|
|
1983
|
-
setSidebarOpen(v => !v);
|
|
2773
|
+
setSidebarOpen((v) => !v);
|
|
1984
2774
|
return;
|
|
1985
2775
|
}
|
|
1986
2776
|
// H — toggle show-hidden (sidecars + project/runtime orphans)
|
|
1987
2777
|
if (e.key === 'h' || e.key === 'H') {
|
|
1988
2778
|
if (e.shiftKey || meta) return;
|
|
1989
2779
|
e.preventDefault();
|
|
1990
|
-
setShowHidden(v => !v);
|
|
2780
|
+
setShowHidden((v) => !v);
|
|
1991
2781
|
return;
|
|
1992
2782
|
}
|
|
1993
2783
|
// S — toggle Design system view
|
|
@@ -2009,18 +2799,31 @@ function App() {
|
|
|
2009
2799
|
// Esc — clear focused pin. The in-place composer (Phase 6) and thread
|
|
2010
2800
|
// popover handle their own Esc inside the iframe.
|
|
2011
2801
|
if (e.key === 'Escape') {
|
|
2012
|
-
if (focusedCommentId) {
|
|
2802
|
+
if (focusedCommentId) {
|
|
2803
|
+
setFocusedCommentId(null);
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2013
2806
|
}
|
|
2014
2807
|
}
|
|
2015
2808
|
window.addEventListener('keydown', onKey);
|
|
2016
2809
|
return () => window.removeEventListener('keydown', onKey);
|
|
2017
|
-
}, [
|
|
2810
|
+
}, [
|
|
2811
|
+
reloadActive,
|
|
2812
|
+
selected,
|
|
2813
|
+
activePath,
|
|
2814
|
+
focusedCommentId,
|
|
2815
|
+
sidebarOpen,
|
|
2816
|
+
openSystem,
|
|
2817
|
+
closeTab,
|
|
2818
|
+
clearActiveCanvasSelection,
|
|
2819
|
+
]);
|
|
2018
2820
|
|
|
2019
2821
|
const registerIframe = useCallback((path, el) => {
|
|
2020
2822
|
if (el) iframesRef.current.set(path, el);
|
|
2021
2823
|
}, []);
|
|
2022
2824
|
|
|
2023
|
-
const activeFileComments =
|
|
2825
|
+
const activeFileComments =
|
|
2826
|
+
activePath && activePath !== SYSTEM_TAB ? commentsByFile[activePath] || [] : [];
|
|
2024
2827
|
const totalOpen = totalCounts(commentsByFile).open;
|
|
2025
2828
|
|
|
2026
2829
|
// Suppress the native browser context menu across the shell — the canvas
|
|
@@ -2031,7 +2834,7 @@ function App() {
|
|
|
2031
2834
|
// copy/paste still works.
|
|
2032
2835
|
const onShellContextMenu = useCallback((e) => {
|
|
2033
2836
|
const t = e.target;
|
|
2034
|
-
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
|
2837
|
+
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
|
|
2035
2838
|
return;
|
|
2036
2839
|
}
|
|
2037
2840
|
e.preventDefault();
|
|
@@ -2039,9 +2842,12 @@ function App() {
|
|
|
2039
2842
|
|
|
2040
2843
|
return (
|
|
2041
2844
|
<div
|
|
2042
|
-
className={
|
|
2845
|
+
className={
|
|
2846
|
+
'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')
|
|
2847
|
+
}
|
|
2043
2848
|
onContextMenu={onShellContextMenu}
|
|
2044
2849
|
>
|
|
2850
|
+
<SyncBanner status={syncStatus} />
|
|
2045
2851
|
{gitLifecycle && (
|
|
2046
2852
|
<div
|
|
2047
2853
|
role="status"
|
|
@@ -2067,7 +2873,11 @@ function App() {
|
|
|
2067
2873
|
<span>Repo state changed — reload to sync?</span>
|
|
2068
2874
|
<button
|
|
2069
2875
|
type="button"
|
|
2070
|
-
onClick={() => {
|
|
2876
|
+
onClick={() => {
|
|
2877
|
+
try {
|
|
2878
|
+
window.location.reload();
|
|
2879
|
+
} catch {}
|
|
2880
|
+
}}
|
|
2071
2881
|
style={{
|
|
2072
2882
|
padding: '3px 10px',
|
|
2073
2883
|
background: '#2563eb',
|
|
@@ -2077,7 +2887,9 @@ function App() {
|
|
|
2077
2887
|
font: '500 11px/1.2 system-ui',
|
|
2078
2888
|
cursor: 'pointer',
|
|
2079
2889
|
}}
|
|
2080
|
-
>
|
|
2890
|
+
>
|
|
2891
|
+
Reload
|
|
2892
|
+
</button>
|
|
2081
2893
|
<button
|
|
2082
2894
|
type="button"
|
|
2083
2895
|
onClick={() => setGitLifecycle(null)}
|
|
@@ -2090,7 +2902,9 @@ function App() {
|
|
|
2090
2902
|
font: '500 11px/1.2 system-ui',
|
|
2091
2903
|
cursor: 'pointer',
|
|
2092
2904
|
}}
|
|
2093
|
-
>
|
|
2905
|
+
>
|
|
2906
|
+
Dismiss
|
|
2907
|
+
</button>
|
|
2094
2908
|
</div>
|
|
2095
2909
|
)}
|
|
2096
2910
|
<Sidebar
|
|
@@ -2115,12 +2929,12 @@ function App() {
|
|
|
2115
2929
|
openMenu={openMenu}
|
|
2116
2930
|
setOpenMenu={setOpenMenu}
|
|
2117
2931
|
commentsPanelOpen={commentsPanelOpen}
|
|
2118
|
-
onToggleComments={() => setCommentsPanelOpen(v => !v)}
|
|
2932
|
+
onToggleComments={() => setCommentsPanelOpen((v) => !v)}
|
|
2119
2933
|
onOpenSystem={openSystem}
|
|
2120
2934
|
sidebarOpen={sidebarOpen}
|
|
2121
|
-
onToggleSidebar={() => setSidebarOpen(v => !v)}
|
|
2935
|
+
onToggleSidebar={() => setSidebarOpen((v) => !v)}
|
|
2122
2936
|
showHidden={showHidden}
|
|
2123
|
-
onToggleShowHidden={() => setShowHidden(v => !v)}
|
|
2937
|
+
onToggleShowHidden={() => setShowHidden((v) => !v)}
|
|
2124
2938
|
onOpenHelp={() => setHelpOpen(true)}
|
|
2125
2939
|
annotationsVisible={annotationsVisible}
|
|
2126
2940
|
onToggleAnnotations={toggleAnnotations}
|
|
@@ -2147,6 +2961,7 @@ function App() {
|
|
|
2147
2961
|
theme={theme}
|
|
2148
2962
|
onToggleTheme={toggleTheme}
|
|
2149
2963
|
onClearSelected={clearSelected}
|
|
2964
|
+
syncStatus={syncStatus}
|
|
2150
2965
|
/>
|
|
2151
2966
|
</div>
|
|
2152
2967
|
{commentsPanelOpen && (
|