@1agh/maude 0.15.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/LICENSE +21 -0
- package/README.md +166 -0
- package/cli/bin/maude.exe +15 -0
- package/cli/bin/maude.mjs +45 -0
- package/cli/bin/mdcc.exe +10 -0
- package/cli/bin/mdcc.mjs +7 -0
- package/cli/cli-wrapper.cjs +67 -0
- package/cli/commands/config.mjs +94 -0
- package/cli/commands/design.mjs +386 -0
- package/cli/commands/help.mjs +57 -0
- package/cli/commands/init.mjs +178 -0
- package/cli/commands/version.mjs +7 -0
- package/cli/install.cjs +113 -0
- package/cli/lib/argv.mjs +37 -0
- package/cli/lib/argv.test.mjs +46 -0
- package/cli/lib/copy-tree.mjs +78 -0
- package/package.json +94 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +397 -0
- package/plugins/design/dev-server/annotations-layer.tsx +1717 -0
- package/plugins/design/dev-server/api.ts +674 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +50 -0
- package/plugins/design/dev-server/bin/bootstrap-check.sh +83 -0
- package/plugins/design/dev-server/bin/canvas-edit.sh +48 -0
- package/plugins/design/dev-server/bin/handoff.sh +27 -0
- package/plugins/design/dev-server/bin/screenshot.sh +232 -0
- package/plugins/design/dev-server/bin/server-up.sh +135 -0
- package/plugins/design/dev-server/bin/slug.sh +22 -0
- package/plugins/design/dev-server/bin/smoke.sh +272 -0
- package/plugins/design/dev-server/build.ts +267 -0
- package/plugins/design/dev-server/canvas-build.ts +219 -0
- package/plugins/design/dev-server/canvas-edit.ts +388 -0
- package/plugins/design/dev-server/canvas-header.ts +165 -0
- package/plugins/design/dev-server/canvas-icons.tsx +131 -0
- package/plugins/design/dev-server/canvas-lib-inline.ts +260 -0
- package/plugins/design/dev-server/canvas-lib-resolver.ts +85 -0
- package/plugins/design/dev-server/canvas-lib.tsx +1995 -0
- package/plugins/design/dev-server/canvas-meta.schema.json +181 -0
- package/plugins/design/dev-server/canvas-pipeline.ts +270 -0
- package/plugins/design/dev-server/canvas-shell.tsx +813 -0
- package/plugins/design/dev-server/client/app.jsx +2027 -0
- package/plugins/design/dev-server/client/hmr.mjs +85 -0
- package/plugins/design/dev-server/client/iframe-lazy.mjs +121 -0
- package/plugins/design/dev-server/client/index.html +15 -0
- package/plugins/design/dev-server/client/styles/0-reset.css +18 -0
- package/plugins/design/dev-server/client/styles/1-tokens.css +297 -0
- package/plugins/design/dev-server/client/styles/2-layout.css +35 -0
- package/plugins/design/dev-server/client/styles/3-shell.css +906 -0
- package/plugins/design/dev-server/client/styles/4-components.css +1268 -0
- package/plugins/design/dev-server/client/styles/5-utilities.css +4 -0
- package/plugins/design/dev-server/client/styles/_index.css +24 -0
- package/plugins/design/dev-server/client/styles.css +1419 -0
- package/plugins/design/dev-server/config.schema.json +147 -0
- package/plugins/design/dev-server/context-menu.tsx +343 -0
- package/plugins/design/dev-server/context.ts +173 -0
- package/plugins/design/dev-server/dist/client.bundle.js +20323 -0
- package/plugins/design/dev-server/dist/styles.css +2875 -0
- package/plugins/design/dev-server/examples/README.md +9 -0
- package/plugins/design/dev-server/examples/perf-100-artboards.tsx +113 -0
- package/plugins/design/dev-server/fs-watch.ts +63 -0
- package/plugins/design/dev-server/handoff.ts +721 -0
- package/plugins/design/dev-server/history.ts +125 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +114 -0
- package/plugins/design/dev-server/http.ts +413 -0
- package/plugins/design/dev-server/input-router.tsx +485 -0
- package/plugins/design/dev-server/inspect.ts +365 -0
- package/plugins/design/dev-server/locator.ts +159 -0
- package/plugins/design/dev-server/mem.ts +97 -0
- package/plugins/design/dev-server/runtime-bundle.ts +235 -0
- package/plugins/design/dev-server/server.mjs +1246 -0
- package/plugins/design/dev-server/server.ts +131 -0
- package/plugins/design/dev-server/test/_helpers.ts +81 -0
- package/plugins/design/dev-server/test/active-state.test.ts +145 -0
- package/plugins/design/dev-server/test/annotations-api.test.ts +146 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +419 -0
- package/plugins/design/dev-server/test/binary-smoke.test.ts +47 -0
- package/plugins/design/dev-server/test/bundle-smoke.test.ts +29 -0
- package/plugins/design/dev-server/test/canvas-build.test.ts +78 -0
- package/plugins/design/dev-server/test/canvas-edit.test.ts +139 -0
- package/plugins/design/dev-server/test/canvas-header.test.ts +127 -0
- package/plugins/design/dev-server/test/canvas-lib-inline.test.ts +146 -0
- package/plugins/design/dev-server/test/canvas-lib-resolver.test.ts +112 -0
- package/plugins/design/dev-server/test/canvas-meta-api.test.ts +236 -0
- package/plugins/design/dev-server/test/canvas-pipeline.test.ts +180 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +176 -0
- package/plugins/design/dev-server/test/fs-watch.test.ts +41 -0
- package/plugins/design/dev-server/test/handoff-static-frames.test.ts +215 -0
- package/plugins/design/dev-server/test/handoff.test.ts +281 -0
- package/plugins/design/dev-server/test/history-rollback.test.ts +62 -0
- package/plugins/design/dev-server/test/hmr-broadcast.test.ts +108 -0
- package/plugins/design/dev-server/test/input-router.test.ts +316 -0
- package/plugins/design/dev-server/test/locator.test.ts +214 -0
- package/plugins/design/dev-server/test/perf-harness.ts +193 -0
- package/plugins/design/dev-server/test/phase-3.6-smoke.test.ts +77 -0
- package/plugins/design/dev-server/test/runtime-bundle.test.ts +69 -0
- package/plugins/design/dev-server/test/server-lifecycle.test.ts +28 -0
- package/plugins/design/dev-server/test/tool-palette.test.tsx +55 -0
- package/plugins/design/dev-server/test/use-annotation-selection.test.tsx +77 -0
- package/plugins/design/dev-server/test/use-artboard-drag.test.ts +325 -0
- package/plugins/design/dev-server/test/use-selection-set.test.tsx +166 -0
- package/plugins/design/dev-server/test/use-snap-guides.test.ts +190 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +93 -0
- package/plugins/design/dev-server/test/ws-handshake.test.ts +33 -0
- package/plugins/design/dev-server/tool-palette.tsx +278 -0
- package/plugins/design/dev-server/tsconfig.json +26 -0
- package/plugins/design/dev-server/use-annotation-selection.tsx +92 -0
- package/plugins/design/dev-server/use-annotations-visibility.tsx +43 -0
- package/plugins/design/dev-server/use-artboard-drag.tsx +445 -0
- package/plugins/design/dev-server/use-selection-set.tsx +224 -0
- package/plugins/design/dev-server/use-snap-guides.tsx +215 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +114 -0
- package/plugins/design/dev-server/ws.ts +90 -0
- package/plugins/design/templates/_shell.html +177 -0
- package/plugins/design/templates/canvas.tsx.template +54 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +277 -0
- package/plugins/design/templates/design-system-inspiration/_README.md +71 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-banner.html +68 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-empty-state-generous.html +39 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-feature-grid.html +62 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-marketing-card.html +63 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-testimonial.html +80 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-code-block.html +71 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-diff-view.html +65 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-log-stream.html +62 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-monospace-table.html +57 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-terminal-pane.html +67 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/type-mono.html +57 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/colors-presence.html +74 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-command-palette.html +90 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-keyboard.html +51 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-list.html +89 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-shortcuts-overlay.html +85 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-toast-menu.html +80 -0
- package/plugins/design/templates/design-system-inspiration/core/INDEX.md.tpl +25 -0
- package/plugins/design/templates/design-system-inspiration/core/README.orchestration.md.tpl +71 -0
- package/plugins/design/templates/design-system-inspiration/core/README.philosophy.md.tpl +77 -0
- package/plugins/design/templates/design-system-inspiration/core/SKILL.md.tpl +50 -0
- package/plugins/design/templates/design-system-inspiration/core/colors_and_type.css.tpl +111 -0
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +28 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_layout.css +113 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-accent.html +48 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-surfaces.html +49 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-text.html +52 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-buttons.html +83 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-cards.html +79 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-inputs.html +82 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.html +66 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/spacing-scale.html +53 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/type-scale.html +62 -0
- package/plugins/design/templates/design-system-inspiration/foundations/borders.html +39 -0
- package/plugins/design/templates/design-system-inspiration/foundations/elevation.html +46 -0
- package/plugins/design/templates/design-system-inspiration/foundations/focus.html +48 -0
- package/plugins/design/templates/design-system-inspiration/foundations/grid.html +51 -0
- package/plugins/design/templates/design-system-inspiration/foundations/iconography.html +73 -0
- package/plugins/design/templates/design-system-inspiration/foundations/opacity.html +45 -0
- package/plugins/design/templates/design-system-inspiration/foundations/radii.html +40 -0
- package/plugins/design/templates/design-system-inspiration/foundations/selection.html +45 -0
- package/plugins/design/templates/design-system-inspiration/meta/accessibility.html +62 -0
- package/plugins/design/templates/design-system-inspiration/meta/i18n.html +73 -0
- package/plugins/design/templates/design-system-inspiration/meta/presence-multiplayer.html +71 -0
- package/plugins/design/templates/design-system-inspiration/meta/tokens-index.html +80 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-auth.html +78 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-data-density.html +61 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-error-pages.html +70 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-form-layouts.html +70 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-onboarding.html +71 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-pricing.html +83 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/components-resize-panels.html +63 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-index.html +55 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-showcase.html +302 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-bottom-sheet.html +63 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-pull-to-refresh.html +74 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-segmented-control.html +51 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-tab-bar.html +57 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-index.html +58 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-showcase.html +237 -0
- package/plugins/design/templates/design-system-inspiration/status/colors-status.html +49 -0
- package/plugins/design/templates/design-system-inspiration/status/components-status.html +63 -0
- package/plugins/design/templates/design-system-inspiration/status/skeletons.html +74 -0
- package/plugins/design/templates/design-system-inspiration/theme-both/colors-themes-side-by-side.html +59 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-callout.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-dialogs.html +81 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-tables.html +101 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-toggles.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-tooltips.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/empty-state.html.tpl +34 -0
- package/plugins/design/templates/design-system-inspiration/universal/logo.html +42 -0
- package/plugins/design/templates/ds-specimen.tsx.template +59 -0
- package/plugins/flow/.claude-plugin/config.schema.json +398 -0
- package/plugins/flow/README.md +45 -0
- package/plugins/flow/templates/ai-skeleton/INDEX.md +50 -0
- package/plugins/flow/templates/ai-skeleton/README.md +46 -0
- package/plugins/flow/templates/ai-skeleton/browser/har/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/browser/snapshots/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/business/README.md +12 -0
- package/plugins/flow/templates/ai-skeleton/context/README.md +12 -0
- package/plugins/flow/templates/ai-skeleton/decisions/README.md +20 -0
- package/plugins/flow/templates/ai-skeleton/design-import/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/dev-logs/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/device/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/docs/README.md +5 -0
- package/plugins/flow/templates/ai-skeleton/logs/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/logs/README.md +13 -0
- package/plugins/flow/templates/ai-skeleton/plans/README.md +16 -0
- package/plugins/flow/templates/ai-skeleton/plans/archive/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/release-guide.md +35 -0
- package/plugins/flow/templates/ai-skeleton/reviews/README.md +15 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +26 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/_lib/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/state/STATE.md +25 -0
- package/plugins/flow/templates/ai-skeleton/templates/HANDOFF.md +32 -0
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +74 -0
|
@@ -0,0 +1,2027 @@
|
|
|
1
|
+
// Design plugin local browser — React UI.
|
|
2
|
+
// Bundled via Bun.build (DDR-009/012) — IIFE, tree-shaken, React 19 from npm.
|
|
3
|
+
// Renders: file tree, tabs, viewport (iframes), status bar, design-system view, comments.
|
|
4
|
+
// Universal — no project tokens needed; styling lives in client/styles/.
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect, useRef, useMemo, useCallback, Fragment } from 'react';
|
|
7
|
+
import { createRoot } from 'react-dom/client';
|
|
8
|
+
|
|
9
|
+
const SYSTEM_TAB = '__system__';
|
|
10
|
+
const THEME_STORE = 'mdcc-theme';
|
|
11
|
+
const SHOW_HIDDEN_STORE = 'mdcc-show-hidden';
|
|
12
|
+
const SECTIONS_STORE = 'mdcc-sections-expanded';
|
|
13
|
+
const SIDEBAR_STORE = 'mdcc-sidebar-open';
|
|
14
|
+
const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
|
|
15
|
+
// Bun's `define` substitutes this at build time (see build.ts); falls back when
|
|
16
|
+
// the bundle is consumed in a context that hasn't run the build.
|
|
17
|
+
const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__ : 'dev';
|
|
18
|
+
|
|
19
|
+
function readInitialTheme() {
|
|
20
|
+
if (typeof window === 'undefined') return 'dark';
|
|
21
|
+
try {
|
|
22
|
+
const stored = localStorage.getItem(THEME_STORE);
|
|
23
|
+
if (stored === 'light' || stored === 'dark') return stored;
|
|
24
|
+
} catch {}
|
|
25
|
+
// Match the data-theme attribute index.html ships with (dark).
|
|
26
|
+
return 'dark';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readBoolStore(key, fallback) {
|
|
30
|
+
if (typeof window === 'undefined') return fallback;
|
|
31
|
+
try {
|
|
32
|
+
const v = localStorage.getItem(key);
|
|
33
|
+
if (v === '1') return true;
|
|
34
|
+
if (v === '0') return false;
|
|
35
|
+
} catch {}
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readJsonStore(key, fallback) {
|
|
40
|
+
if (typeof window === 'undefined') return fallback;
|
|
41
|
+
try {
|
|
42
|
+
const v = localStorage.getItem(key);
|
|
43
|
+
return v ? JSON.parse(v) : fallback;
|
|
44
|
+
} catch { return fallback; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Section default-open: working sections (project + non-DS canvas groups)
|
|
48
|
+
// open; meta sections (DS + runtime) collapsed. Users can override per-section
|
|
49
|
+
// via the chevron; overrides persist in localStorage.
|
|
50
|
+
function sectionDefaultOpen(g) {
|
|
51
|
+
if (g.kind === 'runtime') return false;
|
|
52
|
+
if (g.label === 'Design system') return false;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------- Utility ----------
|
|
57
|
+
|
|
58
|
+
function urlOf(p) {
|
|
59
|
+
return '/' + p.split('/').map(encodeURIComponent).join('/');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Iframe src for a canvas path. TSX canvases go through _canvas-shell.html so
|
|
63
|
+
// the bundled React 19 runtime + importmap can mount the default export. HTML
|
|
64
|
+
// canvases keep the legacy "serve the file with inspector + Babel injected"
|
|
65
|
+
// path. Phase 3.6 contract; the path argument is repo-root-relative
|
|
66
|
+
// (e.g. ".design/ui/Foo.tsx").
|
|
67
|
+
function canvasUrl(p, cfg) {
|
|
68
|
+
if (!p.endsWith('.tsx')) return urlOf(p);
|
|
69
|
+
const designRel = (cfg?.designRel || '.design').replace(/^\/+|\/+$/g, '');
|
|
70
|
+
// Path under designRoot.
|
|
71
|
+
let rel = p;
|
|
72
|
+
if (rel.startsWith(designRel + '/')) rel = rel.slice(designRel.length + 1);
|
|
73
|
+
// Pass `rel` to URLSearchParams RAW — it does encoding once. Pre-encoding
|
|
74
|
+
// with encodeURIComponent then handing to URLSearchParams produced
|
|
75
|
+
// `Docs%2520Site.tsx` (the `%` of `%20` got re-encoded as `%25`) and broke
|
|
76
|
+
// every UI canvas with a space in its filename.
|
|
77
|
+
const params = new URLSearchParams();
|
|
78
|
+
params.set('canvas', rel);
|
|
79
|
+
params.set('designRel', designRel);
|
|
80
|
+
// Resolve tokens path. Prefer the first designSystem's tokensCssRel — that's
|
|
81
|
+
// the project's authoritative tokens file (e.g. `system/project/colors_and_type.css`).
|
|
82
|
+
// The top-level cfg.tokensCssRel is the legacy default (`system/colors_and_type.css`)
|
|
83
|
+
// and points to a file that usually doesn't exist in DS-bootstrapped projects.
|
|
84
|
+
const ds0 = cfg?.designSystems?.[0];
|
|
85
|
+
const tokens = ds0?.tokensCssRel || cfg?.tokensCssRel;
|
|
86
|
+
if (tokens) params.set('tokens', tokens);
|
|
87
|
+
if (cfg?.componentsCssRel) params.set('components', cfg.componentsCssRel);
|
|
88
|
+
// Specimen detection: anything under `system/<ds>/preview/` gets the layout
|
|
89
|
+
// chrome CSS so its `.specimen-hd` / `_layout.css`-baked treatment renders.
|
|
90
|
+
const specMatch = rel.match(/^system\/([^/]+)\/preview\//);
|
|
91
|
+
if (specMatch) {
|
|
92
|
+
const ds = specMatch[1];
|
|
93
|
+
params.set('layout', `system/${ds}/preview/_layout.css`);
|
|
94
|
+
if (!cfg?.componentsCssRel) {
|
|
95
|
+
params.set('components', `system/${ds}/preview/_components.css`);
|
|
96
|
+
}
|
|
97
|
+
} else if (ds0?.path) {
|
|
98
|
+
// UI canvas — load the project DS's `_components.css` so the dc-canvas /
|
|
99
|
+
// dc-section / dc-artboard chrome (and any DS classes the canvas reuses)
|
|
100
|
+
// renders correctly.
|
|
101
|
+
if (!cfg?.componentsCssRel) {
|
|
102
|
+
params.set('components', `${ds0.path}/preview/_components.css`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return `/_canvas-shell.html?${params.toString()}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function basename(p) {
|
|
109
|
+
return p.split('/').pop();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Strip canvas extensions for display. `Canvas Viewport.tsx` → `Canvas Viewport`.
|
|
113
|
+
// Sidecars (`.meta.json`, `.css`, `.registry.json`) keep their extensions so
|
|
114
|
+
// the file type stays unambiguous.
|
|
115
|
+
function displayName(name) {
|
|
116
|
+
return name.replace(CANVAS_EXT_RE, '');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Primary base = name with the canvas extension stripped. `Canvas Viewport.tsx`
|
|
120
|
+
// → `Canvas Viewport`. A sidecar belongs to that primary when its name starts
|
|
121
|
+
// with `<base>.` — so `Canvas Viewport.meta.json` and `Canvas Viewport.css`
|
|
122
|
+
// both nest under `Canvas Viewport.tsx`. Naïve single-extension stripping
|
|
123
|
+
// breaks for multi-dot sidecars like `*.meta.json`.
|
|
124
|
+
function canvasBase(name) {
|
|
125
|
+
return name.replace(CANVAS_EXT_RE, '');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Group flat file list into { primary: canvas, sidecars: [...] }. Sidecars
|
|
129
|
+
// share the primary base + `.` prefix and don't themselves match the canvas
|
|
130
|
+
// extension regex. Orphans (no canvas peer at this dir level) come back as
|
|
131
|
+
// `{ primary: orphan, sidecars: [], orphan: true }` so the caller can gate
|
|
132
|
+
// them on `showHidden`.
|
|
133
|
+
function groupBySidecar(files) {
|
|
134
|
+
// Pass 1 — claim primaries; prefer .tsx over .html on tie.
|
|
135
|
+
const primaryByBase = new Map();
|
|
136
|
+
for (const f of files) {
|
|
137
|
+
if (!CANVAS_EXT_RE.test(f.name)) continue;
|
|
138
|
+
const base = canvasBase(f.name);
|
|
139
|
+
if (!primaryByBase.has(base) || /\.tsx$/i.test(f.name)) primaryByBase.set(base, f);
|
|
140
|
+
}
|
|
141
|
+
// Pass 2 — match non-canvas files to the longest primary base they prefix.
|
|
142
|
+
const sidecarsByBase = new Map();
|
|
143
|
+
const orphans = [];
|
|
144
|
+
for (const f of files) {
|
|
145
|
+
if (CANVAS_EXT_RE.test(f.name)) continue;
|
|
146
|
+
let matched = null;
|
|
147
|
+
for (const base of primaryByBase.keys()) {
|
|
148
|
+
if (f.name === base) continue;
|
|
149
|
+
if (f.name.startsWith(`${base}.`)) {
|
|
150
|
+
if (!matched || base.length > matched.length) matched = base;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (matched) {
|
|
154
|
+
const list = sidecarsByBase.get(matched) || [];
|
|
155
|
+
list.push(f);
|
|
156
|
+
sidecarsByBase.set(matched, list);
|
|
157
|
+
} else {
|
|
158
|
+
orphans.push(f);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const canvases = [];
|
|
162
|
+
for (const [base, primary] of primaryByBase) {
|
|
163
|
+
const sidecars = (sidecarsByBase.get(base) || []).sort((a, b) => a.name.localeCompare(b.name));
|
|
164
|
+
canvases.push({ primary, sidecars, orphan: false });
|
|
165
|
+
}
|
|
166
|
+
canvases.sort((a, b) => a.primary.name.localeCompare(b.primary.name));
|
|
167
|
+
orphans.sort((a, b) => a.name.localeCompare(b.name));
|
|
168
|
+
return {
|
|
169
|
+
canvases,
|
|
170
|
+
orphans: orphans.map((f) => ({ primary: f, sidecars: [], orphan: true })),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildTree(paths, stripPrefix) {
|
|
175
|
+
const root = {};
|
|
176
|
+
for (const p of paths) {
|
|
177
|
+
const stripped = p.startsWith(stripPrefix) ? p.slice(stripPrefix.length).replace(/^\/+/, '') : p;
|
|
178
|
+
const parts = stripped.split('/');
|
|
179
|
+
let node = root;
|
|
180
|
+
for (let i = 0; i < parts.length; i++) {
|
|
181
|
+
const key = parts[i];
|
|
182
|
+
const isFile = i === parts.length - 1;
|
|
183
|
+
if (isFile) {
|
|
184
|
+
node._files = node._files || [];
|
|
185
|
+
node._files.push({ name: key, path: p });
|
|
186
|
+
} else {
|
|
187
|
+
node[key] = node[key] || {};
|
|
188
|
+
node = node[key];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return root;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function filterTree(node, query) {
|
|
196
|
+
if (!query) return node;
|
|
197
|
+
const q = query.toLowerCase();
|
|
198
|
+
const out = {};
|
|
199
|
+
let any = false;
|
|
200
|
+
const dirs = Object.keys(node).filter(k => k !== '_files');
|
|
201
|
+
for (const d of dirs) {
|
|
202
|
+
const filtered = filterTree(node[d], query);
|
|
203
|
+
if (filtered) { out[d] = filtered; any = true; }
|
|
204
|
+
}
|
|
205
|
+
if (node._files) {
|
|
206
|
+
const files = node._files.filter(f =>
|
|
207
|
+
f.name.toLowerCase().includes(q) || f.path.toLowerCase().includes(q)
|
|
208
|
+
);
|
|
209
|
+
if (files.length) { out._files = files; any = true; }
|
|
210
|
+
}
|
|
211
|
+
return any ? out : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function openCount(comments) {
|
|
215
|
+
return (comments || []).filter(c => c.status !== 'resolved').length;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function timeAgo(iso) {
|
|
219
|
+
if (!iso) return '';
|
|
220
|
+
const t = new Date(iso).getTime();
|
|
221
|
+
if (!t) return '';
|
|
222
|
+
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
|
|
223
|
+
if (s < 60) return s + 's';
|
|
224
|
+
const m = Math.floor(s / 60);
|
|
225
|
+
if (m < 60) return m + 'm';
|
|
226
|
+
const h = Math.floor(m / 60);
|
|
227
|
+
if (h < 24) return h + 'h';
|
|
228
|
+
const d = Math.floor(h / 24);
|
|
229
|
+
if (d < 7) return d + 'd';
|
|
230
|
+
return new Date(iso).toLocaleDateString();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function totalCounts(commentsByFile) {
|
|
234
|
+
let all = 0, open = 0, resolved = 0;
|
|
235
|
+
for (const list of Object.values(commentsByFile || {})) {
|
|
236
|
+
for (const c of list || []) {
|
|
237
|
+
all++;
|
|
238
|
+
if (c.status === 'resolved') resolved++;
|
|
239
|
+
else open++;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { all, open, resolved };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------- Components ----------
|
|
246
|
+
|
|
247
|
+
function Icon({ d, size = 14, color }) {
|
|
248
|
+
return (
|
|
249
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color || 'currentColor'} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flex: 'none' }}>
|
|
250
|
+
<path d={d} />
|
|
251
|
+
</svg>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ───── Tree (CV-08 spec) ─────
|
|
256
|
+
// File rows use `.tp-row` with optional .dir / .sel / .star / .modified
|
|
257
|
+
// modifiers + a leading `.glyph` (▾ open dir, ▸ closed dir / selected file,
|
|
258
|
+
// · file). Section headers use `.tp-section-hd` with a `.pill` counter.
|
|
259
|
+
// The flat-row model (vs the old nested <details>) mirrors the mock and
|
|
260
|
+
// keeps padding-left under explicit control per depth level.
|
|
261
|
+
|
|
262
|
+
const TREE_INDENT_BASE = 12;
|
|
263
|
+
const TREE_INDENT_STEP = 16;
|
|
264
|
+
|
|
265
|
+
function DirRow({ name, depth, defaultOpen, children }) {
|
|
266
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
267
|
+
return (
|
|
268
|
+
<Fragment>
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
role="treeitem"
|
|
272
|
+
aria-expanded={open}
|
|
273
|
+
tabIndex={-1}
|
|
274
|
+
className="tp-row dir"
|
|
275
|
+
style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
|
|
276
|
+
onClick={() => setOpen(v => !v)}
|
|
277
|
+
>
|
|
278
|
+
<span className="glyph" aria-hidden="true">{open ? '▾' : '▸'}</span>
|
|
279
|
+
<span className="name">{name}</span>
|
|
280
|
+
</button>
|
|
281
|
+
{open && children}
|
|
282
|
+
</Fragment>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// DsFolderRow — a per-DS folder inside the DESIGN SYSTEM section.
|
|
287
|
+
// Split target: chevron toggles disclosure of the folder's contents; clicking
|
|
288
|
+
// the folder name opens the SystemView focused on that DS (single SystemView
|
|
289
|
+
// for now; the dsName is plumbed through so a future per-DS view can use it).
|
|
290
|
+
function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, children }) {
|
|
291
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
292
|
+
return (
|
|
293
|
+
<Fragment>
|
|
294
|
+
<div
|
|
295
|
+
className={'tp-row ds-folder' + (active ? ' sel' : '')}
|
|
296
|
+
style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
|
|
297
|
+
role="treeitem"
|
|
298
|
+
aria-expanded={open}
|
|
299
|
+
>
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
className="ds-folder-chev"
|
|
303
|
+
onClick={() => setOpen((v) => !v)}
|
|
304
|
+
aria-label={open ? 'Collapse design system' : 'Expand design system'}
|
|
305
|
+
title={open ? 'Collapse' : 'Expand'}
|
|
306
|
+
>
|
|
307
|
+
<span className="glyph" aria-hidden="true">{open ? '▾' : '▸'}</span>
|
|
308
|
+
</button>
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
className="ds-folder-open"
|
|
312
|
+
onClick={() => onOpenSystem(dsName)}
|
|
313
|
+
aria-label={`Open ${dsName} design system view`}
|
|
314
|
+
title="Open the design system view"
|
|
315
|
+
>
|
|
316
|
+
<span className="name">{name}</span>
|
|
317
|
+
</button>
|
|
318
|
+
</div>
|
|
319
|
+
{open && children}
|
|
320
|
+
</Fragment>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function FileRow({ file, activePath, onOpen, openCount: oc, depth, kind, sidecar }) {
|
|
325
|
+
const isSel = file.path === activePath;
|
|
326
|
+
const isCanvas = CANVAS_EXT_RE.test(file.name);
|
|
327
|
+
// Non-canvas rows (PROJECT *.md, RUNTIME _active.json, ...) are display-only —
|
|
328
|
+
// clicking them doesn't open an iframe; we leave the click as no-op + cursor
|
|
329
|
+
// hint via `aria-disabled`.
|
|
330
|
+
const inert = !isCanvas;
|
|
331
|
+
const label = isCanvas ? displayName(file.name) : file.name;
|
|
332
|
+
return (
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
role="treeitem"
|
|
336
|
+
aria-selected={isSel}
|
|
337
|
+
aria-disabled={inert ? 'true' : undefined}
|
|
338
|
+
tabIndex={isSel ? 0 : -1}
|
|
339
|
+
className={'tp-row' + (isSel ? ' sel' : '') + (kind === 'runtime' ? ' muted' : '') + (sidecar ? ' sidecar' : '')}
|
|
340
|
+
style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
|
|
341
|
+
title={file.path + (oc ? ` — ${oc} open` : (inert ? ' (file index only)' : ''))}
|
|
342
|
+
onClick={() => { if (!inert) onOpen(file.path); }}
|
|
343
|
+
>
|
|
344
|
+
<span className="glyph" aria-hidden="true">{isSel ? '▸' : '·'}</span>
|
|
345
|
+
<span className="name">{label}</span>
|
|
346
|
+
{oc > 0 && <span className="badge">{oc}</span>}
|
|
347
|
+
</button>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function CanvasRow({ primary, sidecars, depth, kind, activePath, onOpen, openCount: oc, showHidden, forceOpen }) {
|
|
352
|
+
const hasSidecars = sidecars.length > 0;
|
|
353
|
+
const [openState, setOpenState] = useState(false);
|
|
354
|
+
// Sidecars are only revealed when the user opts in via `showHidden` — the
|
|
355
|
+
// chevron itself only appears in that mode. When `forceOpen` is true (search
|
|
356
|
+
// match in a sidecar), override local state so the user sees the hit.
|
|
357
|
+
const open = forceOpen || openState;
|
|
358
|
+
const isSel = primary.path === activePath;
|
|
359
|
+
const showChevron = hasSidecars && showHidden;
|
|
360
|
+
if (!showChevron) {
|
|
361
|
+
return (
|
|
362
|
+
<FileRow
|
|
363
|
+
file={primary}
|
|
364
|
+
activePath={activePath}
|
|
365
|
+
onOpen={onOpen}
|
|
366
|
+
openCount={oc}
|
|
367
|
+
depth={depth}
|
|
368
|
+
kind={kind}
|
|
369
|
+
/>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
return (
|
|
373
|
+
<Fragment>
|
|
374
|
+
<button
|
|
375
|
+
type="button"
|
|
376
|
+
role="treeitem"
|
|
377
|
+
aria-selected={isSel}
|
|
378
|
+
aria-expanded={open}
|
|
379
|
+
tabIndex={isSel ? 0 : -1}
|
|
380
|
+
className={'tp-row canvas-row' + (isSel ? ' sel' : '')}
|
|
381
|
+
style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
|
|
382
|
+
title={primary.path}
|
|
383
|
+
onClick={(e) => {
|
|
384
|
+
// Click the chevron region → toggle disclosure. Click anywhere else → open canvas.
|
|
385
|
+
if (e.target.closest('.canvas-chev')) {
|
|
386
|
+
setOpenState((v) => !v);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
onOpen(primary.path);
|
|
390
|
+
}}
|
|
391
|
+
>
|
|
392
|
+
<span
|
|
393
|
+
className="glyph canvas-chev"
|
|
394
|
+
aria-hidden="true"
|
|
395
|
+
onClick={(e) => {
|
|
396
|
+
e.stopPropagation();
|
|
397
|
+
setOpenState((v) => !v);
|
|
398
|
+
}}
|
|
399
|
+
>
|
|
400
|
+
{open ? '▾' : '▸'}
|
|
401
|
+
</span>
|
|
402
|
+
<span className="name">{displayName(primary.name)}</span>
|
|
403
|
+
{oc > 0 && <span className="badge">{oc}</span>}
|
|
404
|
+
</button>
|
|
405
|
+
{open && sidecars.map((sc) => (
|
|
406
|
+
<FileRow
|
|
407
|
+
key={sc.path}
|
|
408
|
+
file={sc}
|
|
409
|
+
activePath={activePath}
|
|
410
|
+
onOpen={onOpen}
|
|
411
|
+
openCount={0}
|
|
412
|
+
depth={depth + 1}
|
|
413
|
+
kind={kind}
|
|
414
|
+
sidecar
|
|
415
|
+
/>
|
|
416
|
+
))}
|
|
417
|
+
</Fragment>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showHidden, search, dsFolders, onOpenSystem }) {
|
|
422
|
+
const dirs = Object.keys(node).filter(k => k !== '_files').sort();
|
|
423
|
+
const files = node._files || [];
|
|
424
|
+
// VS Code-style sidecar grouping. Canvas (`.tsx`/`.html`) becomes the primary
|
|
425
|
+
// row; same-basename non-canvas files (`.meta.json`, `.css`, …) collapse
|
|
426
|
+
// under it. Orphans surface only when `showHidden` is on.
|
|
427
|
+
const { canvases, orphans } = useMemo(() => groupBySidecar(files), [files]);
|
|
428
|
+
const hasSearch = !!(search && search.trim());
|
|
429
|
+
// DS-folder lookup: only meaningful at the top level of a DS group. The
|
|
430
|
+
// server emits `dsFolders: [{name, folder}, ...]` so the client knows which
|
|
431
|
+
// dir at depth=1 corresponds to a DS root (click → open SystemView).
|
|
432
|
+
const dsFolderByName = useMemo(() => {
|
|
433
|
+
if (!dsFolders || depth !== 1) return null;
|
|
434
|
+
const m = new Map();
|
|
435
|
+
for (const f of dsFolders) m.set(f.folder, f);
|
|
436
|
+
return m;
|
|
437
|
+
}, [dsFolders, depth]);
|
|
438
|
+
return (
|
|
439
|
+
<Fragment>
|
|
440
|
+
{canvases.map((entry) => {
|
|
441
|
+
const forceOpen = hasSearch && entry.sidecars.some((sc) => {
|
|
442
|
+
const q = search.toLowerCase();
|
|
443
|
+
return sc.name.toLowerCase().includes(q) || sc.path.toLowerCase().includes(q);
|
|
444
|
+
});
|
|
445
|
+
return (
|
|
446
|
+
<CanvasRow
|
|
447
|
+
key={entry.primary.path}
|
|
448
|
+
primary={entry.primary}
|
|
449
|
+
sidecars={entry.sidecars}
|
|
450
|
+
activePath={activePath}
|
|
451
|
+
onOpen={onOpen}
|
|
452
|
+
openCount={openCount(commentsByFile[entry.primary.path])}
|
|
453
|
+
depth={depth}
|
|
454
|
+
kind={kind}
|
|
455
|
+
showHidden={showHidden}
|
|
456
|
+
forceOpen={forceOpen}
|
|
457
|
+
/>
|
|
458
|
+
);
|
|
459
|
+
})}
|
|
460
|
+
{showHidden && orphans.map((entry) => (
|
|
461
|
+
<FileRow
|
|
462
|
+
key={entry.primary.path}
|
|
463
|
+
file={entry.primary}
|
|
464
|
+
activePath={activePath}
|
|
465
|
+
onOpen={onOpen}
|
|
466
|
+
openCount={openCount(commentsByFile[entry.primary.path])}
|
|
467
|
+
depth={depth}
|
|
468
|
+
kind={kind}
|
|
469
|
+
/>
|
|
470
|
+
))}
|
|
471
|
+
{dirs.map(d => {
|
|
472
|
+
const dsMatch = dsFolderByName?.get(d);
|
|
473
|
+
const childTree = (
|
|
474
|
+
<Tree
|
|
475
|
+
node={node[d]}
|
|
476
|
+
activePath={activePath}
|
|
477
|
+
onOpen={onOpen}
|
|
478
|
+
commentsByFile={commentsByFile}
|
|
479
|
+
depth={depth + 1}
|
|
480
|
+
kind={kind}
|
|
481
|
+
showHidden={showHidden}
|
|
482
|
+
search={search}
|
|
483
|
+
onOpenSystem={onOpenSystem}
|
|
484
|
+
/>
|
|
485
|
+
);
|
|
486
|
+
if (dsMatch && onOpenSystem) {
|
|
487
|
+
return (
|
|
488
|
+
<DsFolderRow
|
|
489
|
+
key={d}
|
|
490
|
+
name={d}
|
|
491
|
+
dsName={dsMatch.name}
|
|
492
|
+
depth={depth}
|
|
493
|
+
defaultOpen={true}
|
|
494
|
+
active={activePath === SYSTEM_TAB}
|
|
495
|
+
onOpenSystem={onOpenSystem}
|
|
496
|
+
>
|
|
497
|
+
{childTree}
|
|
498
|
+
</DsFolderRow>
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
return (
|
|
502
|
+
<DirRow key={d} name={d} depth={depth} defaultOpen={true}>
|
|
503
|
+
{childTree}
|
|
504
|
+
</DirRow>
|
|
505
|
+
);
|
|
506
|
+
})}
|
|
507
|
+
</Fragment>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// CV-08 section labels — title + optional SKU pill. The pill carries
|
|
512
|
+
// project / DS identity; the mock keeps these tight (1 line). Labels are
|
|
513
|
+
// keyed by the server-provided `kind` (PROJECT / DS / UI / RUNTIME).
|
|
514
|
+
const SECTION_META = {
|
|
515
|
+
project: { title: 'PROJECT', pillFromCount: false },
|
|
516
|
+
// Design-system group: pill shows the number of DSes (one row per DS folder
|
|
517
|
+
// inside). Computed in Sidebar from `g.dsFolders.length`.
|
|
518
|
+
ds: { title: 'DESIGN SYSTEM', pillFromDsCount: true },
|
|
519
|
+
canvas: { title: 'UI CANVASES', pillFromCount: true },
|
|
520
|
+
runtime: { title: 'RUNTIME · GITIGNORED', pillFromCount: true },
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
function sectionMetaFor(g) {
|
|
524
|
+
if (g.kind === 'project') return SECTION_META.project;
|
|
525
|
+
if (g.kind === 'runtime') return SECTION_META.runtime;
|
|
526
|
+
// canvas-kind groups: "Design system" → ds, anything else → canvas label
|
|
527
|
+
if (g.label === 'Design system') return SECTION_META.ds;
|
|
528
|
+
if (g.label === 'UI kit') return SECTION_META.canvas;
|
|
529
|
+
return { title: g.label.toUpperCase(), pillFromCount: true };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function Sidebar({ groups, activePath, onOpen, onOpenSystem, wsConnected, search, setSearch, commentsByFile, showHidden, sectionsExpanded, onToggleSection }) {
|
|
533
|
+
const filteredGroups = useMemo(() => {
|
|
534
|
+
if (!search) return groups;
|
|
535
|
+
return groups.map(g => ({ ...g, tree: filterTree(g.tree, search), filtered: !!search }));
|
|
536
|
+
}, [groups, search]);
|
|
537
|
+
|
|
538
|
+
// Mock uses `42 / 42` — total openable canvases, not every listed file.
|
|
539
|
+
// We count canvas files (TSX Phase 3.6+ default, HTML legacy) so the counter
|
|
540
|
+
// matches "canvases you can mount".
|
|
541
|
+
const htmlCount = useMemo(() => {
|
|
542
|
+
let total = 0;
|
|
543
|
+
for (const g of groups) for (const p of g.paths || []) if (CANVAS_EXT_RE.test(p)) total++;
|
|
544
|
+
return total;
|
|
545
|
+
}, [groups]);
|
|
546
|
+
const htmlShown = useMemo(() => {
|
|
547
|
+
let total = 0;
|
|
548
|
+
for (const g of filteredGroups) for (const p of g.paths || []) if (CANVAS_EXT_RE.test(p)) total++;
|
|
549
|
+
return total;
|
|
550
|
+
}, [filteredGroups]);
|
|
551
|
+
|
|
552
|
+
return (
|
|
553
|
+
<nav className="sidebar">
|
|
554
|
+
<div className="tree-panel-hd">
|
|
555
|
+
<span>FILES</span>
|
|
556
|
+
<span className="ct" title={wsConnected ? 'live · file index synced' : 'reconnecting…'}>
|
|
557
|
+
<span className={'live-dot' + (wsConnected ? ' connected' : '')} aria-hidden="true" />
|
|
558
|
+
{htmlShown} / {htmlCount}
|
|
559
|
+
</span>
|
|
560
|
+
</div>
|
|
561
|
+
|
|
562
|
+
<div className="tree-panel-search">
|
|
563
|
+
<Icon d="M21 21l-4.35-4.35 M11 19a8 8 0 100-16 8 8 0 000 16z" size={12} />
|
|
564
|
+
<input
|
|
565
|
+
type="search"
|
|
566
|
+
placeholder="filter (⌘F)"
|
|
567
|
+
value={search}
|
|
568
|
+
onChange={e => setSearch(e.target.value)}
|
|
569
|
+
aria-label="Filter files"
|
|
570
|
+
/>
|
|
571
|
+
{search ? (
|
|
572
|
+
<button className="search-clear" onClick={() => setSearch('')} title="Clear (Esc)" aria-label="Clear search">×</button>
|
|
573
|
+
) : (
|
|
574
|
+
<span className="search-kbd" aria-hidden="true">/</span>
|
|
575
|
+
)}
|
|
576
|
+
</div>
|
|
577
|
+
|
|
578
|
+
<div className="tree-panel-body" role="tree" aria-label="Project file tree">
|
|
579
|
+
{filteredGroups.map(g => {
|
|
580
|
+
// Hide gitignored runtime / orphan-only project sections by default.
|
|
581
|
+
// Active search overrides — if the user typed a query, they want hits
|
|
582
|
+
// wherever they live.
|
|
583
|
+
if (!showHidden && !search && g.kind === 'runtime') return null;
|
|
584
|
+
const meta = sectionMetaFor(g);
|
|
585
|
+
// Counter pill counts canvases only — sidecars + orphans inflate the
|
|
586
|
+
// raw `paths.length` and the FILES header already filters this way.
|
|
587
|
+
const canvasCount = (g.paths || []).filter((p) => CANVAS_EXT_RE.test(p)).length;
|
|
588
|
+
const pill = meta.pill
|
|
589
|
+
|| (meta.pillFromDsCount ? String(g.dsFolders?.length || 0) : null)
|
|
590
|
+
|| (meta.pillFromCount ? String(canvasCount || g.paths?.length || 0) : null);
|
|
591
|
+
const hasItems = g.tree && Object.keys(g.tree).length > 0;
|
|
592
|
+
const isDs = g.label === 'Design system';
|
|
593
|
+
const isProject = g.kind === 'project';
|
|
594
|
+
// Project section: when showHidden is off, every row inside is an
|
|
595
|
+
// orphan (.md / .json / .css) → empty body. Skip the header in that
|
|
596
|
+
// case so the sidebar doesn't show "PROJECT" with nothing under it.
|
|
597
|
+
if (!showHidden && !search && isProject && canvasCount === 0) return null;
|
|
598
|
+
const defaultOpen = sectionDefaultOpen(g);
|
|
599
|
+
const explicit = sectionsExpanded[g.label];
|
|
600
|
+
// Active search forces every section open so hits aren't hidden.
|
|
601
|
+
const sectionOpen = !!search || (explicit === undefined ? defaultOpen : explicit);
|
|
602
|
+
const chev = sectionOpen ? '▾' : '▸';
|
|
603
|
+
return (
|
|
604
|
+
<Fragment key={g.label}>
|
|
605
|
+
<button
|
|
606
|
+
type="button"
|
|
607
|
+
className="tp-section-hd clickable section-toggle"
|
|
608
|
+
onClick={() => onToggleSection(g.label, defaultOpen)}
|
|
609
|
+
aria-expanded={sectionOpen}
|
|
610
|
+
title={sectionOpen ? 'Collapse section' : 'Expand section'}
|
|
611
|
+
>
|
|
612
|
+
<span className="chev" aria-hidden="true">{chev}</span>
|
|
613
|
+
<span className="section-label">{meta.title}</span>
|
|
614
|
+
{pill && <span className="pill">{pill}</span>}
|
|
615
|
+
</button>
|
|
616
|
+
{sectionOpen && (hasItems ? (
|
|
617
|
+
<Tree
|
|
618
|
+
node={g.tree}
|
|
619
|
+
activePath={activePath}
|
|
620
|
+
onOpen={onOpen}
|
|
621
|
+
commentsByFile={commentsByFile}
|
|
622
|
+
depth={1}
|
|
623
|
+
kind={g.kind}
|
|
624
|
+
showHidden={showHidden}
|
|
625
|
+
search={search}
|
|
626
|
+
dsFolders={g.dsFolders}
|
|
627
|
+
onOpenSystem={isDs ? onOpenSystem : undefined}
|
|
628
|
+
/>
|
|
629
|
+
) : (
|
|
630
|
+
<div className="tp-empty">
|
|
631
|
+
{search ? 'No matches.' : 'Empty.'}
|
|
632
|
+
</div>
|
|
633
|
+
))}
|
|
634
|
+
</Fragment>
|
|
635
|
+
);
|
|
636
|
+
})}
|
|
637
|
+
</div>
|
|
638
|
+
</nav>
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Help modal — hosts the cheatsheet that used to live in the left sidebar.
|
|
643
|
+
// Triggered from the menubar's Help item. Esc + backdrop click close it.
|
|
644
|
+
function HelpModal({ open, onClose }) {
|
|
645
|
+
useEffect(() => {
|
|
646
|
+
if (!open) return;
|
|
647
|
+
function onKey(e) { if (e.key === 'Escape') onClose(); }
|
|
648
|
+
window.addEventListener('keydown', onKey);
|
|
649
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
650
|
+
}, [open, onClose]);
|
|
651
|
+
if (!open) return null;
|
|
652
|
+
return (
|
|
653
|
+
<div
|
|
654
|
+
className="help-modal-backdrop"
|
|
655
|
+
role="presentation"
|
|
656
|
+
onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}
|
|
657
|
+
>
|
|
658
|
+
<div className="help-modal" role="dialog" aria-modal="true" aria-labelledby="help-modal-title">
|
|
659
|
+
<header className="help-modal-hd">
|
|
660
|
+
<span className="title" id="help-modal-title">Help · shortcuts & commands</span>
|
|
661
|
+
<span className="sku">MDCC-DEV-SRV / v{MDCC_VERSION}</span>
|
|
662
|
+
<button type="button" className="help-modal-close" aria-label="Close (Esc)" onClick={onClose}>×</button>
|
|
663
|
+
</header>
|
|
664
|
+
<div className="help-modal-body">
|
|
665
|
+
<details open>
|
|
666
|
+
<summary>Canvas selection & tools</summary>
|
|
667
|
+
<ul>
|
|
668
|
+
<li><kbd>V</kbd> <span>move tool — Cmd+click to select, Cmd+Shift to multi</span></li>
|
|
669
|
+
<li><kbd>H</kbd> <span>hand tool — bare drag pans (no Space needed)</span></li>
|
|
670
|
+
<li><kbd>C</kbd> <span>comment tool — hover paints, click drops a pin</span></li>
|
|
671
|
+
<li><kbd>⌘</kbd> + hover <span>preview deepest element under cursor</span></li>
|
|
672
|
+
<li><kbd>⌘</kbd> + click <span>select that element (replace)</span></li>
|
|
673
|
+
<li><kbd>⌘⇧</kbd> + click <span>add deepest to selection (multi)</span></li>
|
|
674
|
+
<li>right-click <span>context menu (Copy CSS / Fit / Reset...)</span></li>
|
|
675
|
+
<li><kbd>Esc</kbd> in canvas <span>clear selection + close menu</span></li>
|
|
676
|
+
</ul>
|
|
677
|
+
</details>
|
|
678
|
+
<details>
|
|
679
|
+
<summary>Annotation tools</summary>
|
|
680
|
+
<ul>
|
|
681
|
+
<li><kbd>B</kbd> <span>pen — freehand stroke</span></li>
|
|
682
|
+
<li><kbd>R</kbd> <span>rectangle — drag to define corners</span></li>
|
|
683
|
+
<li><kbd>O</kbd> <span>ellipse — drag from center outward</span></li>
|
|
684
|
+
<li><kbd>A</kbd> <span>arrow — drag tail → tip</span></li>
|
|
685
|
+
<li><kbd>E</kbd> <span>eraser — click or drag over strokes to remove</span></li>
|
|
686
|
+
<li><kbd>V</kbd> + click stroke <span>select annotation (Shift+click to multi)</span></li>
|
|
687
|
+
<li><kbd>V</kbd> + drag empty <span>marquee-select strokes that overlap</span></li>
|
|
688
|
+
<li>double-click rect/ellipse <span>add text inside the shape</span></li>
|
|
689
|
+
<li>arrow keys <span>nudge selected annotation 1 unit (Shift = 10)</span></li>
|
|
690
|
+
<li><kbd>Backspace</kbd> <span>delete selected annotations</span></li>
|
|
691
|
+
<li><kbd>⇧P</kbd> <span>presentation — hide annotations for clean screenshot</span></li>
|
|
692
|
+
</ul>
|
|
693
|
+
</details>
|
|
694
|
+
<details>
|
|
695
|
+
<summary>Tabs & canvas</summary>
|
|
696
|
+
<ul>
|
|
697
|
+
<li>click in tree <span>open tab</span></li>
|
|
698
|
+
<li><kbd>×</kbd> on tab <span>close tab</span></li>
|
|
699
|
+
<li><kbd>⌘R</kbd> <span>reload iframe</span></li>
|
|
700
|
+
<li><kbd>/</kbd> <span>focus search</span></li>
|
|
701
|
+
<li><kbd>⌘⇧M</kbd> <span>toggle comments panel</span></li>
|
|
702
|
+
</ul>
|
|
703
|
+
</details>
|
|
704
|
+
<details>
|
|
705
|
+
<summary>Slash commands</summary>
|
|
706
|
+
<ul className="cmds">
|
|
707
|
+
<li><code>/design:edit "<i>feedback</i>"</code><span>edit + 4-iter multi-axis loop</span></li>
|
|
708
|
+
<li><code>/design:edit "<i>…</i>" --perfect</code><span>8-iter polish (4.5/5 aspiration)</span></li>
|
|
709
|
+
<li><code>/design:edit "<i>…</i>" --no-critic</code><span>raw edit, skip loop</span></li>
|
|
710
|
+
<li><code>/design:edit "<i>…</i>" --opt-out=<i>scope</i></code><span>override DS scope (palette/aesthetic/full)</span></li>
|
|
711
|
+
<li><code>/design:new "<i>Name</i>" "<i>brief</i>"</code><span>scaffold canvas</span></li>
|
|
712
|
+
<li><code>/design:new "<i>…</i>" --opt-out=aesthetic</code><span>scaffold off-system canvas (gradients/radii/type free)</span></li>
|
|
713
|
+
<li><code>/design:critic</code><span>review panel (routed)</span></li>
|
|
714
|
+
<li><code>/design:critic --all</code><span>10-critic sweep</span></li>
|
|
715
|
+
<li><code>/design:critic --agent signature-moment-critic</code><span>aspiration axis only</span></li>
|
|
716
|
+
<li><code>/design:rollback</code><span>undo last edit</span></li>
|
|
717
|
+
<li><code>/design:screenshot</code><span>capture canvas</span></li>
|
|
718
|
+
<li><code>/design:setup-docs</code><span>refresh README + INDEX</span></li>
|
|
719
|
+
<li><code>/design:handoff</code><span>migrate to apps/</span></li>
|
|
720
|
+
</ul>
|
|
721
|
+
</details>
|
|
722
|
+
<details>
|
|
723
|
+
<summary>Opt-out scope</summary>
|
|
724
|
+
<ul>
|
|
725
|
+
<li><strong>palette</strong> <span>default — tokens + rootClass kept; local namespace overrides colors only. DS aesthetic still enforced.</span></li>
|
|
726
|
+
<li><strong>aesthetic</strong> <span>palette + gradients/off-ladder radii/alt type/decorative SVG flags allowed.</span></li>
|
|
727
|
+
<li><strong>full</strong> <span>DS treated as advisory. Type/radii/aesthetic up to canvas.</span></li>
|
|
728
|
+
<li><em>A11y enforced at every scope</em> <span>contrast, focus, semantics, motion, touch targets — never relaxed.</span></li>
|
|
729
|
+
<li>Persisted on canvas's <code>.meta.json</code> <code>opt_out_scope</code> field — subsequent <code>/design:edit</code> iterations inherit.</li>
|
|
730
|
+
<li>Inferred from brief ("modern", "vibrant", "off-system") with one-shot AskUserQuestion before iter-1 critics fire.</li>
|
|
731
|
+
</ul>
|
|
732
|
+
</details>
|
|
733
|
+
<details>
|
|
734
|
+
<summary>Auto-critic loop</summary>
|
|
735
|
+
<ul>
|
|
736
|
+
<li><strong>Default</strong> <span>4 iter · aspiration ≥ 4.0 · stable-but-bland exit</span></li>
|
|
737
|
+
<li><strong>--perfect</strong> <span>8 iter · aspiration ≥ 4.5 · broader divergence tolerance</span></li>
|
|
738
|
+
<li><strong>--perfect --all</strong> <span>every critic incl. aspiration · portfolio-grade</span></li>
|
|
739
|
+
<li>Exit: <code>solid</code> · <code>stable-but-bland</code> · <code>max-reached</code> · <code>divergent</code></li>
|
|
740
|
+
<li><em>stable-but-bland</em> = correctness clean, aspiration plateau — surface for review with lowest 2 axes named</li>
|
|
741
|
+
<li>When <code>opt_out_scope ∈ {aesthetic, full}</code>: iter-1 checkpoint fires — pick (a) run loop, (b) skip auto-loop and review iter 1, (c) a11y-only check.</li>
|
|
742
|
+
</ul>
|
|
743
|
+
</details>
|
|
744
|
+
<details>
|
|
745
|
+
<summary>Pin-to-element flow</summary>
|
|
746
|
+
<ol>
|
|
747
|
+
<li>Open canvas tab</li>
|
|
748
|
+
<li><kbd>⌘</kbd>+click element</li>
|
|
749
|
+
<li>Status bar shows ● selector</li>
|
|
750
|
+
<li>Run <code>/design:edit "<i>change just this</i>"</code></li>
|
|
751
|
+
<li>Reload iframe (<kbd>⌘R</kbd>)</li>
|
|
752
|
+
</ol>
|
|
753
|
+
</details>
|
|
754
|
+
<details>
|
|
755
|
+
<summary>Comments</summary>
|
|
756
|
+
<ol>
|
|
757
|
+
<li><kbd>⌘</kbd>+click element, then <kbd>⌘C</kbd> <span>or ⌘⇧+click</span></li>
|
|
758
|
+
<li>Numbered pin appears on canvas</li>
|
|
759
|
+
<li><kbd>⌘⇧M</kbd> <span>opens panel — All / Open / Resolved</span></li>
|
|
760
|
+
<li>Click row in panel <span>jumps to that file + pin</span></li>
|
|
761
|
+
<li>Claude reads <code>_comments/<slug>.json</code> on next <code>/design</code></li>
|
|
762
|
+
</ol>
|
|
763
|
+
</details>
|
|
764
|
+
</div>
|
|
765
|
+
</div>
|
|
766
|
+
</div>
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ───────── Menubar (CV-01/CV-08 top chrome) ─────────
|
|
771
|
+
//
|
|
772
|
+
// Replaces the legacy `.header` action-button toolbar. Mirrors the shared
|
|
773
|
+
// Menubar component from .design/ui/Canvas Viewport.html — brand · menus ·
|
|
774
|
+
// status. View dropdown is wired to the only toggleable panel today (the
|
|
775
|
+
// Comments sidebar); the rest is inert with a phase-tag explaining when it
|
|
776
|
+
// lands.
|
|
777
|
+
|
|
778
|
+
const MENU_NAMES = ['File', 'Edit', 'View', 'Selection', 'Tools', 'Help'];
|
|
779
|
+
|
|
780
|
+
function ViewDropdown({ panels, onToggle, onClose }) {
|
|
781
|
+
useEffect(() => {
|
|
782
|
+
function onKey(e) { if (e.key === 'Escape') onClose(); }
|
|
783
|
+
function onDocClick(e) {
|
|
784
|
+
if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
|
|
785
|
+
}
|
|
786
|
+
window.addEventListener('keydown', onKey);
|
|
787
|
+
window.addEventListener('mousedown', onDocClick);
|
|
788
|
+
return () => {
|
|
789
|
+
window.removeEventListener('keydown', onKey);
|
|
790
|
+
window.removeEventListener('mousedown', onDocClick);
|
|
791
|
+
};
|
|
792
|
+
}, [onClose]);
|
|
793
|
+
|
|
794
|
+
return (
|
|
795
|
+
<div className="mb-dropdown" role="menu" aria-label="View" style={{ left: '146px' }}>
|
|
796
|
+
<div className="mb-dd-hd">Panels</div>
|
|
797
|
+
{panels.map(p => (
|
|
798
|
+
<button
|
|
799
|
+
key={p.id}
|
|
800
|
+
type="button"
|
|
801
|
+
role="menuitem"
|
|
802
|
+
className={'mb-dd-item' + (p.checked ? ' active' : '')}
|
|
803
|
+
aria-disabled={p.disabled ? 'true' : undefined}
|
|
804
|
+
onClick={() => { if (!p.disabled) { onToggle(p.id); onClose(); } }}
|
|
805
|
+
>
|
|
806
|
+
<span className="lbl">
|
|
807
|
+
<span className="check">{p.checked ? '✓' : ''}</span>
|
|
808
|
+
<span>{p.label}</span>
|
|
809
|
+
</span>
|
|
810
|
+
{p.phase
|
|
811
|
+
? <span className="phase-tag">{p.phase}</span>
|
|
812
|
+
: <span className="shortcut">{p.shortcut || ''}</span>}
|
|
813
|
+
</button>
|
|
814
|
+
))}
|
|
815
|
+
<div className="mb-dd-sep" />
|
|
816
|
+
<div className="mb-dd-hd">Zoom</div>
|
|
817
|
+
{[
|
|
818
|
+
{ label: 'Zoom In', shortcut: '⌘ +' },
|
|
819
|
+
{ label: 'Zoom Out', shortcut: '⌘ −' },
|
|
820
|
+
{ label: 'Fit to Screen', shortcut: '⌘ 0' },
|
|
821
|
+
{ label: 'Actual Size · 100 %', shortcut: '⌥ ⌘ 0' },
|
|
822
|
+
].map(z => (
|
|
823
|
+
<button
|
|
824
|
+
key={z.label}
|
|
825
|
+
type="button"
|
|
826
|
+
role="menuitem"
|
|
827
|
+
className="mb-dd-item"
|
|
828
|
+
aria-disabled="true"
|
|
829
|
+
>
|
|
830
|
+
<span className="lbl"><span className="check" /><span>{z.label}</span></span>
|
|
831
|
+
<span className="phase-tag">Phase 4</span>
|
|
832
|
+
</button>
|
|
833
|
+
))}
|
|
834
|
+
</div>
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
839
|
+
// Phase 5.1 — Selection + Tools dropdowns (mirror ViewDropdown shape).
|
|
840
|
+
|
|
841
|
+
function SelectionDropdown({ onAction, onClose }) {
|
|
842
|
+
useEffect(() => {
|
|
843
|
+
function onKey(e) { if (e.key === 'Escape') onClose(); }
|
|
844
|
+
function onDocClick(e) {
|
|
845
|
+
if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
|
|
846
|
+
}
|
|
847
|
+
window.addEventListener('keydown', onKey);
|
|
848
|
+
window.addEventListener('mousedown', onDocClick);
|
|
849
|
+
return () => {
|
|
850
|
+
window.removeEventListener('keydown', onKey);
|
|
851
|
+
window.removeEventListener('mousedown', onDocClick);
|
|
852
|
+
};
|
|
853
|
+
}, [onClose]);
|
|
854
|
+
const items = [
|
|
855
|
+
{ id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
|
|
856
|
+
{ id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⌘ ⇧ A' },
|
|
857
|
+
];
|
|
858
|
+
return (
|
|
859
|
+
<div className="mb-dropdown" role="menu" aria-label="Selection" style={{ left: '195px' }}>
|
|
860
|
+
{items.map(it => (
|
|
861
|
+
<button
|
|
862
|
+
key={it.id}
|
|
863
|
+
type="button"
|
|
864
|
+
role="menuitem"
|
|
865
|
+
className="mb-dd-item"
|
|
866
|
+
onClick={() => { onAction(it.id); onClose(); }}
|
|
867
|
+
>
|
|
868
|
+
<span className="lbl"><span className="check" /><span>{it.label}</span></span>
|
|
869
|
+
<span className="shortcut">{it.shortcut}</span>
|
|
870
|
+
</button>
|
|
871
|
+
))}
|
|
872
|
+
</div>
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function ToolsDropdown({ onAction, onClose }) {
|
|
877
|
+
useEffect(() => {
|
|
878
|
+
function onKey(e) { if (e.key === 'Escape') onClose(); }
|
|
879
|
+
function onDocClick(e) {
|
|
880
|
+
if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
|
|
881
|
+
}
|
|
882
|
+
window.addEventListener('keydown', onKey);
|
|
883
|
+
window.addEventListener('mousedown', onDocClick);
|
|
884
|
+
return () => {
|
|
885
|
+
window.removeEventListener('keydown', onKey);
|
|
886
|
+
window.removeEventListener('mousedown', onDocClick);
|
|
887
|
+
};
|
|
888
|
+
}, [onClose]);
|
|
889
|
+
// Mirrors DEFAULT_TOOLS in plugins/design/dev-server/use-tool-mode.tsx —
|
|
890
|
+
// kept in sync by hand because the menubar lives in the dev-server shell
|
|
891
|
+
// (no shared bundle with the canvas iframes).
|
|
892
|
+
const tools = [
|
|
893
|
+
{ id: 'move', label: 'Move', shortcut: 'V' },
|
|
894
|
+
{ id: 'hand', label: 'Hand', shortcut: 'H' },
|
|
895
|
+
{ id: 'comment', label: 'Comment', shortcut: 'C' },
|
|
896
|
+
{ id: 'pen', label: 'Pen', shortcut: 'B' },
|
|
897
|
+
{ id: 'rect', label: 'Rect', shortcut: 'R' },
|
|
898
|
+
{ id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
|
|
899
|
+
{ id: 'arrow', label: 'Arrow', shortcut: 'A' },
|
|
900
|
+
{ id: 'eraser', label: 'Eraser', shortcut: 'E' },
|
|
901
|
+
];
|
|
902
|
+
return (
|
|
903
|
+
<div className="mb-dropdown" role="menu" aria-label="Tools" style={{ left: '253px' }}>
|
|
904
|
+
{tools.map(t => (
|
|
905
|
+
<button
|
|
906
|
+
key={t.id}
|
|
907
|
+
type="button"
|
|
908
|
+
role="menuitem"
|
|
909
|
+
className="mb-dd-item"
|
|
910
|
+
onClick={() => { onAction(t.id); onClose(); }}
|
|
911
|
+
>
|
|
912
|
+
<span className="lbl"><span className="check" /><span>{t.label}</span></span>
|
|
913
|
+
<span className="shortcut">{t.shortcut}</span>
|
|
914
|
+
</button>
|
|
915
|
+
))}
|
|
916
|
+
</div>
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commentsPanelOpen, onToggleComments, onOpenSystem, sidebarOpen, onToggleSidebar, showHidden, onToggleShowHidden, onOpenHelp, annotationsVisible, onToggleAnnotations, postToActiveCanvas }) {
|
|
921
|
+
const isSystem = activePath === SYSTEM_TAB;
|
|
922
|
+
const stamp = isSystem ? 'SYSTEM' : (activePath ? 'CANVAS' : 'IDLE');
|
|
923
|
+
const fileLabel = isSystem
|
|
924
|
+
? <b>design system</b>
|
|
925
|
+
: (activePath ? <>{activePath.split('/').slice(0, -1).join('/')}/<b>{displayName(basename(activePath))}</b></> : <span style={{ color: 'var(--u-fg-3)' }}>no canvas open</span>);
|
|
926
|
+
|
|
927
|
+
const panels = [
|
|
928
|
+
{ id: 'tree', label: 'Project Tree', shortcut: 'T', checked: sidebarOpen, disabled: false },
|
|
929
|
+
{ id: 'comments', label: 'Comments Sidebar', shortcut: '⌘ ⇧ M', checked: commentsPanelOpen, disabled: false },
|
|
930
|
+
{ id: 'hidden', label: 'Show hidden files', shortcut: 'H', checked: showHidden, disabled: false },
|
|
931
|
+
{ id: 'layers', label: 'Layers Panel', phase: 'Phase 12', disabled: true },
|
|
932
|
+
{ id: 'inspector', label: 'Inspector', phase: 'Phase 12', disabled: true },
|
|
933
|
+
{ id: 'annotate', label: 'Annotations', shortcut: '⇧ P', checked: annotationsVisible, disabled: false },
|
|
934
|
+
{ id: 'present', label: 'Presentation Mode', phase: 'Phase 6', disabled: true },
|
|
935
|
+
];
|
|
936
|
+
|
|
937
|
+
function onMenuClick(key) {
|
|
938
|
+
if (key === 'view' || key === 'selection' || key === 'tools') {
|
|
939
|
+
setOpenMenu(openMenu === key ? null : key);
|
|
940
|
+
} else if (key === 'help') {
|
|
941
|
+
setOpenMenu(null);
|
|
942
|
+
onOpenHelp();
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
return (
|
|
947
|
+
<header className="mb" role="menubar" aria-label="Application menubar">
|
|
948
|
+
<span className="mb-brand">
|
|
949
|
+
<span className="dot" aria-hidden="true" />
|
|
950
|
+
<span>maude</span>
|
|
951
|
+
</span>
|
|
952
|
+
<nav className="mb-menus" aria-label="Application menus">
|
|
953
|
+
{MENU_NAMES.map(name => {
|
|
954
|
+
const key = name.toLowerCase();
|
|
955
|
+
const hasDropdown = key === 'view' || key === 'selection' || key === 'tools';
|
|
956
|
+
const interactive = hasDropdown || key === 'help';
|
|
957
|
+
const open = openMenu === key;
|
|
958
|
+
return (
|
|
959
|
+
<button
|
|
960
|
+
key={key}
|
|
961
|
+
type="button"
|
|
962
|
+
className="mb-menu"
|
|
963
|
+
role="menuitem"
|
|
964
|
+
aria-haspopup={hasDropdown ? 'menu' : undefined}
|
|
965
|
+
aria-expanded={hasDropdown ? open : undefined}
|
|
966
|
+
aria-disabled={interactive ? undefined : 'true'}
|
|
967
|
+
title={interactive ? '' : 'Coming in a later phase'}
|
|
968
|
+
onClick={() => onMenuClick(key)}
|
|
969
|
+
>
|
|
970
|
+
{name}
|
|
971
|
+
</button>
|
|
972
|
+
);
|
|
973
|
+
})}
|
|
974
|
+
</nav>
|
|
975
|
+
{openMenu === 'view' && (
|
|
976
|
+
<ViewDropdown
|
|
977
|
+
panels={panels}
|
|
978
|
+
onToggle={id => {
|
|
979
|
+
if (id === 'tree') onToggleSidebar();
|
|
980
|
+
else if (id === 'comments') onToggleComments();
|
|
981
|
+
else if (id === 'hidden') onToggleShowHidden();
|
|
982
|
+
else if (id === 'annotate') onToggleAnnotations();
|
|
983
|
+
}}
|
|
984
|
+
onClose={() => setOpenMenu(null)}
|
|
985
|
+
/>
|
|
986
|
+
)}
|
|
987
|
+
{openMenu === 'selection' && (
|
|
988
|
+
<SelectionDropdown
|
|
989
|
+
onAction={(id) => {
|
|
990
|
+
if (id === 'deselect-all') postToActiveCanvas({ dgn: 'selection-clear' });
|
|
991
|
+
else if (id === 'select-all-annotations') postToActiveCanvas({ dgn: 'annotation-select-all' });
|
|
992
|
+
}}
|
|
993
|
+
onClose={() => setOpenMenu(null)}
|
|
994
|
+
/>
|
|
995
|
+
)}
|
|
996
|
+
{openMenu === 'tools' && (
|
|
997
|
+
<ToolsDropdown
|
|
998
|
+
onAction={(tool) => postToActiveCanvas({ dgn: 'tool-set', tool })}
|
|
999
|
+
onClose={() => setOpenMenu(null)}
|
|
1000
|
+
/>
|
|
1001
|
+
)}
|
|
1002
|
+
<div className="mb-spacer" />
|
|
1003
|
+
<div className="mb-status">
|
|
1004
|
+
<span className="cv-stamp">{stamp}</span>
|
|
1005
|
+
<span className="file" title={activePath || ''}>{fileLabel}</span>
|
|
1006
|
+
<span className="sep" />
|
|
1007
|
+
<span><span className="accent-dot">●</span> <b>{tabsCount}</b> ARTBOARDS</span>
|
|
1008
|
+
<span className="sep" />
|
|
1009
|
+
<span title="Pan/zoom in Phase 4">ZOOM <b>100%</b></span>
|
|
1010
|
+
<span className="sep" />
|
|
1011
|
+
<span className="ok"><b>{project || 'MDCC'}</b></span>
|
|
1012
|
+
</div>
|
|
1013
|
+
</header>
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function ThemeToggle({ theme, onToggle }) {
|
|
1018
|
+
// Show the icon of the theme you'll switch TO — clearer affordance than current state.
|
|
1019
|
+
// Sun + Moon paths are condensed Lucide-style (single-path so the existing <Icon> works).
|
|
1020
|
+
const sun = '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';
|
|
1021
|
+
const moon = 'M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z';
|
|
1022
|
+
const next = theme === 'dark' ? 'light' : 'dark';
|
|
1023
|
+
return (
|
|
1024
|
+
<button
|
|
1025
|
+
type="button"
|
|
1026
|
+
className="theme-toggle"
|
|
1027
|
+
onClick={onToggle}
|
|
1028
|
+
title={`Switch to ${next} theme`}
|
|
1029
|
+
aria-label={`Switch to ${next} theme`}
|
|
1030
|
+
>
|
|
1031
|
+
<Icon d={theme === 'dark' ? sun : moon} size={14} />
|
|
1032
|
+
<span className="theme-toggle-label">{next}</span>
|
|
1033
|
+
</button>
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function Wordmark({ project, port, version }) {
|
|
1038
|
+
return (
|
|
1039
|
+
<div className="wm" aria-label="maude design server">
|
|
1040
|
+
<span className="wm-glyph">maude-design-server</span>
|
|
1041
|
+
<span className="wm-sub">
|
|
1042
|
+
<span>CANVAS · {(project || 'MAUDE').toUpperCase()}</span>
|
|
1043
|
+
<span className="wm-sep">/</span>
|
|
1044
|
+
<b>v{version}</b>
|
|
1045
|
+
<span className="wm-sep">/</span>
|
|
1046
|
+
<span>localhost:{port || '4399'}</span>
|
|
1047
|
+
</span>
|
|
1048
|
+
</div>
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function Viewport({ tabs, activePath, registerIframe, systemData, onOpenFromSystem, project, cfg }) {
|
|
1053
|
+
return (
|
|
1054
|
+
<div className="viewport">
|
|
1055
|
+
{tabs.length === 0 && (
|
|
1056
|
+
<>
|
|
1057
|
+
<Wordmark project={project} port={typeof window !== 'undefined' ? window.location.port : ''} version={MDCC_VERSION} />
|
|
1058
|
+
<div className="empty-state">
|
|
1059
|
+
<div className="big">No mock open</div>
|
|
1060
|
+
<div className="small">
|
|
1061
|
+
← Click a <code>.tsx</code> (or legacy <code>.html</code>) file in the tree, or open the <strong>Design system</strong> view above it.
|
|
1062
|
+
<br /><br />
|
|
1063
|
+
Tabs work like in an editor — close with the × on each tab. <kbd>⌘R</kbd> reloads the active iframe.
|
|
1064
|
+
<br /><br />
|
|
1065
|
+
<strong>Element selection:</strong> hold <kbd>⌘</kbd> inside the canvas and hover for a preview, click to select. <kbd>⌘⇧</kbd>+click adds to a multi-selection. <kbd>V</kbd>/<kbd>H</kbd>/<kbd>C</kbd> swap tool; right-click opens the context menu.
|
|
1066
|
+
<br /><br />
|
|
1067
|
+
Active file, selection, and comments are tracked in <code>_active.json</code> + <code>_comments/</code> — Claude reads them when you run <code>/design</code>.
|
|
1068
|
+
</div>
|
|
1069
|
+
</div>
|
|
1070
|
+
</>
|
|
1071
|
+
)}
|
|
1072
|
+
{tabs.map(t => {
|
|
1073
|
+
if (t.path === SYSTEM_TAB) {
|
|
1074
|
+
return (
|
|
1075
|
+
<div key={t.path} className={'system-view' + (t.path === activePath ? ' active' : '')}>
|
|
1076
|
+
<SystemView data={systemData} onOpen={onOpenFromSystem} />
|
|
1077
|
+
</div>
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
return (
|
|
1081
|
+
<iframe
|
|
1082
|
+
key={t.path}
|
|
1083
|
+
ref={el => registerIframe(t.path, el)}
|
|
1084
|
+
src={canvasUrl(t.path, cfg)}
|
|
1085
|
+
className={t.path === activePath ? 'active' : ''}
|
|
1086
|
+
data-path={t.path}
|
|
1087
|
+
/>
|
|
1088
|
+
);
|
|
1089
|
+
})}
|
|
1090
|
+
</div>
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// ---------- SystemView ----------
|
|
1095
|
+
|
|
1096
|
+
const TOKEN_NAMES = [
|
|
1097
|
+
'--bg-0', '--bg-1', '--bg-2', '--bg-3', '--bg-4',
|
|
1098
|
+
'--fg-0', '--fg-1', '--fg-2', '--fg-3',
|
|
1099
|
+
'--accent', '--accent-hover', '--accent-active', '--accent-fg', '--accent-tint',
|
|
1100
|
+
'--status-success', '--status-warn', '--status-error', '--status-info',
|
|
1101
|
+
'--border-subtle', '--border-default', '--border-strong',
|
|
1102
|
+
];
|
|
1103
|
+
const TYPE_STEPS = ['xs', 'sm', 'base', 'md', 'lg', 'xl', '2xl', '3xl'];
|
|
1104
|
+
|
|
1105
|
+
function readTokens(names) {
|
|
1106
|
+
if (typeof window === 'undefined') return names.map(name => ({ name, value: '' }));
|
|
1107
|
+
const cs = getComputedStyle(document.documentElement);
|
|
1108
|
+
return names.map(name => ({ name, value: cs.getPropertyValue(name).trim() }));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function TokenLadder() {
|
|
1112
|
+
const [tokens, setTokens] = useState(() => readTokens(TOKEN_NAMES));
|
|
1113
|
+
useEffect(() => {
|
|
1114
|
+
setTokens(readTokens(TOKEN_NAMES));
|
|
1115
|
+
const obs = new MutationObserver(() => setTokens(readTokens(TOKEN_NAMES)));
|
|
1116
|
+
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
1117
|
+
return () => obs.disconnect();
|
|
1118
|
+
}, []);
|
|
1119
|
+
return (
|
|
1120
|
+
<section className="sv-section sv-section-tokens">
|
|
1121
|
+
<h2>tokens · surfaces & ink<span className="sv-h-num">{tokens.length}</span></h2>
|
|
1122
|
+
<div className="sv-tokens-ladder">
|
|
1123
|
+
{tokens.map(t => (
|
|
1124
|
+
<div className="sv-tok-cell" key={t.name}>
|
|
1125
|
+
<div className="sv-tok-swatch" style={{ background: `var(${t.name})` }} />
|
|
1126
|
+
<div className="sv-tok-meta">
|
|
1127
|
+
<code className="sv-tok-name">{t.name}</code>
|
|
1128
|
+
<span className="sv-tok-value">{t.value || '—'}</span>
|
|
1129
|
+
</div>
|
|
1130
|
+
</div>
|
|
1131
|
+
))}
|
|
1132
|
+
</div>
|
|
1133
|
+
</section>
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function TypeLadder() {
|
|
1138
|
+
return (
|
|
1139
|
+
<section className="sv-section sv-section-type">
|
|
1140
|
+
<h2>type · 8-step ladder<span className="sv-h-num">{TYPE_STEPS.length}</span></h2>
|
|
1141
|
+
<div className="sv-type-list">
|
|
1142
|
+
{TYPE_STEPS.map(s => (
|
|
1143
|
+
<div className="sv-type-row" key={s}>
|
|
1144
|
+
<code className="sv-type-tok">--type-{s}</code>
|
|
1145
|
+
<span className="sv-type-sample" style={{ fontSize: `var(--type-${s})`, lineHeight: `var(--lh-${s})` }}>
|
|
1146
|
+
The catalog is the system.
|
|
1147
|
+
</span>
|
|
1148
|
+
</div>
|
|
1149
|
+
))}
|
|
1150
|
+
</div>
|
|
1151
|
+
</section>
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function SystemView({ data, onOpen }) {
|
|
1156
|
+
if (!data) {
|
|
1157
|
+
return <div className="sv-empty"><p>Loading design system…</p></div>;
|
|
1158
|
+
}
|
|
1159
|
+
const { previewGallery, uiKitsGallery, systemDir } = data;
|
|
1160
|
+
const empty = (!previewGallery || !previewGallery.length) && (!uiKitsGallery || !uiKitsGallery.length);
|
|
1161
|
+
|
|
1162
|
+
return (
|
|
1163
|
+
<div className="sv">
|
|
1164
|
+
<header className="sv-header">
|
|
1165
|
+
<span className="sv-sku">MDCC-DSN/01</span>
|
|
1166
|
+
<span className="sv-title">design system view</span>
|
|
1167
|
+
<span className="sv-loc"><code>{systemDir}</code></span>
|
|
1168
|
+
</header>
|
|
1169
|
+
|
|
1170
|
+
<TokenLadder />
|
|
1171
|
+
<TypeLadder />
|
|
1172
|
+
|
|
1173
|
+
{empty ? (
|
|
1174
|
+
<div className="sv-empty">
|
|
1175
|
+
<p>No <code>preview/</code> or <code>ui_kits/</code> folders found under <code>{systemDir}</code>.</p>
|
|
1176
|
+
</div>
|
|
1177
|
+
) : (
|
|
1178
|
+
<>
|
|
1179
|
+
<Gallery title="preview" items={previewGallery} onOpen={onOpen} kind="preview" />
|
|
1180
|
+
<Gallery title="ui kits" items={uiKitsGallery} onOpen={onOpen} kind="ui_kits" />
|
|
1181
|
+
</>
|
|
1182
|
+
)}
|
|
1183
|
+
</div>
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function Gallery({ title, items, onOpen, kind }) {
|
|
1188
|
+
if (!items || items.length === 0) return null;
|
|
1189
|
+
return (
|
|
1190
|
+
<section className="sv-section">
|
|
1191
|
+
<h2>{title} <span className="sv-count">{items.length}</span></h2>
|
|
1192
|
+
<div className={'sv-previews sv-previews-' + kind}>
|
|
1193
|
+
{items.map(p => (
|
|
1194
|
+
<article key={p.path} className="sv-preview-card" onClick={() => onOpen(p.path)}>
|
|
1195
|
+
<div className="sv-preview-frame">
|
|
1196
|
+
<iframe src={urlOf(p.path)} title={p.label} scrolling="no" />
|
|
1197
|
+
</div>
|
|
1198
|
+
<div className="sv-preview-foot">
|
|
1199
|
+
<strong>{p.label}</strong>
|
|
1200
|
+
<code>{p.path}</code>
|
|
1201
|
+
</div>
|
|
1202
|
+
</article>
|
|
1203
|
+
))}
|
|
1204
|
+
</div>
|
|
1205
|
+
</section>
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// ---------- Comment composer / viewer ----------
|
|
1210
|
+
|
|
1211
|
+
function CommentBar({ activePath, selected, comments, focusedId, draft, setDraft, onSubmit, onCancel, onResolve, onReopen, onDelete, onFocusPin }) {
|
|
1212
|
+
if (!activePath) return null;
|
|
1213
|
+
const focused = focusedId ? comments.find(c => c.id === focusedId) : null;
|
|
1214
|
+
const openComments = (comments || []).filter(c => c.status !== 'resolved');
|
|
1215
|
+
return (
|
|
1216
|
+
<div className="comment-bar">
|
|
1217
|
+
{draft && draft.file === activePath && (
|
|
1218
|
+
<div className="composer">
|
|
1219
|
+
<div className="composer-head">
|
|
1220
|
+
<span className="cb-label">Comment on</span>
|
|
1221
|
+
<code className="composer-selector" title={(draft.dom_path || []).join(' > ')}>{draft.selector || '(canvas)'}</code>
|
|
1222
|
+
</div>
|
|
1223
|
+
<textarea
|
|
1224
|
+
autoFocus
|
|
1225
|
+
className="composer-textarea"
|
|
1226
|
+
value={draft.text}
|
|
1227
|
+
placeholder="What should change here? (⌘↵ save · Esc cancel)"
|
|
1228
|
+
onChange={e => setDraft({ ...draft, text: e.target.value })}
|
|
1229
|
+
onKeyDown={e => {
|
|
1230
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); onSubmit(); }
|
|
1231
|
+
else if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
|
|
1232
|
+
}}
|
|
1233
|
+
rows={4}
|
|
1234
|
+
/>
|
|
1235
|
+
<div className="composer-actions">
|
|
1236
|
+
<button className="cb-secondary" onClick={onCancel}>Cancel</button>
|
|
1237
|
+
<button className="cb-primary" disabled={!draft.text.trim()} onClick={onSubmit}>Save · ⌘↵</button>
|
|
1238
|
+
</div>
|
|
1239
|
+
</div>
|
|
1240
|
+
)}
|
|
1241
|
+
|
|
1242
|
+
{focused && (
|
|
1243
|
+
<div className="cb-row focused">
|
|
1244
|
+
<span className="cb-pinno">#{(comments || []).filter(c => c.selector).findIndex(c => c.id === focused.id) + 1}</span>
|
|
1245
|
+
<span className="cb-text">{focused.text}</span>
|
|
1246
|
+
<span className="cb-target" title={focused.dom_path ? focused.dom_path.join(' > ') : ''}>
|
|
1247
|
+
<code>{focused.selector || '—'}</code>
|
|
1248
|
+
</span>
|
|
1249
|
+
{focused.status === 'resolved'
|
|
1250
|
+
? <button className="cb-secondary" onClick={() => onReopen(focused.id)}>Reopen</button>
|
|
1251
|
+
: <button className="cb-primary" onClick={() => onResolve(focused.id)}>✓ Resolve</button>}
|
|
1252
|
+
<button className="cb-secondary" onClick={() => onDelete(focused.id)}>Delete</button>
|
|
1253
|
+
</div>
|
|
1254
|
+
)}
|
|
1255
|
+
|
|
1256
|
+
{!draft && !focused && openComments.length > 0 && (
|
|
1257
|
+
<div className="cb-row strip">
|
|
1258
|
+
<span className="cb-label">{openComments.length} open comment{openComments.length === 1 ? '' : 's'}</span>
|
|
1259
|
+
<div className="cb-pin-strip">
|
|
1260
|
+
{openComments.slice(0, 12).map((c, i) => (
|
|
1261
|
+
<button key={c.id} className="cb-pin-chip" title={c.text} onClick={() => onFocusPin(c.id)}>
|
|
1262
|
+
{i + 1}
|
|
1263
|
+
</button>
|
|
1264
|
+
))}
|
|
1265
|
+
{openComments.length > 12 && <span className="cb-more">+{openComments.length - 12}</span>}
|
|
1266
|
+
</div>
|
|
1267
|
+
</div>
|
|
1268
|
+
)}
|
|
1269
|
+
</div>
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function StatusBarSlot({ label, children, className = '' }) {
|
|
1274
|
+
return (
|
|
1275
|
+
<span className={'sb-slot ' + className} role="group" aria-label={label}>
|
|
1276
|
+
{children}
|
|
1277
|
+
</span>
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function StatusBar({ activePath, selected, wsConnected, openCount, theme, onToggleTheme, onClearSelected, onAddComment, hasDraft }) {
|
|
1282
|
+
const isSystem = activePath === SYSTEM_TAB;
|
|
1283
|
+
const text = selected && selected.selector
|
|
1284
|
+
? selected.selector + (selected.text ? ` — "${selected.text.slice(0, 60)}"` : '')
|
|
1285
|
+
: '';
|
|
1286
|
+
const title = selected && selected.dom_path ? selected.dom_path.join(' > ') : (selected ? selected.selector : '');
|
|
1287
|
+
return (
|
|
1288
|
+
<div className="statusbar" role="contentinfo">
|
|
1289
|
+
<StatusBarSlot label="Active file" className="sb-active">
|
|
1290
|
+
<span className="sb-key">active</span>
|
|
1291
|
+
<span className="sb-file" title={activePath || ''}>
|
|
1292
|
+
{isSystem ? '▦ design system' : (activePath || '—')}
|
|
1293
|
+
</span>
|
|
1294
|
+
</StatusBarSlot>
|
|
1295
|
+
|
|
1296
|
+
{selected && selected.selector && !isSystem && (
|
|
1297
|
+
<StatusBarSlot label="Selected element" className="sb-selected">
|
|
1298
|
+
<span className="sb-dot" aria-hidden="true">●</span>
|
|
1299
|
+
<span className="sb-sel-text" title={title}>{text}</span>
|
|
1300
|
+
{!hasDraft && (
|
|
1301
|
+
<button type="button" className="sb-add-comment" onClick={onAddComment} title="Add comment on selected element (⌘⇧+click in canvas)">+ comment</button>
|
|
1302
|
+
)}
|
|
1303
|
+
<button type="button" className="sb-clear-sel" onClick={onClearSelected} title="Clear (Esc inside iframe)" aria-label="Clear selection">×</button>
|
|
1304
|
+
</StatusBarSlot>
|
|
1305
|
+
)}
|
|
1306
|
+
|
|
1307
|
+
<StatusBarSlot label="Open comments" className="sb-unread">
|
|
1308
|
+
<span className="sb-key">comments</span>
|
|
1309
|
+
<span className="sb-count">{openCount}</span>
|
|
1310
|
+
</StatusBarSlot>
|
|
1311
|
+
|
|
1312
|
+
<StatusBarSlot label="Connection" className="sb-live">
|
|
1313
|
+
<span className={'sb-live-dot' + (wsConnected ? ' connected' : '')} aria-hidden="true" />
|
|
1314
|
+
<span className="sb-key">{wsConnected ? 'live' : 'reconnecting'}</span>
|
|
1315
|
+
</StatusBarSlot>
|
|
1316
|
+
|
|
1317
|
+
<span className="sb-spacer" />
|
|
1318
|
+
|
|
1319
|
+
<StatusBarSlot label="Theme" className="sb-theme">
|
|
1320
|
+
<ThemeToggle theme={theme} onToggle={onToggleTheme} />
|
|
1321
|
+
</StatusBarSlot>
|
|
1322
|
+
</div>
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// ---------- Right sidebar — Comments panel ----------
|
|
1327
|
+
|
|
1328
|
+
function CommentsPanel({ commentsByFile, filter, setFilter, activePath, focusedId, onJump, onResolve, onReopen, onDelete }) {
|
|
1329
|
+
const counts = totalCounts(commentsByFile);
|
|
1330
|
+
// Build groups: [{ file, comments: filtered }]
|
|
1331
|
+
const files = Object.keys(commentsByFile || {}).sort();
|
|
1332
|
+
const groups = [];
|
|
1333
|
+
for (const f of files) {
|
|
1334
|
+
const all = commentsByFile[f] || [];
|
|
1335
|
+
const filtered = all.filter(c => {
|
|
1336
|
+
if (filter === 'open') return c.status !== 'resolved';
|
|
1337
|
+
if (filter === 'resolved') return c.status === 'resolved';
|
|
1338
|
+
return true;
|
|
1339
|
+
});
|
|
1340
|
+
if (filtered.length === 0) continue;
|
|
1341
|
+
// Number is fixed by all-list order so it matches pin numbers (which are based on position in the array of selector-having comments)
|
|
1342
|
+
const numberedAll = all.filter(c => c.selector);
|
|
1343
|
+
groups.push({
|
|
1344
|
+
file: f,
|
|
1345
|
+
comments: filtered.map(c => ({
|
|
1346
|
+
...c,
|
|
1347
|
+
n: numberedAll.findIndex(x => x.id === c.id) + 1,
|
|
1348
|
+
})),
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
return (
|
|
1353
|
+
<aside className="rsidebar">
|
|
1354
|
+
<div className="rsidebar-header">
|
|
1355
|
+
<h2>
|
|
1356
|
+
<span>Comments</span>
|
|
1357
|
+
<span className="total">{counts.all}</span>
|
|
1358
|
+
</h2>
|
|
1359
|
+
<div className="rsidebar-filters" role="tablist">
|
|
1360
|
+
<button
|
|
1361
|
+
className={'rsidebar-filter' + (filter === 'all' ? ' active' : '')}
|
|
1362
|
+
role="tab" aria-selected={filter === 'all'}
|
|
1363
|
+
onClick={() => setFilter('all')}
|
|
1364
|
+
>All <span className="fc">{counts.all}</span></button>
|
|
1365
|
+
<button
|
|
1366
|
+
className={'rsidebar-filter' + (filter === 'open' ? ' active' : '')}
|
|
1367
|
+
role="tab" aria-selected={filter === 'open'}
|
|
1368
|
+
onClick={() => setFilter('open')}
|
|
1369
|
+
>Open <span className="fc">{counts.open}</span></button>
|
|
1370
|
+
<button
|
|
1371
|
+
className={'rsidebar-filter' + (filter === 'resolved' ? ' active' : '')}
|
|
1372
|
+
role="tab" aria-selected={filter === 'resolved'}
|
|
1373
|
+
onClick={() => setFilter('resolved')}
|
|
1374
|
+
>Resolved <span className="fc">{counts.resolved}</span></button>
|
|
1375
|
+
</div>
|
|
1376
|
+
</div>
|
|
1377
|
+
<div className="rsidebar-body">
|
|
1378
|
+
{groups.length === 0 ? (
|
|
1379
|
+
<div className="rsidebar-empty">
|
|
1380
|
+
<p>No comments {filter !== 'all' ? `with status “${filter}”` : 'yet'}.</p>
|
|
1381
|
+
<p style={{ marginTop: 12 }}>Open a canvas, hold <kbd>⌘</kbd> and click an element, then press <kbd>C</kbd> — or hold <kbd>⌘⇧</kbd> and click directly.</p>
|
|
1382
|
+
</div>
|
|
1383
|
+
) : groups.map(g => (
|
|
1384
|
+
<div key={g.file} className="rs-group">
|
|
1385
|
+
<button
|
|
1386
|
+
className="rs-group-h"
|
|
1387
|
+
onClick={() => onJump(g.file, null)}
|
|
1388
|
+
title={g.file}
|
|
1389
|
+
>
|
|
1390
|
+
<span className="rs-group-name">{displayName(basename(g.file))}</span>
|
|
1391
|
+
<span className="rs-group-count">{g.comments.length}</span>
|
|
1392
|
+
</button>
|
|
1393
|
+
{g.comments.map(c => (
|
|
1394
|
+
<div
|
|
1395
|
+
key={c.id}
|
|
1396
|
+
className={'rs-comment' + (c.status === 'resolved' ? ' resolved' : '') + (c.id === focusedId ? ' active-pin' : '')}
|
|
1397
|
+
onClick={() => onJump(g.file, c.id)}
|
|
1398
|
+
>
|
|
1399
|
+
<div className="rs-comment-head">
|
|
1400
|
+
<span className="rs-num">{c.n || '·'}</span>
|
|
1401
|
+
<span className="rs-time">{timeAgo(c.created)}</span>
|
|
1402
|
+
</div>
|
|
1403
|
+
<div className="rs-comment-text">{c.text}</div>
|
|
1404
|
+
<div className="rs-comment-foot">
|
|
1405
|
+
<code title={(c.dom_path || []).join(' > ')}>{c.selector || '—'}</code>
|
|
1406
|
+
<div className="rs-comment-actions">
|
|
1407
|
+
{c.status === 'resolved'
|
|
1408
|
+
? <button className="rs-act" onClick={e => { e.stopPropagation(); onReopen(c.id); }}>↺</button>
|
|
1409
|
+
: <button className="rs-act" onClick={e => { e.stopPropagation(); onResolve(c.id); }}>✓</button>}
|
|
1410
|
+
<button className="rs-act danger" onClick={e => { e.stopPropagation(); onDelete(c.id); }}>×</button>
|
|
1411
|
+
</div>
|
|
1412
|
+
</div>
|
|
1413
|
+
</div>
|
|
1414
|
+
))}
|
|
1415
|
+
</div>
|
|
1416
|
+
))}
|
|
1417
|
+
</div>
|
|
1418
|
+
</aside>
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// ---------- App ----------
|
|
1423
|
+
|
|
1424
|
+
function App() {
|
|
1425
|
+
const [groups, setGroups] = useState([]);
|
|
1426
|
+
const [project, setProject] = useState('Design');
|
|
1427
|
+
const [tabs, setTabs] = useState([]);
|
|
1428
|
+
const [activePath, setActivePath] = useState(null);
|
|
1429
|
+
const [selected, setSelected] = useState(null);
|
|
1430
|
+
const [wsConnected, setWsConnected] = useState(false);
|
|
1431
|
+
const [search, setSearch] = useState('');
|
|
1432
|
+
const [systemData, setSystemData] = useState(null);
|
|
1433
|
+
// Loaded once at boot from /_config — informs canvasUrl() so TSX iframes
|
|
1434
|
+
// can pass the right ?designRel + ?tokens query to the canvas mount shell.
|
|
1435
|
+
const [cfg, setCfg] = useState({ designRel: '.design' });
|
|
1436
|
+
useEffect(() => {
|
|
1437
|
+
let cancelled = false;
|
|
1438
|
+
fetch('/_config')
|
|
1439
|
+
.then((r) => r.json())
|
|
1440
|
+
.then((data) => {
|
|
1441
|
+
if (cancelled) return;
|
|
1442
|
+
const designRel = (data.designRoot || '.design').replace(/^\/+|\/+$/g, '');
|
|
1443
|
+
setCfg({
|
|
1444
|
+
designRel,
|
|
1445
|
+
tokensCssRel: data.tokensCssRel,
|
|
1446
|
+
// Pass through designSystems so canvasUrl can resolve the right
|
|
1447
|
+
// tokens/components paths per-DS. Top-level tokensCssRel is the
|
|
1448
|
+
// legacy default; designSystems[0].tokensCssRel is the project's
|
|
1449
|
+
// authoritative value (post DS-bootstrap).
|
|
1450
|
+
designSystems: data.designSystems,
|
|
1451
|
+
});
|
|
1452
|
+
})
|
|
1453
|
+
.catch(() => {});
|
|
1454
|
+
return () => { cancelled = true; };
|
|
1455
|
+
}, []);
|
|
1456
|
+
const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
|
|
1457
|
+
const [draft, setDraft] = useState(null); // { file, selector, dom_path, bounds, tag, classes, html, text }
|
|
1458
|
+
const [focusedCommentId, setFocusedCommentId] = useState(null);
|
|
1459
|
+
const [commentsPanelOpen, setCommentsPanelOpen] = useState(false);
|
|
1460
|
+
const [commentsFilter, setCommentsFilter] = useState('open'); // 'all' | 'open' | 'resolved'
|
|
1461
|
+
const [theme, setTheme] = useState(readInitialTheme);
|
|
1462
|
+
const [openMenu, setOpenMenu] = useState(null);
|
|
1463
|
+
const [sidebarOpen, setSidebarOpen] = useState(() => readBoolStore(SIDEBAR_STORE, true));
|
|
1464
|
+
const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
|
|
1465
|
+
const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
|
|
1466
|
+
const [helpOpen, setHelpOpen] = useState(false);
|
|
1467
|
+
const [annotationsVisible, setAnnotationsVisible] = useState(true);
|
|
1468
|
+
const wsRef = useRef(null);
|
|
1469
|
+
const iframesRef = useRef(new Map());
|
|
1470
|
+
|
|
1471
|
+
// Phase 5.1 — postMessage bridge from menubar dropdowns to the canvas iframe.
|
|
1472
|
+
// The canvas-shell listens for these `dgn:*` messages and dispatches into the
|
|
1473
|
+
// matching local provider (annotations visibility / both selection stores /
|
|
1474
|
+
// tool mode). Mirrors the existing `force-clear` / `select-clear` channel.
|
|
1475
|
+
const postToActiveCanvas = useCallback((payload) => {
|
|
1476
|
+
const el = activePath ? iframesRef.current.get(activePath) : null;
|
|
1477
|
+
if (!el || !el.contentWindow) return;
|
|
1478
|
+
try { el.contentWindow.postMessage(payload, '*'); } catch {}
|
|
1479
|
+
}, [activePath]);
|
|
1480
|
+
|
|
1481
|
+
const toggleAnnotations = useCallback(() => {
|
|
1482
|
+
setAnnotationsVisible((v) => {
|
|
1483
|
+
const next = !v;
|
|
1484
|
+
const el = activePath ? iframesRef.current.get(activePath) : null;
|
|
1485
|
+
if (el && el.contentWindow) {
|
|
1486
|
+
try { el.contentWindow.postMessage({ dgn: 'view-annotations', visible: next }, '*'); } catch {}
|
|
1487
|
+
}
|
|
1488
|
+
return next;
|
|
1489
|
+
});
|
|
1490
|
+
}, [activePath]);
|
|
1491
|
+
|
|
1492
|
+
// Sync theme to <html data-theme> + localStorage on every change.
|
|
1493
|
+
useEffect(() => {
|
|
1494
|
+
try {
|
|
1495
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
1496
|
+
localStorage.setItem(THEME_STORE, theme);
|
|
1497
|
+
} catch {}
|
|
1498
|
+
}, [theme]);
|
|
1499
|
+
|
|
1500
|
+
// Persist sidebar / hidden-files / DS-body toggles. Mirror theme pattern.
|
|
1501
|
+
useEffect(() => {
|
|
1502
|
+
try { localStorage.setItem(SIDEBAR_STORE, sidebarOpen ? '1' : '0'); } catch {}
|
|
1503
|
+
}, [sidebarOpen]);
|
|
1504
|
+
useEffect(() => {
|
|
1505
|
+
try { localStorage.setItem(SHOW_HIDDEN_STORE, showHidden ? '1' : '0'); } catch {}
|
|
1506
|
+
}, [showHidden]);
|
|
1507
|
+
useEffect(() => {
|
|
1508
|
+
try { localStorage.setItem(SECTIONS_STORE, JSON.stringify(sectionsExpanded)); } catch {}
|
|
1509
|
+
}, [sectionsExpanded]);
|
|
1510
|
+
|
|
1511
|
+
const toggleSection = useCallback((label, defaultOpen) => {
|
|
1512
|
+
setSectionsExpanded(prev => {
|
|
1513
|
+
const cur = prev[label];
|
|
1514
|
+
const isOpen = cur === undefined ? defaultOpen : cur;
|
|
1515
|
+
return { ...prev, [label]: !isOpen };
|
|
1516
|
+
});
|
|
1517
|
+
}, []);
|
|
1518
|
+
|
|
1519
|
+
const toggleTheme = useCallback(() => {
|
|
1520
|
+
setTheme(t => (t === 'dark' ? 'light' : 'dark'));
|
|
1521
|
+
}, []);
|
|
1522
|
+
|
|
1523
|
+
// ----- Tree -----
|
|
1524
|
+
const loadTree = useCallback(async () => {
|
|
1525
|
+
try {
|
|
1526
|
+
const r = await fetch('/_index-data');
|
|
1527
|
+
const data = await r.json();
|
|
1528
|
+
setProject(data.project || 'Design');
|
|
1529
|
+
const built = data.groups.map(g => ({
|
|
1530
|
+
...g,
|
|
1531
|
+
tree: buildTree(g.paths, g.stripPrefix),
|
|
1532
|
+
}));
|
|
1533
|
+
setGroups(built);
|
|
1534
|
+
} catch (e) {
|
|
1535
|
+
console.error('failed to load tree', e);
|
|
1536
|
+
}
|
|
1537
|
+
}, []);
|
|
1538
|
+
|
|
1539
|
+
useEffect(() => { loadTree(); }, [loadTree]);
|
|
1540
|
+
|
|
1541
|
+
// ----- System data (lazy) -----
|
|
1542
|
+
const loadSystemData = useCallback(async () => {
|
|
1543
|
+
try {
|
|
1544
|
+
const r = await fetch('/_system-data');
|
|
1545
|
+
const data = await r.json();
|
|
1546
|
+
setSystemData(data);
|
|
1547
|
+
} catch (e) {
|
|
1548
|
+
console.error('failed to load system-data', e);
|
|
1549
|
+
}
|
|
1550
|
+
}, []);
|
|
1551
|
+
|
|
1552
|
+
// ----- Comments — initial load of all files -----
|
|
1553
|
+
const loadAllComments = useCallback(async () => {
|
|
1554
|
+
try {
|
|
1555
|
+
const r = await fetch('/_comments-all');
|
|
1556
|
+
const data = await r.json();
|
|
1557
|
+
setCommentsByFile(data || {});
|
|
1558
|
+
} catch (e) {
|
|
1559
|
+
console.error('failed to load comments', e);
|
|
1560
|
+
}
|
|
1561
|
+
}, []);
|
|
1562
|
+
|
|
1563
|
+
useEffect(() => { loadAllComments(); }, [loadAllComments]);
|
|
1564
|
+
|
|
1565
|
+
// ----- WebSocket -----
|
|
1566
|
+
useEffect(() => {
|
|
1567
|
+
function connect() {
|
|
1568
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1569
|
+
const ws = new WebSocket(proto + '//' + location.host + '/_ws');
|
|
1570
|
+
wsRef.current = ws;
|
|
1571
|
+
ws.addEventListener('open', () => setWsConnected(true));
|
|
1572
|
+
ws.addEventListener('close', () => {
|
|
1573
|
+
setWsConnected(false);
|
|
1574
|
+
setTimeout(connect, 1000);
|
|
1575
|
+
});
|
|
1576
|
+
ws.addEventListener('error', () => {});
|
|
1577
|
+
ws.addEventListener('message', e => {
|
|
1578
|
+
try {
|
|
1579
|
+
const m = JSON.parse(e.data);
|
|
1580
|
+
if (m.type === 'snapshot' && m.state) {
|
|
1581
|
+
setSelected(m.state.selected);
|
|
1582
|
+
} else if (m.type === 'selected') {
|
|
1583
|
+
setSelected(m.selected);
|
|
1584
|
+
} else if (m.type === 'comments' && typeof m.file === 'string') {
|
|
1585
|
+
setCommentsByFile(prev => ({ ...prev, [m.file]: m.comments || [] }));
|
|
1586
|
+
}
|
|
1587
|
+
} catch {}
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
connect();
|
|
1591
|
+
return () => wsRef.current && wsRef.current.close();
|
|
1592
|
+
}, []);
|
|
1593
|
+
|
|
1594
|
+
function wsSend(obj) {
|
|
1595
|
+
const ws = wsRef.current;
|
|
1596
|
+
try { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); } catch {}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// ----- Tab management (single-canvas) -----
|
|
1600
|
+
// Single-canvas model: opening a file REPLACES the active one (no tab strip).
|
|
1601
|
+
// The `tabs` state stays as a 0-or-1 array so the rest of the plumbing
|
|
1602
|
+
// (iframesRef, comments push, WS `tabs` message) doesn't need refactoring.
|
|
1603
|
+
// ARTBOARDS slot in the menubar reads `tabs.length` and reports 0 or 1.
|
|
1604
|
+
const openTab = useCallback((path) => {
|
|
1605
|
+
setTabs(prev => {
|
|
1606
|
+
// Drop the previously-open iframe so we don't leak DOM nodes.
|
|
1607
|
+
for (const t of prev) if (t.path !== path) iframesRef.current.delete(t.path);
|
|
1608
|
+
return [{ path }];
|
|
1609
|
+
});
|
|
1610
|
+
setActivePath(path);
|
|
1611
|
+
setFocusedCommentId(null);
|
|
1612
|
+
setDraft(null);
|
|
1613
|
+
}, []);
|
|
1614
|
+
|
|
1615
|
+
const openSystem = useCallback(() => {
|
|
1616
|
+
if (!systemData) loadSystemData();
|
|
1617
|
+
openTab(SYSTEM_TAB);
|
|
1618
|
+
}, [systemData, loadSystemData, openTab]);
|
|
1619
|
+
|
|
1620
|
+
useEffect(() => {
|
|
1621
|
+
wsSend({ type: 'tabs', tabs: tabs.map(t => t.path).filter(p => p !== SYSTEM_TAB) });
|
|
1622
|
+
}, [tabs]);
|
|
1623
|
+
|
|
1624
|
+
useEffect(() => {
|
|
1625
|
+
if (activePath && activePath !== SYSTEM_TAB) wsSend({ type: 'active', file: activePath });
|
|
1626
|
+
else if (activePath === SYSTEM_TAB) wsSend({ type: 'active', file: '' });
|
|
1627
|
+
else wsSend({ type: 'active', file: '' });
|
|
1628
|
+
}, [activePath]);
|
|
1629
|
+
|
|
1630
|
+
const closeTab = useCallback((path) => {
|
|
1631
|
+
setTabs(prev => {
|
|
1632
|
+
const idx = prev.findIndex(t => t.path === path);
|
|
1633
|
+
if (idx < 0) return prev;
|
|
1634
|
+
const next = prev.filter(t => t.path !== path);
|
|
1635
|
+
if (path === activePath) {
|
|
1636
|
+
if (next.length === 0) setActivePath(null);
|
|
1637
|
+
else setActivePath(next[Math.max(0, idx - 1)].path);
|
|
1638
|
+
}
|
|
1639
|
+
return next;
|
|
1640
|
+
});
|
|
1641
|
+
iframesRef.current.delete(path);
|
|
1642
|
+
}, [activePath]);
|
|
1643
|
+
|
|
1644
|
+
const reloadActive = useCallback(() => {
|
|
1645
|
+
if (!activePath || activePath === SYSTEM_TAB) {
|
|
1646
|
+
if (activePath === SYSTEM_TAB) loadSystemData();
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
const el = iframesRef.current.get(activePath);
|
|
1650
|
+
if (el) el.src = el.src;
|
|
1651
|
+
}, [activePath, loadSystemData]);
|
|
1652
|
+
|
|
1653
|
+
const reloadTree = useCallback(() => loadTree(), [loadTree]);
|
|
1654
|
+
|
|
1655
|
+
const clearSelected = useCallback(() => {
|
|
1656
|
+
wsSend({ type: 'clear-select' });
|
|
1657
|
+
setSelected(null);
|
|
1658
|
+
if (activePath && activePath !== SYSTEM_TAB) {
|
|
1659
|
+
const el = iframesRef.current.get(activePath);
|
|
1660
|
+
if (el && el.contentWindow) {
|
|
1661
|
+
try { el.contentWindow.postMessage({ dgn: 'force-clear' }, '*'); } catch {}
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
}, [activePath]);
|
|
1665
|
+
|
|
1666
|
+
// ----- Push comments to iframe whenever they change for active file -----
|
|
1667
|
+
useEffect(() => {
|
|
1668
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
1669
|
+
const el = iframesRef.current.get(activePath);
|
|
1670
|
+
if (!el || !el.contentWindow) return;
|
|
1671
|
+
const list = commentsByFile[activePath] || [];
|
|
1672
|
+
try { el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*'); } catch {}
|
|
1673
|
+
}, [activePath, commentsByFile]);
|
|
1674
|
+
|
|
1675
|
+
// ----- Comment composer helpers -----
|
|
1676
|
+
// Declared BEFORE the inbound-message useEffect that references them — under
|
|
1677
|
+
// ES build (no var-style hoisting) these are real TDZ violations otherwise.
|
|
1678
|
+
const startDraftFor = useCallback((sel) => {
|
|
1679
|
+
const file = (sel && sel.file) || activePath;
|
|
1680
|
+
if (!file || file === SYSTEM_TAB) return;
|
|
1681
|
+
setDraft({
|
|
1682
|
+
file,
|
|
1683
|
+
selector: sel?.selector || '',
|
|
1684
|
+
dom_path: sel?.dom_path || [],
|
|
1685
|
+
tag: sel?.tag || '',
|
|
1686
|
+
classes: sel?.classes || '',
|
|
1687
|
+
bounds: sel?.bounds || null,
|
|
1688
|
+
html: sel?.html || '',
|
|
1689
|
+
text: '',
|
|
1690
|
+
});
|
|
1691
|
+
setFocusedCommentId(null);
|
|
1692
|
+
}, [activePath]);
|
|
1693
|
+
|
|
1694
|
+
const startDraftFromSelection = useCallback(() => {
|
|
1695
|
+
if (!selected || !selected.selector) return;
|
|
1696
|
+
startDraftFor(selected);
|
|
1697
|
+
}, [selected, startDraftFor]);
|
|
1698
|
+
|
|
1699
|
+
// ----- Inbound messages from iframes -----
|
|
1700
|
+
useEffect(() => {
|
|
1701
|
+
function onMessage(e) {
|
|
1702
|
+
const m = e.data;
|
|
1703
|
+
if (!m || typeof m !== 'object' || !m.dgn) return;
|
|
1704
|
+
if (m.dgn === 'select' && m.selection) {
|
|
1705
|
+
wsSend({ type: 'select', selection: m.selection });
|
|
1706
|
+
setSelected(m.selection);
|
|
1707
|
+
} else if (m.dgn === 'select-set') {
|
|
1708
|
+
// Canvas multi-select. Payload shape:
|
|
1709
|
+
// null → empty selection
|
|
1710
|
+
// Selection → length-1 (back-compat with legacy single-element shape)
|
|
1711
|
+
// Selection[] → N > 1
|
|
1712
|
+
// For shell purposes we track the focused entry (head of array, or
|
|
1713
|
+
// the bare object) — comments + halo only act on one element at a
|
|
1714
|
+
// time today. Multi-target editing is an explicit Phase-4.1 non-goal.
|
|
1715
|
+
const payload = m.selection;
|
|
1716
|
+
if (payload == null) {
|
|
1717
|
+
wsSend({ type: 'clear-select' });
|
|
1718
|
+
setSelected(null);
|
|
1719
|
+
} else if (Array.isArray(payload)) {
|
|
1720
|
+
const head = payload[0] ?? null;
|
|
1721
|
+
if (head) wsSend({ type: 'select', selection: head });
|
|
1722
|
+
setSelected(head);
|
|
1723
|
+
} else {
|
|
1724
|
+
wsSend({ type: 'select', selection: payload });
|
|
1725
|
+
setSelected(payload);
|
|
1726
|
+
}
|
|
1727
|
+
} else if (m.dgn === 'clear-select') {
|
|
1728
|
+
wsSend({ type: 'clear-select' });
|
|
1729
|
+
setSelected(null);
|
|
1730
|
+
} else if (m.dgn === 'comment-compose' && m.selection) {
|
|
1731
|
+
// Canvas C-tool / right-click "Add comment" converge here. The shell
|
|
1732
|
+
// opens a composer for the target.
|
|
1733
|
+
startDraftFor(m.selection);
|
|
1734
|
+
} else if (m.dgn === 'comment-shortcut') {
|
|
1735
|
+
// Carry-over for any legacy `.html` mock or external embed that
|
|
1736
|
+
// still posts this. Canvas-shell uses `comment-compose` directly.
|
|
1737
|
+
startDraftFromSelection();
|
|
1738
|
+
} else if (m.dgn === 'comment-click' && m.id) {
|
|
1739
|
+
setFocusedCommentId(m.id);
|
|
1740
|
+
} else if (m.dgn === 'loaded' && m.file) {
|
|
1741
|
+
// iframe finished loading — push current comments + carry over focused pin if any
|
|
1742
|
+
const list = commentsByFile[m.file] || [];
|
|
1743
|
+
const el = [...iframesRef.current.entries()].find(([k]) => k === m.file)?.[1];
|
|
1744
|
+
if (el && el.contentWindow) {
|
|
1745
|
+
try { el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*'); } catch {}
|
|
1746
|
+
if (focusedCommentId && list.some(c => c.id === focusedCommentId)) {
|
|
1747
|
+
try { el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*'); } catch {}
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
window.addEventListener('message', onMessage);
|
|
1753
|
+
return () => window.removeEventListener('message', onMessage);
|
|
1754
|
+
}, [commentsByFile, focusedCommentId, startDraftFromSelection, startDraftFor]);
|
|
1755
|
+
|
|
1756
|
+
// Tell the active canvas iframe to drop any persistent selection (canvas
|
|
1757
|
+
// SelectionSet) — used when the comment composer closes via submit /
|
|
1758
|
+
// cancel / Esc. canvas-shell listens for `force-clear` on the window
|
|
1759
|
+
// message channel and calls selSet.clear().
|
|
1760
|
+
const clearActiveCanvasSelection = useCallback(() => {
|
|
1761
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
1762
|
+
const el = iframesRef.current.get(activePath);
|
|
1763
|
+
if (el && el.contentWindow) {
|
|
1764
|
+
try { el.contentWindow.postMessage({ dgn: 'force-clear' }, '*'); } catch {}
|
|
1765
|
+
}
|
|
1766
|
+
}, [activePath]);
|
|
1767
|
+
|
|
1768
|
+
const submitDraft = useCallback(() => {
|
|
1769
|
+
if (!draft || !draft.text.trim()) return;
|
|
1770
|
+
wsSend({ type: 'comments-add', payload: {
|
|
1771
|
+
file: draft.file,
|
|
1772
|
+
selector: draft.selector,
|
|
1773
|
+
dom_path: draft.dom_path,
|
|
1774
|
+
tag: draft.tag,
|
|
1775
|
+
classes: draft.classes,
|
|
1776
|
+
bounds: draft.bounds,
|
|
1777
|
+
html_excerpt: draft.html,
|
|
1778
|
+
text: draft.text.trim(),
|
|
1779
|
+
}});
|
|
1780
|
+
setDraft(null);
|
|
1781
|
+
clearActiveCanvasSelection();
|
|
1782
|
+
}, [draft, clearActiveCanvasSelection]);
|
|
1783
|
+
|
|
1784
|
+
const cancelDraft = useCallback(() => {
|
|
1785
|
+
setDraft(null);
|
|
1786
|
+
clearActiveCanvasSelection();
|
|
1787
|
+
}, [clearActiveCanvasSelection]);
|
|
1788
|
+
|
|
1789
|
+
const resolveComment = useCallback((id) => {
|
|
1790
|
+
wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
|
|
1791
|
+
}, []);
|
|
1792
|
+
const reopenComment = useCallback((id) => {
|
|
1793
|
+
wsSend({ type: 'comments-patch', id, patch: { status: 'open' } });
|
|
1794
|
+
}, []);
|
|
1795
|
+
const deleteComment = useCallback((id) => {
|
|
1796
|
+
wsSend({ type: 'comments-delete', id });
|
|
1797
|
+
setFocusedCommentId(prev => (prev === id ? null : prev));
|
|
1798
|
+
}, []);
|
|
1799
|
+
|
|
1800
|
+
const focusPinFromBar = useCallback((id) => {
|
|
1801
|
+
setFocusedCommentId(id);
|
|
1802
|
+
if (activePath && activePath !== SYSTEM_TAB) {
|
|
1803
|
+
const el = iframesRef.current.get(activePath);
|
|
1804
|
+
if (el && el.contentWindow) {
|
|
1805
|
+
try { el.contentWindow.postMessage({ dgn: 'comment-focus', id }, '*'); } catch {}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}, [activePath]);
|
|
1809
|
+
|
|
1810
|
+
// Jump from right-sidebar list to a comment: open file tab if needed, focus pin.
|
|
1811
|
+
// The iframe may be freshly mounted; the loaded handler also re-sends focus if focusedCommentId matches.
|
|
1812
|
+
const jumpToComment = useCallback((file, id) => {
|
|
1813
|
+
if (file && file !== activePath) {
|
|
1814
|
+
setTabs(prev => prev.find(t => t.path === file) ? prev : [...prev, { path: file }]);
|
|
1815
|
+
setActivePath(file);
|
|
1816
|
+
}
|
|
1817
|
+
if (id == null) {
|
|
1818
|
+
setFocusedCommentId(null);
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
setFocusedCommentId(id);
|
|
1822
|
+
// Try sending focus immediately (existing iframe) and again after a short delay (newly opened tab).
|
|
1823
|
+
const send = () => {
|
|
1824
|
+
const el = iframesRef.current.get(file);
|
|
1825
|
+
if (el && el.contentWindow) {
|
|
1826
|
+
try { el.contentWindow.postMessage({ dgn: 'comment-focus', id }, '*'); } catch {}
|
|
1827
|
+
}
|
|
1828
|
+
};
|
|
1829
|
+
send();
|
|
1830
|
+
setTimeout(send, 200);
|
|
1831
|
+
}, [activePath]);
|
|
1832
|
+
|
|
1833
|
+
// ----- Keyboard shortcuts (no Cmd+W — let browser close the tab) -----
|
|
1834
|
+
useEffect(() => {
|
|
1835
|
+
function onKey(e) {
|
|
1836
|
+
const meta = e.metaKey || e.ctrlKey;
|
|
1837
|
+
const inEditable = ['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName) || document.activeElement?.isContentEditable;
|
|
1838
|
+
// Phase 4.1: shell-side letter shortcuts (H/T/S) must not double-fire
|
|
1839
|
+
// inside a focused canvas iframe — the canvas input router owns those
|
|
1840
|
+
// letters as tool-mode keys (V/H/C). Cmd-modified shortcuts (⌘R, ⌘⇧M,
|
|
1841
|
+
// ⌘F) still fire regardless of focus, mirroring browser convention.
|
|
1842
|
+
const inCanvasIframe = document.activeElement?.tagName === 'IFRAME';
|
|
1843
|
+
|
|
1844
|
+
// Cmd+R — reload active iframe (override browser reload)
|
|
1845
|
+
if (meta && (e.key === 'r' || e.key === 'R')) {
|
|
1846
|
+
e.preventDefault();
|
|
1847
|
+
reloadActive();
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
// Cmd+Shift+M / Ctrl+Shift+M — toggle right "Comments" panel
|
|
1851
|
+
if (meta && e.shiftKey && (e.key === 'm' || e.key === 'M')) {
|
|
1852
|
+
e.preventDefault();
|
|
1853
|
+
setCommentsPanelOpen(v => !v);
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
// Cmd+C / Ctrl+C — Phase 4.1 removed the shell-side comment-drop chord.
|
|
1857
|
+
// Canvas comment-drop is the `C` tool letter (press C in the canvas,
|
|
1858
|
+
// then click the element) or right-click "Add comment". Cmd+C now
|
|
1859
|
+
// reverts to native browser copy.
|
|
1860
|
+
if (meta && !e.shiftKey && !e.altKey && (e.key === 'c' || e.key === 'C')) {
|
|
1861
|
+
if (selected && selected.selector && activePath && activePath !== SYSTEM_TAB && !inEditable && console && console.warn) {
|
|
1862
|
+
console.warn('Cmd+C comment-drop deprecated — press C inside the canvas to enter Comment tool, then click the element.');
|
|
1863
|
+
}
|
|
1864
|
+
// Fall through to native copy.
|
|
1865
|
+
}
|
|
1866
|
+
if (inEditable) return;
|
|
1867
|
+
// / — focus search (or ⌘F per CV-08 placeholder hint)
|
|
1868
|
+
if (e.key === '/') {
|
|
1869
|
+
e.preventDefault();
|
|
1870
|
+
const inp = document.querySelector('.tree-panel-search input');
|
|
1871
|
+
if (inp) inp.focus();
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
if (meta && (e.key === 'f' || e.key === 'F')) {
|
|
1875
|
+
e.preventDefault();
|
|
1876
|
+
if (!sidebarOpen) setSidebarOpen(true);
|
|
1877
|
+
setTimeout(() => {
|
|
1878
|
+
const inp = document.querySelector('.tree-panel-search input');
|
|
1879
|
+
if (inp) inp.focus();
|
|
1880
|
+
}, 0);
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
// T / H / S are bare-letter shell shortcuts. When focus is inside a
|
|
1884
|
+
// canvas iframe, the canvas input router claims V/H/C — bail out
|
|
1885
|
+
// here so the canvas owns the key and the sidebar/system view don't
|
|
1886
|
+
// double-fire on focused-canvas keypresses.
|
|
1887
|
+
if (inCanvasIframe) {
|
|
1888
|
+
// Esc still bubbles below (composer / focused-pin clear).
|
|
1889
|
+
if (e.key !== 'Escape') return;
|
|
1890
|
+
}
|
|
1891
|
+
// T — toggle Project Tree (sidebar)
|
|
1892
|
+
if (e.key === 't' || e.key === 'T') {
|
|
1893
|
+
if (e.shiftKey || meta) return;
|
|
1894
|
+
e.preventDefault();
|
|
1895
|
+
setSidebarOpen(v => !v);
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
// H — toggle show-hidden (sidecars + project/runtime orphans)
|
|
1899
|
+
if (e.key === 'h' || e.key === 'H') {
|
|
1900
|
+
if (e.shiftKey || meta) return;
|
|
1901
|
+
e.preventDefault();
|
|
1902
|
+
setShowHidden(v => !v);
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
// S — toggle Design system view
|
|
1906
|
+
if ((e.key === 's' || e.key === 'S') && !meta && !e.shiftKey) {
|
|
1907
|
+
e.preventDefault();
|
|
1908
|
+
if (activePath === SYSTEM_TAB) {
|
|
1909
|
+
closeTab(SYSTEM_TAB);
|
|
1910
|
+
} else {
|
|
1911
|
+
openSystem();
|
|
1912
|
+
}
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
// ? or F1 — open Help modal
|
|
1916
|
+
if (e.key === '?' || e.key === 'F1') {
|
|
1917
|
+
e.preventDefault();
|
|
1918
|
+
setHelpOpen(true);
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
// Esc — close composer (in addition to its own textarea handler) or clear focused pin
|
|
1922
|
+
if (e.key === 'Escape') {
|
|
1923
|
+
if (draft) { setDraft(null); clearActiveCanvasSelection(); return; }
|
|
1924
|
+
if (focusedCommentId) { setFocusedCommentId(null); return; }
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
window.addEventListener('keydown', onKey);
|
|
1928
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
1929
|
+
}, [reloadActive, selected, activePath, startDraftFromSelection, draft, focusedCommentId, sidebarOpen, openSystem, closeTab, clearActiveCanvasSelection]);
|
|
1930
|
+
|
|
1931
|
+
const registerIframe = useCallback((path, el) => {
|
|
1932
|
+
if (el) iframesRef.current.set(path, el);
|
|
1933
|
+
}, []);
|
|
1934
|
+
|
|
1935
|
+
const activeFileComments = (activePath && activePath !== SYSTEM_TAB) ? (commentsByFile[activePath] || []) : [];
|
|
1936
|
+
const totalOpen = totalCounts(commentsByFile).open;
|
|
1937
|
+
|
|
1938
|
+
return (
|
|
1939
|
+
<div className={'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')}>
|
|
1940
|
+
<Sidebar
|
|
1941
|
+
groups={groups}
|
|
1942
|
+
activePath={activePath}
|
|
1943
|
+
onOpen={openTab}
|
|
1944
|
+
onOpenSystem={openSystem}
|
|
1945
|
+
wsConnected={wsConnected}
|
|
1946
|
+
search={search}
|
|
1947
|
+
setSearch={setSearch}
|
|
1948
|
+
commentsByFile={commentsByFile}
|
|
1949
|
+
showHidden={showHidden}
|
|
1950
|
+
sectionsExpanded={sectionsExpanded}
|
|
1951
|
+
onToggleSection={toggleSection}
|
|
1952
|
+
/>
|
|
1953
|
+
<div className="main">
|
|
1954
|
+
<Menubar
|
|
1955
|
+
activePath={activePath}
|
|
1956
|
+
project={project}
|
|
1957
|
+
tabsCount={tabs.length}
|
|
1958
|
+
openMenu={openMenu}
|
|
1959
|
+
setOpenMenu={setOpenMenu}
|
|
1960
|
+
commentsPanelOpen={commentsPanelOpen}
|
|
1961
|
+
onToggleComments={() => setCommentsPanelOpen(v => !v)}
|
|
1962
|
+
onOpenSystem={openSystem}
|
|
1963
|
+
sidebarOpen={sidebarOpen}
|
|
1964
|
+
onToggleSidebar={() => setSidebarOpen(v => !v)}
|
|
1965
|
+
showHidden={showHidden}
|
|
1966
|
+
onToggleShowHidden={() => setShowHidden(v => !v)}
|
|
1967
|
+
onOpenHelp={() => setHelpOpen(true)}
|
|
1968
|
+
annotationsVisible={annotationsVisible}
|
|
1969
|
+
onToggleAnnotations={toggleAnnotations}
|
|
1970
|
+
postToActiveCanvas={postToActiveCanvas}
|
|
1971
|
+
/>
|
|
1972
|
+
<Viewport
|
|
1973
|
+
tabs={tabs}
|
|
1974
|
+
activePath={activePath}
|
|
1975
|
+
registerIframe={registerIframe}
|
|
1976
|
+
systemData={systemData}
|
|
1977
|
+
onOpenFromSystem={openTab}
|
|
1978
|
+
project={project}
|
|
1979
|
+
cfg={cfg}
|
|
1980
|
+
/>
|
|
1981
|
+
{activePath && activePath !== SYSTEM_TAB && (
|
|
1982
|
+
<CommentBar
|
|
1983
|
+
activePath={activePath}
|
|
1984
|
+
selected={selected}
|
|
1985
|
+
comments={activeFileComments}
|
|
1986
|
+
focusedId={focusedCommentId}
|
|
1987
|
+
draft={draft && draft.file === activePath ? draft : null}
|
|
1988
|
+
setDraft={setDraft}
|
|
1989
|
+
onSubmit={submitDraft}
|
|
1990
|
+
onCancel={cancelDraft}
|
|
1991
|
+
onResolve={resolveComment}
|
|
1992
|
+
onReopen={reopenComment}
|
|
1993
|
+
onDelete={deleteComment}
|
|
1994
|
+
onFocusPin={focusPinFromBar}
|
|
1995
|
+
/>
|
|
1996
|
+
)}
|
|
1997
|
+
<StatusBar
|
|
1998
|
+
activePath={activePath}
|
|
1999
|
+
selected={selected}
|
|
2000
|
+
wsConnected={wsConnected}
|
|
2001
|
+
openCount={totalOpen}
|
|
2002
|
+
theme={theme}
|
|
2003
|
+
onToggleTheme={toggleTheme}
|
|
2004
|
+
onClearSelected={clearSelected}
|
|
2005
|
+
onAddComment={startDraftFromSelection}
|
|
2006
|
+
hasDraft={!!(draft && draft.file === activePath)}
|
|
2007
|
+
/>
|
|
2008
|
+
</div>
|
|
2009
|
+
{commentsPanelOpen && (
|
|
2010
|
+
<CommentsPanel
|
|
2011
|
+
commentsByFile={commentsByFile}
|
|
2012
|
+
filter={commentsFilter}
|
|
2013
|
+
setFilter={setCommentsFilter}
|
|
2014
|
+
activePath={activePath}
|
|
2015
|
+
focusedId={focusedCommentId}
|
|
2016
|
+
onJump={jumpToComment}
|
|
2017
|
+
onResolve={resolveComment}
|
|
2018
|
+
onReopen={reopenComment}
|
|
2019
|
+
onDelete={deleteComment}
|
|
2020
|
+
/>
|
|
2021
|
+
)}
|
|
2022
|
+
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
|
|
2023
|
+
</div>
|
|
2024
|
+
);
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
createRoot(document.getElementById('root')).render(<App />);
|