@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,1246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Design plugin — local browser. Zero deps, just node:http + node:crypto for WS handshake.
|
|
3
|
+
// Serves the design content under <designRoot> behind a tabbed UI with active-canvas tracking
|
|
4
|
+
// and Cmd+click element selection injected into served HTML files.
|
|
5
|
+
//
|
|
6
|
+
// Per-repo configuration: <repo>/.design/config.json (see ./config.schema.json).
|
|
7
|
+
//
|
|
8
|
+
// On boot, writes <designRoot>/_server.json (port + pid + url) so the orchestrator
|
|
9
|
+
// can detect a running instance instead of accidentally starting a second one.
|
|
10
|
+
// Tabs in the UI push their active state over WebSocket; server persists to
|
|
11
|
+
// <designRoot>/_active.json so /design:edit "<feedback>" knows which canvas to edit.
|
|
12
|
+
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import fs from 'node:fs/promises';
|
|
15
|
+
import fsSync from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import net from 'node:net';
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import { exec } from 'node:child_process';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
23
|
+
const __dirname = path.dirname(__filename);
|
|
24
|
+
|
|
25
|
+
// Resolve the user's project root (where .design/ lives), NOT the plugin install dir.
|
|
26
|
+
// Priority: explicit --root arg > $CLAUDE_PROJECT_DIR (hooks) > process.cwd() (Bash tool / pnpm).
|
|
27
|
+
// Never uses __dirname — the plugin can be installed centrally and serve any repo.
|
|
28
|
+
function resolveRepoRoot() {
|
|
29
|
+
const i = process.argv.indexOf('--root');
|
|
30
|
+
if (i !== -1 && process.argv[i + 1]) return path.resolve(process.argv[i + 1]);
|
|
31
|
+
if (process.env.CLAUDE_PROJECT_DIR) return path.resolve(process.env.CLAUDE_PROJECT_DIR);
|
|
32
|
+
return process.cwd();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const REPO_ROOT = resolveRepoRoot();
|
|
36
|
+
|
|
37
|
+
// Fail loud if launched from a directory that has no .design/ — otherwise the server
|
|
38
|
+
// would silently fall back to defaults and serve nothing useful, masking the real
|
|
39
|
+
// problem (user ran `node server.mjs` from $HOME, or CLAUDE_PROJECT_DIR is wrong).
|
|
40
|
+
if (!fsSync.existsSync(path.join(REPO_ROOT, '.design'))) {
|
|
41
|
+
console.error(` error: no .design/ directory at ${REPO_ROOT}`);
|
|
42
|
+
console.error(` Run from your project root, set $CLAUDE_PROJECT_DIR, or pass --root <path>.`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------- Config ----------
|
|
47
|
+
|
|
48
|
+
const CONFIG_PATH = path.join(REPO_ROOT, '.design', 'config.json');
|
|
49
|
+
|
|
50
|
+
const DEFAULT_CONFIG = {
|
|
51
|
+
name: 'Design',
|
|
52
|
+
projectLabel: null,
|
|
53
|
+
designRoot: '.design',
|
|
54
|
+
canvasGroups: [
|
|
55
|
+
{ label: 'Design system', path: 'system' },
|
|
56
|
+
{ label: 'Canvases', path: 'ui' },
|
|
57
|
+
],
|
|
58
|
+
rootClass: 'app',
|
|
59
|
+
themeDefault: 'dark',
|
|
60
|
+
tokensCssRel: 'system/colors_and_type.css',
|
|
61
|
+
teamAccentDefault: null,
|
|
62
|
+
handoffTargets: [],
|
|
63
|
+
newCanvasDir: 'ui',
|
|
64
|
+
newComponentDir: 'ui/components',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function loadConfig() {
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = fsSync.readFileSync(CONFIG_PATH, 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
return { ...DEFAULT_CONFIG, _source: 'defaults' };
|
|
73
|
+
}
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(raw);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error(` warn: ${CONFIG_PATH} is not valid JSON: ${e.message}. Using defaults.`);
|
|
79
|
+
return { ...DEFAULT_CONFIG, _source: 'defaults (config invalid)' };
|
|
80
|
+
}
|
|
81
|
+
return { ...DEFAULT_CONFIG, ...parsed, _source: '.design/config.json' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const CFG = loadConfig();
|
|
85
|
+
const PROJECT_LABEL = CFG.projectLabel || `${CFG.name} Design`;
|
|
86
|
+
const DESIGN_REL = CFG.designRoot.replace(/^\/+|\/+$/g, '');
|
|
87
|
+
const DESIGN_ROOT = path.join(REPO_ROOT, DESIGN_REL);
|
|
88
|
+
const SERVER_INFO_FILE = path.join(DESIGN_ROOT, '_server.json');
|
|
89
|
+
const ACTIVE_FILE = path.join(DESIGN_ROOT, '_active.json');
|
|
90
|
+
const COMMENTS_DIR = path.join(DESIGN_ROOT, '_comments');
|
|
91
|
+
const CANVAS_STATE_DIR = path.join(DESIGN_ROOT, '_canvas-state');
|
|
92
|
+
const TOKENS_URL_REL = path.posix.join(DESIGN_REL, CFG.tokensCssRel.replace(/^\/+/, ''));
|
|
93
|
+
|
|
94
|
+
// ---------- MIME / FS ----------
|
|
95
|
+
|
|
96
|
+
const MIME = {
|
|
97
|
+
'.html': 'text/html; charset=utf-8',
|
|
98
|
+
'.css': 'text/css; charset=utf-8',
|
|
99
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
100
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
101
|
+
'.jsx': 'text/plain; charset=utf-8',
|
|
102
|
+
'.ts': 'text/plain; charset=utf-8',
|
|
103
|
+
'.tsx': 'text/plain; charset=utf-8',
|
|
104
|
+
'.json': 'application/json; charset=utf-8',
|
|
105
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
106
|
+
'.svg': 'image/svg+xml',
|
|
107
|
+
'.png': 'image/png',
|
|
108
|
+
'.jpg': 'image/jpeg',
|
|
109
|
+
'.jpeg': 'image/jpeg',
|
|
110
|
+
'.gif': 'image/gif',
|
|
111
|
+
'.webp': 'image/webp',
|
|
112
|
+
'.ico': 'image/x-icon',
|
|
113
|
+
'.woff': 'font/woff',
|
|
114
|
+
'.woff2':'font/woff2',
|
|
115
|
+
'.ttf': 'font/ttf',
|
|
116
|
+
'.otf': 'font/otf',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.next', '.turbo', 'dist', 'build', '.expo', 'coverage', 'dev-server', '_history']);
|
|
120
|
+
const HIDDEN_OK = new Set(['.ai', '.claude', '.design']);
|
|
121
|
+
|
|
122
|
+
async function findHtmlFiles(absRoot, prefixUnderRepo) {
|
|
123
|
+
const out = [];
|
|
124
|
+
let entries;
|
|
125
|
+
try {
|
|
126
|
+
entries = await fs.readdir(absRoot, { withFileTypes: true });
|
|
127
|
+
} catch {
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
131
|
+
for (const e of entries) {
|
|
132
|
+
if (e.name.startsWith('.') && !HIDDEN_OK.has(e.name) && !e.name.startsWith('_')) continue;
|
|
133
|
+
if (e.name.startsWith('_')) continue;
|
|
134
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
135
|
+
const full = path.join(absRoot, e.name);
|
|
136
|
+
const rel = path.posix.join(prefixUnderRepo, e.name);
|
|
137
|
+
if (e.isDirectory()) {
|
|
138
|
+
out.push(...await findHtmlFiles(full, rel));
|
|
139
|
+
} else if (e.name.toLowerCase().endsWith('.html')) {
|
|
140
|
+
out.push(rel);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function safePath(reqUrl) {
|
|
147
|
+
let pathname;
|
|
148
|
+
try {
|
|
149
|
+
pathname = decodeURIComponent(new URL(reqUrl, 'http://x').pathname);
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const resolved = path.normalize(path.join(REPO_ROOT, pathname));
|
|
154
|
+
if (resolved !== REPO_ROOT && !resolved.startsWith(REPO_ROOT + path.sep)) return null;
|
|
155
|
+
return resolved;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function findFreePort(start = 4321, tries = 100) {
|
|
159
|
+
for (let p = start; p < start + tries; p++) {
|
|
160
|
+
const ok = await new Promise(resolve => {
|
|
161
|
+
const srv = net.createServer();
|
|
162
|
+
srv.unref();
|
|
163
|
+
srv.once('error', () => resolve(false));
|
|
164
|
+
srv.listen(p, '127.0.0.1', () => srv.close(() => resolve(true)));
|
|
165
|
+
});
|
|
166
|
+
if (ok) return p;
|
|
167
|
+
}
|
|
168
|
+
throw new Error(`no free port in [${start}..${start + tries})`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function encodeUrlPath(p) {
|
|
172
|
+
return '/' + p.split('/').map(encodeURIComponent).join('/');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------- active state ----------
|
|
176
|
+
|
|
177
|
+
let activeState = {
|
|
178
|
+
active: null,
|
|
179
|
+
open_tabs: [],
|
|
180
|
+
selected: null,
|
|
181
|
+
last_change: null,
|
|
182
|
+
session_started: new Date().toISOString(),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
async function loadActive() {
|
|
186
|
+
try {
|
|
187
|
+
const raw = await fs.readFile(ACTIVE_FILE, 'utf8');
|
|
188
|
+
const prev = JSON.parse(raw);
|
|
189
|
+
activeState = { ...activeState, ...prev, session_started: new Date().toISOString() };
|
|
190
|
+
} catch { /* first boot */ }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function saveActive() {
|
|
194
|
+
try {
|
|
195
|
+
await fs.mkdir(path.dirname(ACTIVE_FILE), { recursive: true });
|
|
196
|
+
// Mirror the active file's comments inline so /design has a single read.
|
|
197
|
+
// Authoritative storage stays in _comments/<slug>.json.
|
|
198
|
+
let activeComments = [];
|
|
199
|
+
if (activeState.active) {
|
|
200
|
+
try { activeComments = await loadCommentsForFile(activeState.active); } catch {}
|
|
201
|
+
}
|
|
202
|
+
const enriched = { ...activeState, active_comments: activeComments };
|
|
203
|
+
await fs.writeFile(ACTIVE_FILE, JSON.stringify(enriched, null, 2));
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.error(' warn: failed to save _active.json:', e.message);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function setActive(file) {
|
|
210
|
+
if (typeof file !== 'string') return;
|
|
211
|
+
if (activeState.active === file) return;
|
|
212
|
+
activeState.active = file || null;
|
|
213
|
+
activeState.selected = null;
|
|
214
|
+
activeState.last_change = new Date().toISOString();
|
|
215
|
+
saveActive();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function setOpenTabs(tabs) {
|
|
219
|
+
if (!Array.isArray(tabs)) return;
|
|
220
|
+
activeState.open_tabs = tabs.filter(t => typeof t === 'string');
|
|
221
|
+
activeState.last_change = new Date().toISOString();
|
|
222
|
+
saveActive();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function setSelected(sel) {
|
|
226
|
+
if (sel && typeof sel === 'object') {
|
|
227
|
+
activeState.selected = {
|
|
228
|
+
file: typeof sel.file === 'string' ? sel.file : activeState.active,
|
|
229
|
+
selector: String(sel.selector || ''),
|
|
230
|
+
tag: String(sel.tag || ''),
|
|
231
|
+
classes: String(sel.classes || ''),
|
|
232
|
+
text: String(sel.text || '').slice(0, 240),
|
|
233
|
+
dom_path: Array.isArray(sel.dom_path) ? sel.dom_path.slice(0, 16) : [],
|
|
234
|
+
bounds: sel.bounds || null,
|
|
235
|
+
html: String(sel.html || '').slice(0, 4000),
|
|
236
|
+
ts: new Date().toISOString(),
|
|
237
|
+
};
|
|
238
|
+
} else {
|
|
239
|
+
activeState.selected = null;
|
|
240
|
+
}
|
|
241
|
+
activeState.last_change = new Date().toISOString();
|
|
242
|
+
saveActive();
|
|
243
|
+
broadcast({ type: 'selected', selected: activeState.selected });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function broadcast(obj) {
|
|
247
|
+
const msg = JSON.stringify(obj);
|
|
248
|
+
for (const s of wsClients) wsSendText(s, msg);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------- Comments ----------
|
|
252
|
+
|
|
253
|
+
function fileSlug(file) {
|
|
254
|
+
let p = String(file).replace(/^\/+|\/+$/g, '');
|
|
255
|
+
// Decode URL-encoded paths (e.g. `Dugmate%20Studio.html` from location.pathname)
|
|
256
|
+
// so the slug matches whether the caller sends raw or encoded.
|
|
257
|
+
try { p = decodeURIComponent(p); } catch {}
|
|
258
|
+
// Strip designRoot prefix if present so the slug stays stable regardless of how
|
|
259
|
+
// callers spelled the path (some send "<designRoot>/ui/...", others send "ui/...").
|
|
260
|
+
const prefix = DESIGN_REL.replace(/^\/+|\/+$/g, '') + '/';
|
|
261
|
+
if (p.startsWith(prefix)) p = p.slice(prefix.length);
|
|
262
|
+
return p
|
|
263
|
+
.replace(/\//g, '-')
|
|
264
|
+
.replace(/\s+/g, '_')
|
|
265
|
+
.replace(/\.html$/i, '')
|
|
266
|
+
.replace(/^\.+/, '') // never produce a hidden file
|
|
267
|
+
.toLowerCase();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function commentsPath(file) {
|
|
271
|
+
return path.join(COMMENTS_DIR, `${fileSlug(file)}.json`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function loadCommentsForFile(file) {
|
|
275
|
+
try {
|
|
276
|
+
const raw = await fs.readFile(commentsPath(file), 'utf8');
|
|
277
|
+
const arr = JSON.parse(raw);
|
|
278
|
+
return Array.isArray(arr) ? arr : [];
|
|
279
|
+
} catch {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function saveCommentsForFile(file, list) {
|
|
285
|
+
await fs.mkdir(COMMENTS_DIR, { recursive: true });
|
|
286
|
+
await fs.writeFile(commentsPath(file), JSON.stringify(list, null, 2));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function loadAllComments() {
|
|
290
|
+
const out = {};
|
|
291
|
+
let entries;
|
|
292
|
+
try {
|
|
293
|
+
entries = await fs.readdir(COMMENTS_DIR, { withFileTypes: true });
|
|
294
|
+
} catch {
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
for (const e of entries) {
|
|
298
|
+
if (!e.isFile() || !e.name.endsWith('.json')) continue;
|
|
299
|
+
try {
|
|
300
|
+
const raw = await fs.readFile(path.join(COMMENTS_DIR, e.name), 'utf8');
|
|
301
|
+
const arr = JSON.parse(raw);
|
|
302
|
+
if (!Array.isArray(arr) || arr.length === 0) continue;
|
|
303
|
+
const file = arr[0]?.file;
|
|
304
|
+
if (file) out[file] = arr;
|
|
305
|
+
} catch {}
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function newCommentId() {
|
|
311
|
+
return 'c_' + crypto.randomBytes(6).toString('hex');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function broadcastComments(file) {
|
|
315
|
+
const comments = await loadCommentsForFile(file);
|
|
316
|
+
broadcast({ type: 'comments', file, comments });
|
|
317
|
+
// Re-mirror into _active.json if this file is the active one
|
|
318
|
+
if (activeState.active === file) saveActive();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function commentsAdd(payload) {
|
|
322
|
+
if (!payload || typeof payload.file !== 'string' || !payload.file) return null;
|
|
323
|
+
if (typeof payload.text !== 'string' || !payload.text.trim()) return null;
|
|
324
|
+
const list = await loadCommentsForFile(payload.file);
|
|
325
|
+
const c = {
|
|
326
|
+
id: newCommentId(),
|
|
327
|
+
file: payload.file,
|
|
328
|
+
selector: String(payload.selector || ''),
|
|
329
|
+
dom_path: Array.isArray(payload.dom_path) ? payload.dom_path.slice(0, 16) : [],
|
|
330
|
+
tag: String(payload.tag || ''),
|
|
331
|
+
classes: String(payload.classes || ''),
|
|
332
|
+
bounds: payload.bounds || null,
|
|
333
|
+
html_excerpt: String(payload.html_excerpt || payload.html || '').slice(0, 2000),
|
|
334
|
+
text: String(payload.text).trim().slice(0, 4000),
|
|
335
|
+
status: 'open',
|
|
336
|
+
created: new Date().toISOString(),
|
|
337
|
+
resolved_at: null,
|
|
338
|
+
};
|
|
339
|
+
list.push(c);
|
|
340
|
+
await saveCommentsForFile(payload.file, list);
|
|
341
|
+
await broadcastComments(payload.file);
|
|
342
|
+
return c;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function commentsPatch(id, patch) {
|
|
346
|
+
const all = await loadAllComments();
|
|
347
|
+
for (const [file, list] of Object.entries(all)) {
|
|
348
|
+
const i = list.findIndex(c => c.id === id);
|
|
349
|
+
if (i < 0) continue;
|
|
350
|
+
if (patch.status === 'resolved' || patch.status === 'open') {
|
|
351
|
+
list[i].status = patch.status;
|
|
352
|
+
list[i].resolved_at = patch.status === 'resolved' ? new Date().toISOString() : null;
|
|
353
|
+
}
|
|
354
|
+
if (typeof patch.text === 'string' && patch.text.trim()) {
|
|
355
|
+
list[i].text = patch.text.trim().slice(0, 4000);
|
|
356
|
+
}
|
|
357
|
+
await saveCommentsForFile(file, list);
|
|
358
|
+
await broadcastComments(file);
|
|
359
|
+
return list[i];
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function commentsDelete(id) {
|
|
365
|
+
const all = await loadAllComments();
|
|
366
|
+
for (const [file, list] of Object.entries(all)) {
|
|
367
|
+
const i = list.findIndex(c => c.id === id);
|
|
368
|
+
if (i < 0) continue;
|
|
369
|
+
list.splice(i, 1);
|
|
370
|
+
await saveCommentsForFile(file, list);
|
|
371
|
+
await broadcastComments(file);
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------- Canvas state (pan/zoom + section ordering, persisted per-canvas) ----------
|
|
378
|
+
|
|
379
|
+
function canvasStatePath(file) {
|
|
380
|
+
return path.join(CANVAS_STATE_DIR, `${fileSlug(file)}.json`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function loadCanvasState(file) {
|
|
384
|
+
try {
|
|
385
|
+
const raw = await fs.readFile(canvasStatePath(file), 'utf8');
|
|
386
|
+
const obj = JSON.parse(raw);
|
|
387
|
+
return (obj && typeof obj === 'object') ? obj : null;
|
|
388
|
+
} catch {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function saveCanvasState(file, state) {
|
|
394
|
+
if (!state || typeof state !== 'object') return;
|
|
395
|
+
await fs.mkdir(CANVAS_STATE_DIR, { recursive: true });
|
|
396
|
+
// Whitelist top-level keys we accept (don't trust arbitrary input)
|
|
397
|
+
const safe = {};
|
|
398
|
+
if (state.sections && typeof state.sections === 'object') safe.sections = state.sections;
|
|
399
|
+
if (state.viewport && typeof state.viewport === 'object') {
|
|
400
|
+
const v = state.viewport;
|
|
401
|
+
safe.viewport = {
|
|
402
|
+
x: Number.isFinite(v.x) ? v.x : 0,
|
|
403
|
+
y: Number.isFinite(v.y) ? v.y : 0,
|
|
404
|
+
scale: Number.isFinite(v.scale) ? Math.min(8, Math.max(0.05, v.scale)) : 1,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
await fs.writeFile(canvasStatePath(file), JSON.stringify(safe, null, 2));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function readJsonBody(req, max = 256 * 1024) {
|
|
411
|
+
return new Promise((resolve, reject) => {
|
|
412
|
+
let buf = '';
|
|
413
|
+
let too = false;
|
|
414
|
+
req.on('data', c => {
|
|
415
|
+
if (too) return;
|
|
416
|
+
buf += c;
|
|
417
|
+
if (buf.length > max) { too = true; reject(new Error('body too large')); req.destroy(); }
|
|
418
|
+
});
|
|
419
|
+
req.on('end', () => {
|
|
420
|
+
if (too) return;
|
|
421
|
+
try { resolve(buf ? JSON.parse(buf) : null); } catch (e) { reject(e); }
|
|
422
|
+
});
|
|
423
|
+
req.on('error', reject);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------- server info ----------
|
|
428
|
+
|
|
429
|
+
async function writeServerInfo(port) {
|
|
430
|
+
await fs.mkdir(DESIGN_ROOT, { recursive: true });
|
|
431
|
+
const info = {
|
|
432
|
+
pid: process.pid,
|
|
433
|
+
port,
|
|
434
|
+
url: `http://localhost:${port}`,
|
|
435
|
+
started: new Date().toISOString(),
|
|
436
|
+
project: CFG.name,
|
|
437
|
+
config_source: CFG._source,
|
|
438
|
+
};
|
|
439
|
+
await fs.writeFile(SERVER_INFO_FILE, JSON.stringify(info, null, 2));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function removeServerInfo() {
|
|
443
|
+
try { fsSync.unlinkSync(SERVER_INFO_FILE); } catch {}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ---------- WebSocket (RFC 6455 server, minimal) ----------
|
|
447
|
+
|
|
448
|
+
const wsClients = new Set();
|
|
449
|
+
|
|
450
|
+
function wsAccept(key) {
|
|
451
|
+
return crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function wsHandshake(req, socket) {
|
|
455
|
+
const key = req.headers['sec-websocket-key'];
|
|
456
|
+
if (!key) { socket.destroy(); return false; }
|
|
457
|
+
const accept = wsAccept(key);
|
|
458
|
+
socket.write(
|
|
459
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
460
|
+
'Upgrade: websocket\r\n' +
|
|
461
|
+
'Connection: Upgrade\r\n' +
|
|
462
|
+
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`
|
|
463
|
+
);
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function wsSendText(socket, text) {
|
|
468
|
+
const data = Buffer.from(text, 'utf8');
|
|
469
|
+
const len = data.length;
|
|
470
|
+
let header;
|
|
471
|
+
if (len < 126) {
|
|
472
|
+
header = Buffer.from([0x81, len]);
|
|
473
|
+
} else if (len < 65536) {
|
|
474
|
+
header = Buffer.alloc(4);
|
|
475
|
+
header[0] = 0x81;
|
|
476
|
+
header[1] = 126;
|
|
477
|
+
header.writeUInt16BE(len, 2);
|
|
478
|
+
} else {
|
|
479
|
+
header = Buffer.alloc(10);
|
|
480
|
+
header[0] = 0x81;
|
|
481
|
+
header[1] = 127;
|
|
482
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
483
|
+
}
|
|
484
|
+
try { socket.write(Buffer.concat([header, data])); } catch {}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function wsParseFrames(buf, onText, onClose) {
|
|
488
|
+
let off = 0;
|
|
489
|
+
while (off + 2 <= buf.length) {
|
|
490
|
+
const b1 = buf[off];
|
|
491
|
+
const b2 = buf[off + 1];
|
|
492
|
+
const opcode = b1 & 0x0f;
|
|
493
|
+
const masked = (b2 & 0x80) === 0x80;
|
|
494
|
+
let len = b2 & 0x7f;
|
|
495
|
+
let p = off + 2;
|
|
496
|
+
if (len === 126) {
|
|
497
|
+
if (p + 2 > buf.length) return buf.slice(off);
|
|
498
|
+
len = buf.readUInt16BE(p); p += 2;
|
|
499
|
+
} else if (len === 127) {
|
|
500
|
+
if (p + 8 > buf.length) return buf.slice(off);
|
|
501
|
+
len = Number(buf.readBigUInt64BE(p)); p += 8;
|
|
502
|
+
}
|
|
503
|
+
if (masked) {
|
|
504
|
+
if (p + 4 > buf.length) return buf.slice(off);
|
|
505
|
+
}
|
|
506
|
+
const maskKey = masked ? buf.slice(p, p + 4) : null;
|
|
507
|
+
if (masked) p += 4;
|
|
508
|
+
if (p + len > buf.length) return buf.slice(off);
|
|
509
|
+
const payload = buf.slice(p, p + len);
|
|
510
|
+
if (masked) {
|
|
511
|
+
for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i & 3];
|
|
512
|
+
}
|
|
513
|
+
p += len;
|
|
514
|
+
if (opcode === 0x1) { onText(payload.toString('utf8')); }
|
|
515
|
+
else if (opcode === 0x8) { onClose(); return Buffer.alloc(0); }
|
|
516
|
+
else if (opcode === 0x9) { /* ping — skip */ }
|
|
517
|
+
off = p;
|
|
518
|
+
}
|
|
519
|
+
return buf.slice(off);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function attachWs(req, socket) {
|
|
523
|
+
if (!wsHandshake(req, socket)) return;
|
|
524
|
+
wsClients.add(socket);
|
|
525
|
+
let buf = Buffer.alloc(0);
|
|
526
|
+
|
|
527
|
+
wsSendText(socket, JSON.stringify({ type: 'snapshot', state: activeState }));
|
|
528
|
+
|
|
529
|
+
socket.on('data', chunk => {
|
|
530
|
+
buf = Buffer.concat([buf, chunk]);
|
|
531
|
+
buf = wsParseFrames(buf,
|
|
532
|
+
text => {
|
|
533
|
+
try {
|
|
534
|
+
const msg = JSON.parse(text);
|
|
535
|
+
if (msg.type === 'active' && typeof msg.file === 'string') setActive(msg.file);
|
|
536
|
+
else if (msg.type === 'tabs' && Array.isArray(msg.tabs)) setOpenTabs(msg.tabs);
|
|
537
|
+
else if (msg.type === 'select' && msg.selection) setSelected(msg.selection);
|
|
538
|
+
else if (msg.type === 'clear-select') setSelected(null);
|
|
539
|
+
else if (msg.type === 'comments-add' && msg.payload) commentsAdd(msg.payload);
|
|
540
|
+
else if (msg.type === 'comments-patch' && msg.id) commentsPatch(msg.id, msg.patch || {});
|
|
541
|
+
else if (msg.type === 'comments-delete' && msg.id) commentsDelete(msg.id);
|
|
542
|
+
else if (msg.type === 'comments-request' && typeof msg.file === 'string') {
|
|
543
|
+
loadCommentsForFile(msg.file).then(comments => {
|
|
544
|
+
wsSendText(socket, JSON.stringify({ type: 'comments', file: msg.file, comments }));
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
} catch {}
|
|
548
|
+
},
|
|
549
|
+
() => { try { socket.end(); } catch {} }
|
|
550
|
+
);
|
|
551
|
+
});
|
|
552
|
+
socket.on('close', () => wsClients.delete(socket));
|
|
553
|
+
socket.on('error', () => wsClients.delete(socket));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ---------- Inspector overlay (injected into every served .html under designRoot) ----------
|
|
557
|
+
//
|
|
558
|
+
// Listens for Cmd + hover (highlight), Cmd + click (select), Esc (clear).
|
|
559
|
+
// Posts selection to parent frame via window.parent.postMessage with key `dgn`.
|
|
560
|
+
// The parent (our index page) forwards over WebSocket.
|
|
561
|
+
// Also renders comment pins from comments list pushed by parent.
|
|
562
|
+
|
|
563
|
+
const INSPECTOR_SCRIPT = `
|
|
564
|
+
<script>
|
|
565
|
+
(function() {
|
|
566
|
+
if (window.__designInspectorAttached) return;
|
|
567
|
+
window.__designInspectorAttached = true;
|
|
568
|
+
var FILE = (function(){ try { return decodeURIComponent(location.pathname); } catch(e){ return location.pathname; } })().replace(/^\\//,'');
|
|
569
|
+
|
|
570
|
+
var styleEl = document.createElement('style');
|
|
571
|
+
styleEl.textContent = [
|
|
572
|
+
'.dgn-insp-hover { outline: 2px solid #00D4E4 !important; outline-offset: 1px !important; cursor: crosshair !important; }',
|
|
573
|
+
'.dgn-insp-selected { outline: 2px solid #00D4E4 !important; outline-offset: 1px !important; box-shadow: 0 0 0 4px rgba(0,212,228,0.18) !important; }',
|
|
574
|
+
'.dgn-insp-label { position: fixed; z-index: 2147483647; font: 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace; background: #00D4E4; color: #000; padding: 4px 8px; border-radius: 4px; pointer-events: none; box-shadow: 0 2px 8px rgba(0,0,0,0.4); transform: translate(0, -110%); white-space: nowrap; max-width: 320px; overflow: hidden; text-overflow: ellipsis; }',
|
|
575
|
+
'.dgn-insp-label.warn { background: #ef4444; color: #fff; }',
|
|
576
|
+
'.dgn-pin { position: absolute; top: 0; left: 0; z-index: 2147483646; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 999px 999px 999px 4px; background: #facc15; color: #1c1917; font: 600 11px/22px ui-sans-serif, system-ui, sans-serif; text-align: center; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,0,0,0.4); transition: filter 120ms; transform-origin: bottom left; will-change: transform; }',
|
|
577
|
+
'.dgn-pin:hover { filter: brightness(1.1); outline: 2px solid rgba(0,0,0,0.3); }',
|
|
578
|
+
'.dgn-pin.resolved { background: #22c55e; color: #052e16; }',
|
|
579
|
+
'.dgn-pin.focused { box-shadow: 0 4px 12px rgba(0,0,0,0.6), 0 0 0 2px #fff; outline: 2px solid #fff; }'
|
|
580
|
+
].join('\\n');
|
|
581
|
+
document.documentElement.appendChild(styleEl);
|
|
582
|
+
|
|
583
|
+
var label = document.createElement('div');
|
|
584
|
+
label.className = 'dgn-insp-label';
|
|
585
|
+
label.style.display = 'none';
|
|
586
|
+
document.documentElement.appendChild(label);
|
|
587
|
+
|
|
588
|
+
var pinLayer = document.createElement('div');
|
|
589
|
+
pinLayer.id = 'dgn-pin-layer';
|
|
590
|
+
pinLayer.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;pointer-events:none;z-index:2147483646;';
|
|
591
|
+
document.documentElement.appendChild(pinLayer);
|
|
592
|
+
|
|
593
|
+
var lastHover = null;
|
|
594
|
+
var lastSelected = null;
|
|
595
|
+
var modifierDown = false;
|
|
596
|
+
var cKeyDown = false; // true while user holds C — turns next click into select+comment
|
|
597
|
+
var commentsCache = [];
|
|
598
|
+
var focusedPinId = null;
|
|
599
|
+
|
|
600
|
+
function isModifier(e) { return e.metaKey; }
|
|
601
|
+
|
|
602
|
+
function shortText(el, max) {
|
|
603
|
+
var t = (el.innerText || el.textContent || '').replace(/\\s+/g,' ').trim();
|
|
604
|
+
return t.length > max ? t.slice(0, max - 1) + '…' : t;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Filter out inspector-added classes (dgn-insp-*, dgn-pin*) — they're added/
|
|
608
|
+
// removed live as the user hovers/selects, so capturing them in selectors
|
|
609
|
+
// would make the saved selector stop matching the moment the user releases
|
|
610
|
+
// the mouse. Same applies to dom_path.
|
|
611
|
+
function realClasses(el) {
|
|
612
|
+
return (el.getAttribute('class') || '').trim().split(/\\s+/)
|
|
613
|
+
.filter(function(c) { return c && c.indexOf('dgn-') !== 0; });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function cssPath(el) {
|
|
617
|
+
if (!(el instanceof Element)) return '';
|
|
618
|
+
var path = [];
|
|
619
|
+
while (el && el.nodeType === 1 && path.length < 8) {
|
|
620
|
+
var dscEl = el.getAttribute && el.getAttribute('data-dc-element');
|
|
621
|
+
if (dscEl) { path.unshift('[data-dc-element="' + dscEl + '"]'); break; }
|
|
622
|
+
var dscSc = el.getAttribute && el.getAttribute('data-dc-screen');
|
|
623
|
+
if (dscSc) { path.unshift('[data-dc-screen="' + dscSc + '"]'); break; }
|
|
624
|
+
var sel = el.nodeName.toLowerCase();
|
|
625
|
+
if (el.id) { sel = '#' + el.id; path.unshift(sel); break; }
|
|
626
|
+
var cls = realClasses(el).slice(0, 2);
|
|
627
|
+
if (cls.length) sel += '.' + cls.join('.');
|
|
628
|
+
var sib = 1, n = el;
|
|
629
|
+
while ((n = n.previousElementSibling)) sib++;
|
|
630
|
+
sel += ':nth-child(' + sib + ')';
|
|
631
|
+
path.unshift(sel);
|
|
632
|
+
el = el.parentElement;
|
|
633
|
+
}
|
|
634
|
+
return path.join(' > ');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function domPath(el) {
|
|
638
|
+
var hops = [];
|
|
639
|
+
while (el && el.nodeType === 1 && hops.length < 8) {
|
|
640
|
+
var label = el.nodeName.toLowerCase();
|
|
641
|
+
var dEl = el.getAttribute && el.getAttribute('data-dc-element');
|
|
642
|
+
var dSc = el.getAttribute && el.getAttribute('data-dc-screen');
|
|
643
|
+
if (dEl) label += '[data-dc-element="' + dEl + '"]';
|
|
644
|
+
else if (dSc) label += '[data-dc-screen="' + dSc + '"]';
|
|
645
|
+
else if (el.id) label += '#' + el.id;
|
|
646
|
+
var cls = realClasses(el).slice(0, 2);
|
|
647
|
+
if (cls.length && !dEl && !dSc) label += '.' + cls.join('.');
|
|
648
|
+
hops.unshift(label);
|
|
649
|
+
el = el.parentElement;
|
|
650
|
+
}
|
|
651
|
+
return hops;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function elInfo(el) {
|
|
655
|
+
var rect = el.getBoundingClientRect();
|
|
656
|
+
return {
|
|
657
|
+
file: FILE,
|
|
658
|
+
selector: cssPath(el),
|
|
659
|
+
tag: el.tagName.toLowerCase(),
|
|
660
|
+
classes: realClasses(el).join(' '),
|
|
661
|
+
text: shortText(el, 240),
|
|
662
|
+
dom_path: domPath(el),
|
|
663
|
+
bounds: { x: Math.round(rect.left), y: Math.round(rect.top), w: Math.round(rect.width), h: Math.round(rect.height) },
|
|
664
|
+
html: (el.outerHTML || '').slice(0, 4000)
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function showLabel(text, x, y, warn) {
|
|
669
|
+
label.style.display = '';
|
|
670
|
+
label.style.left = (x + 12) + 'px';
|
|
671
|
+
label.style.top = y + 'px';
|
|
672
|
+
label.textContent = text;
|
|
673
|
+
label.classList.toggle('warn', !!warn);
|
|
674
|
+
}
|
|
675
|
+
function hideLabel() { label.style.display = 'none'; }
|
|
676
|
+
|
|
677
|
+
document.addEventListener('keydown', function(e) {
|
|
678
|
+
if (e.key === 'Meta') modifierDown = true;
|
|
679
|
+
if ((e.key === 'c' || e.key === 'C') && (e.metaKey || e.ctrlKey)) cKeyDown = true;
|
|
680
|
+
if (e.key === 'Escape') {
|
|
681
|
+
if (lastHover) lastHover.classList.remove('dgn-insp-hover');
|
|
682
|
+
if (lastSelected) lastSelected.classList.remove('dgn-insp-selected');
|
|
683
|
+
lastHover = null; lastSelected = null;
|
|
684
|
+
hideLabel();
|
|
685
|
+
try { window.parent.postMessage({ dgn: 'clear-select' }, '*'); } catch(e) {}
|
|
686
|
+
}
|
|
687
|
+
// Cmd+C without click — open composer for already-selected element
|
|
688
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C') && !e.shiftKey && !e.altKey) {
|
|
689
|
+
if (lastSelected) {
|
|
690
|
+
e.preventDefault();
|
|
691
|
+
try { window.parent.postMessage({ dgn: 'comment-shortcut' }, '*'); } catch(e) {}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}, true);
|
|
695
|
+
document.addEventListener('keyup', function(e) {
|
|
696
|
+
if (e.key === 'Meta') {
|
|
697
|
+
modifierDown = false;
|
|
698
|
+
if (lastHover) { lastHover.classList.remove('dgn-insp-hover'); lastHover = null; }
|
|
699
|
+
hideLabel();
|
|
700
|
+
}
|
|
701
|
+
if (e.key === 'c' || e.key === 'C') cKeyDown = false;
|
|
702
|
+
}, true);
|
|
703
|
+
document.addEventListener('blur', function() {
|
|
704
|
+
modifierDown = false;
|
|
705
|
+
cKeyDown = false;
|
|
706
|
+
if (lastHover) { lastHover.classList.remove('dgn-insp-hover'); lastHover = null; }
|
|
707
|
+
hideLabel();
|
|
708
|
+
}, true);
|
|
709
|
+
|
|
710
|
+
document.addEventListener('mousemove', function(e) {
|
|
711
|
+
if (!isModifier(e)) {
|
|
712
|
+
if (lastHover) { lastHover.classList.remove('dgn-insp-hover'); lastHover = null; }
|
|
713
|
+
hideLabel();
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
var el = document.elementFromPoint(e.clientX, e.clientY);
|
|
717
|
+
if (!el || el === lastHover) return;
|
|
718
|
+
if (el === label) return;
|
|
719
|
+
if (lastHover) lastHover.classList.remove('dgn-insp-hover');
|
|
720
|
+
lastHover = el;
|
|
721
|
+
el.classList.add('dgn-insp-hover');
|
|
722
|
+
var t = el.tagName.toLowerCase();
|
|
723
|
+
var c = (el.getAttribute('class') || '').trim();
|
|
724
|
+
showLabel(t + (c ? '.' + c.split(/\\s+/).slice(0,2).join('.') : ''), e.clientX, e.clientY);
|
|
725
|
+
}, true);
|
|
726
|
+
|
|
727
|
+
document.addEventListener('click', function(e) {
|
|
728
|
+
if (!isModifier(e)) return;
|
|
729
|
+
if (e.target && e.target.closest && e.target.closest('.dgn-pin')) return; // let pin handler run
|
|
730
|
+
e.preventDefault();
|
|
731
|
+
e.stopPropagation();
|
|
732
|
+
var el = document.elementFromPoint(e.clientX, e.clientY);
|
|
733
|
+
if (!el || el.classList.contains('dgn-pin')) return;
|
|
734
|
+
if (lastSelected) lastSelected.classList.remove('dgn-insp-selected');
|
|
735
|
+
lastSelected = el;
|
|
736
|
+
el.classList.add('dgn-insp-selected');
|
|
737
|
+
var info = elInfo(el);
|
|
738
|
+
try { window.parent.postMessage({ dgn: 'select', selection: info }, '*'); } catch(err) {}
|
|
739
|
+
// Comment-while-clicking: ⌘⇧+click OR ⌘+click while holding C → select + composer
|
|
740
|
+
var commentNow = e.shiftKey || cKeyDown;
|
|
741
|
+
if (commentNow) {
|
|
742
|
+
try { window.parent.postMessage({ dgn: 'comment-compose', selection: info }, '*'); } catch(err) {}
|
|
743
|
+
}
|
|
744
|
+
showLabel((commentNow ? 'comment: ' : 'selected: ') + info.tag + (info.classes ? '.' + info.classes.split(/\\s+/).slice(0,2).join('.') : ''), e.clientX, e.clientY);
|
|
745
|
+
setTimeout(hideLabel, 1500);
|
|
746
|
+
}, true);
|
|
747
|
+
|
|
748
|
+
// ----- Pin rendering for comments -----
|
|
749
|
+
// Pins are anchored to actual DOM elements. Because the design canvas pans/zooms
|
|
750
|
+
// via CSS transform (no scroll event fires), we keep a rAF loop running while any
|
|
751
|
+
// pin exists — cheap layout reads, ensures pins stay glued to their elements
|
|
752
|
+
// through every transform tick (wheel zoom, drag pan, gesture pinch).
|
|
753
|
+
|
|
754
|
+
var pinNodes = []; // [{ el, comment, target }]
|
|
755
|
+
var rafToken = null;
|
|
756
|
+
|
|
757
|
+
function buildPinNodes() {
|
|
758
|
+
pinLayer.innerHTML = '';
|
|
759
|
+
pinNodes = [];
|
|
760
|
+
var withSelector = commentsCache.filter(function(c) { return c && c.selector; });
|
|
761
|
+
withSelector.forEach(function(c, i) {
|
|
762
|
+
var target = null;
|
|
763
|
+
try { target = document.querySelector(c.selector); } catch (e) {}
|
|
764
|
+
var pin = document.createElement('button');
|
|
765
|
+
pin.className = 'dgn-pin' + (c.status === 'resolved' ? ' resolved' : '') + (c.id === focusedPinId ? ' focused' : '');
|
|
766
|
+
pin.textContent = String(i + 1);
|
|
767
|
+
pin.title = (c.text || '').slice(0, 200);
|
|
768
|
+
pin.style.pointerEvents = 'auto';
|
|
769
|
+
pin.style.left = '0px';
|
|
770
|
+
pin.style.top = '0px';
|
|
771
|
+
pin.dataset.id = c.id;
|
|
772
|
+
pin.addEventListener('click', function(ev) {
|
|
773
|
+
ev.preventDefault();
|
|
774
|
+
ev.stopPropagation();
|
|
775
|
+
focusedPinId = c.id;
|
|
776
|
+
try { window.parent.postMessage({ dgn: 'comment-click', id: c.id }, '*'); } catch (e) {}
|
|
777
|
+
buildPinNodes();
|
|
778
|
+
});
|
|
779
|
+
pinLayer.appendChild(pin);
|
|
780
|
+
pinNodes.push({ el: pin, comment: c, target: target });
|
|
781
|
+
});
|
|
782
|
+
placePins();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function placePins() {
|
|
786
|
+
if (!pinNodes.length) return;
|
|
787
|
+
for (var i = 0; i < pinNodes.length; i++) {
|
|
788
|
+
var node = pinNodes[i];
|
|
789
|
+
var x, y, hidden = false;
|
|
790
|
+
// Re-resolve target lazily — DOM may have changed since last build
|
|
791
|
+
if (!node.target || !node.target.isConnected) {
|
|
792
|
+
try { node.target = document.querySelector(node.comment.selector); } catch (e) {}
|
|
793
|
+
}
|
|
794
|
+
if (node.target) {
|
|
795
|
+
var r = node.target.getBoundingClientRect();
|
|
796
|
+
// pinLayer is position:absolute at document top:0/left:0 → coords are document-relative
|
|
797
|
+
x = r.left + window.scrollX - 8;
|
|
798
|
+
y = r.top + window.scrollY - 8;
|
|
799
|
+
// Hide if element is gone from layout (zero rect)
|
|
800
|
+
if (r.width === 0 && r.height === 0) hidden = true;
|
|
801
|
+
} else if (node.comment.bounds) {
|
|
802
|
+
x = node.comment.bounds.x - 8;
|
|
803
|
+
y = node.comment.bounds.y - 8;
|
|
804
|
+
} else {
|
|
805
|
+
hidden = true;
|
|
806
|
+
}
|
|
807
|
+
if (hidden) {
|
|
808
|
+
node.el.style.display = 'none';
|
|
809
|
+
} else {
|
|
810
|
+
node.el.style.display = '';
|
|
811
|
+
var scale = (node.comment.id === focusedPinId) ? 1.2 : 1;
|
|
812
|
+
node.el.style.transform = 'translate(' + Math.round(x) + 'px, ' + Math.round(y) + 'px) scale(' + scale + ')';
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function tick() {
|
|
818
|
+
rafToken = null;
|
|
819
|
+
placePins();
|
|
820
|
+
if (pinNodes.length) rafToken = requestAnimationFrame(tick);
|
|
821
|
+
}
|
|
822
|
+
function startTick() {
|
|
823
|
+
if (rafToken == null && pinNodes.length) rafToken = requestAnimationFrame(tick);
|
|
824
|
+
}
|
|
825
|
+
function stopTick() {
|
|
826
|
+
if (rafToken != null) { cancelAnimationFrame(rafToken); rafToken = null; }
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function schedulePins() { buildPinNodes(); startTick(); }
|
|
830
|
+
|
|
831
|
+
// Keep position fresh on any scroll (incl. inner scrollers via capture phase),
|
|
832
|
+
// resize, transform/style mutations, and continuously while pins exist via rAF.
|
|
833
|
+
window.addEventListener('resize', placePins);
|
|
834
|
+
document.addEventListener('scroll', placePins, { passive: true, capture: true });
|
|
835
|
+
document.addEventListener('wheel', function() { startTick(); }, { passive: true, capture: true });
|
|
836
|
+
document.addEventListener('pointermove', function(e) { if (e.buttons) startTick(); }, { passive: true, capture: true });
|
|
837
|
+
document.addEventListener('keyup', function() { startTick(); }, true);
|
|
838
|
+
// Also observe DOM mutations (transform style, layout shifts, late-mounted React content)
|
|
839
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
840
|
+
new MutationObserver(function(){ startTick(); }).observe(document.documentElement, { subtree: true, attributes: true, attributeFilter: ['style', 'class'], childList: true });
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
window.addEventListener('message', function(e) {
|
|
844
|
+
var m = e.data;
|
|
845
|
+
if (!m || typeof m !== 'object' || !m.dgn) return;
|
|
846
|
+
if (m.dgn === 'comments-set' && Array.isArray(m.comments)) {
|
|
847
|
+
commentsCache = m.comments;
|
|
848
|
+
schedulePins();
|
|
849
|
+
} else if (m.dgn === 'comment-focus') {
|
|
850
|
+
focusedPinId = m.id || null;
|
|
851
|
+
// Update pin classes + transform without rebuilding nodes (preserves rAF state)
|
|
852
|
+
pinNodes.forEach(function(node) {
|
|
853
|
+
node.el.classList.toggle('focused', node.comment.id === focusedPinId);
|
|
854
|
+
});
|
|
855
|
+
placePins();
|
|
856
|
+
startTick();
|
|
857
|
+
// Scroll target into view if present
|
|
858
|
+
var c = commentsCache.find(function(x){ return x && x.id === m.id; });
|
|
859
|
+
if (c && c.selector) {
|
|
860
|
+
try {
|
|
861
|
+
var t = document.querySelector(c.selector);
|
|
862
|
+
if (t) t.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
863
|
+
} catch (e) {}
|
|
864
|
+
}
|
|
865
|
+
} else if (m.dgn === 'force-clear') {
|
|
866
|
+
if (lastSelected) lastSelected.classList.remove('dgn-insp-selected');
|
|
867
|
+
lastSelected = null;
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
try { window.parent.postMessage({ dgn: 'loaded', file: FILE }, '*'); } catch(e) {}
|
|
872
|
+
})();
|
|
873
|
+
</script>
|
|
874
|
+
`;
|
|
875
|
+
|
|
876
|
+
// ---------- Inspector script injection ----------
|
|
877
|
+
|
|
878
|
+
function injectInspector(html) {
|
|
879
|
+
var idx = html.lastIndexOf('</body>');
|
|
880
|
+
if (idx === -1) return html + INSPECTOR_SCRIPT;
|
|
881
|
+
return html.slice(0, idx) + INSPECTOR_SCRIPT + html.slice(idx);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ---------- File-tree data ----------
|
|
885
|
+
|
|
886
|
+
async function buildIndexData() {
|
|
887
|
+
const groups = [];
|
|
888
|
+
for (const g of CFG.canvasGroups) {
|
|
889
|
+
const groupAbs = path.join(DESIGN_ROOT, g.path);
|
|
890
|
+
const groupRel = path.posix.join(DESIGN_REL, g.path);
|
|
891
|
+
const paths = await findHtmlFiles(groupAbs, groupRel);
|
|
892
|
+
groups.push({
|
|
893
|
+
label: g.label,
|
|
894
|
+
paths,
|
|
895
|
+
fullPath: groupRel,
|
|
896
|
+
stripPrefix: groupRel + '/',
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
return {
|
|
900
|
+
project: CFG.name,
|
|
901
|
+
projectLabel: PROJECT_LABEL,
|
|
902
|
+
designRoot: DESIGN_REL,
|
|
903
|
+
groups,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ---------- System view aggregator ----------
|
|
908
|
+
|
|
909
|
+
const SYSTEM_DIR_REL = (CFG.canvasGroups.find(g => /system/i.test(g.path))?.path) || 'system';
|
|
910
|
+
|
|
911
|
+
async function findFiles(absRoot, prefix, exts) {
|
|
912
|
+
const out = [];
|
|
913
|
+
let entries;
|
|
914
|
+
try { entries = await fs.readdir(absRoot, { withFileTypes: true }); } catch { return out; }
|
|
915
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
916
|
+
for (const e of entries) {
|
|
917
|
+
if (e.name.startsWith('.') && !HIDDEN_OK.has(e.name)) continue;
|
|
918
|
+
if (e.name.startsWith('_')) continue;
|
|
919
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
920
|
+
const full = path.join(absRoot, e.name);
|
|
921
|
+
const rel = path.posix.join(prefix, e.name);
|
|
922
|
+
if (e.isDirectory()) {
|
|
923
|
+
out.push(...await findFiles(full, rel, exts));
|
|
924
|
+
} else if (exts.some(x => e.name.toLowerCase().endsWith(x))) {
|
|
925
|
+
out.push(rel);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return out;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function tokenKind(name, value) {
|
|
932
|
+
const n = name.toLowerCase();
|
|
933
|
+
const v = String(value).trim();
|
|
934
|
+
if (/(color|fg|bg|border|accent|status|surface|text)/.test(n)) return 'color';
|
|
935
|
+
if (/^#[0-9a-f]{3,8}$/i.test(v)) return 'color';
|
|
936
|
+
if (/^(rgb|rgba|hsl|hsla|oklch|color)\(/i.test(v)) return 'color';
|
|
937
|
+
if (/(font-size|fs|text)/.test(n) && /\d/.test(v)) return 'fontsize';
|
|
938
|
+
if (/(font|family|display|sans|mono)/.test(n)) return 'font';
|
|
939
|
+
if (/(radius|r-)/.test(n)) return 'radius';
|
|
940
|
+
if (/(shadow|elev)/.test(n)) return 'shadow';
|
|
941
|
+
if (/(space|gap|s-|spacing)/.test(n)) return 'space';
|
|
942
|
+
if (/(weight|fw)/.test(n)) return 'weight';
|
|
943
|
+
if (/(line-height|lh|leading)/.test(n)) return 'leading';
|
|
944
|
+
if (/(duration|ease|motion)/.test(n)) return 'motion';
|
|
945
|
+
return 'other';
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function parseTokens(css) {
|
|
949
|
+
const tokens = [];
|
|
950
|
+
// Capture --name: value; inside any rule. Naive but robust enough.
|
|
951
|
+
const re = /(--[a-z][a-z0-9-]*)\s*:\s*([^;}]+);/gi;
|
|
952
|
+
const seen = new Set();
|
|
953
|
+
let m;
|
|
954
|
+
while ((m = re.exec(css)) !== null) {
|
|
955
|
+
const name = m[1].trim();
|
|
956
|
+
const value = m[2].trim();
|
|
957
|
+
const key = name + '|' + value;
|
|
958
|
+
if (seen.has(key)) continue;
|
|
959
|
+
seen.add(key);
|
|
960
|
+
tokens.push({ name, value, kind: tokenKind(name, value) });
|
|
961
|
+
}
|
|
962
|
+
return tokens;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async function buildSystemData() {
|
|
966
|
+
const sysAbs = path.join(DESIGN_ROOT, SYSTEM_DIR_REL);
|
|
967
|
+
const sysRel = path.posix.join(DESIGN_REL, SYSTEM_DIR_REL);
|
|
968
|
+
|
|
969
|
+
// README — try canonical locations (designRoot/README.md, system/README.md, system/<project>/README.md)
|
|
970
|
+
let readme = null, readmePath = null;
|
|
971
|
+
const readmeCandidates = [
|
|
972
|
+
path.join(DESIGN_ROOT, 'README.md'),
|
|
973
|
+
path.join(sysAbs, 'README.md'),
|
|
974
|
+
];
|
|
975
|
+
try {
|
|
976
|
+
const subs = await fs.readdir(sysAbs, { withFileTypes: true });
|
|
977
|
+
for (const s of subs) {
|
|
978
|
+
if (s.isDirectory()) readmeCandidates.push(path.join(sysAbs, s.name, 'README.md'));
|
|
979
|
+
}
|
|
980
|
+
} catch {}
|
|
981
|
+
for (const c of readmeCandidates) {
|
|
982
|
+
try {
|
|
983
|
+
readme = await fs.readFile(c, 'utf8');
|
|
984
|
+
readmePath = path.relative(REPO_ROOT, c);
|
|
985
|
+
break;
|
|
986
|
+
} catch {}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Tokens
|
|
990
|
+
let tokens = [];
|
|
991
|
+
let tokensPath = null;
|
|
992
|
+
try {
|
|
993
|
+
const tokensAbs = path.join(DESIGN_ROOT, CFG.tokensCssRel);
|
|
994
|
+
const css = await fs.readFile(tokensAbs, 'utf8');
|
|
995
|
+
tokens = parseTokens(css);
|
|
996
|
+
tokensPath = path.relative(REPO_ROOT, tokensAbs);
|
|
997
|
+
} catch {}
|
|
998
|
+
const tokenGroups = {};
|
|
999
|
+
for (const t of tokens) (tokenGroups[t.kind] = tokenGroups[t.kind] || []).push(t);
|
|
1000
|
+
|
|
1001
|
+
// Two canonical galleries per design-system skill: preview/ + ui_kits/
|
|
1002
|
+
// (Other folders — assets, components, scraps — vary per project; surface only
|
|
1003
|
+
// when they exist as a flat file list at the bottom.)
|
|
1004
|
+
async function galleryFor(folderName) {
|
|
1005
|
+
// Look one level deep — system/<project>/<folderName>/...
|
|
1006
|
+
const matches = [];
|
|
1007
|
+
try {
|
|
1008
|
+
const subs = await fs.readdir(sysAbs, { withFileTypes: true });
|
|
1009
|
+
for (const s of subs) {
|
|
1010
|
+
if (!s.isDirectory()) continue;
|
|
1011
|
+
const candidate = path.join(sysAbs, s.name, folderName);
|
|
1012
|
+
try {
|
|
1013
|
+
const stat = await fs.stat(candidate);
|
|
1014
|
+
if (stat.isDirectory()) matches.push({ abs: candidate, rel: path.posix.join(sysRel, s.name, folderName) });
|
|
1015
|
+
} catch {}
|
|
1016
|
+
}
|
|
1017
|
+
// Also accept top-level system/<folderName>/
|
|
1018
|
+
try {
|
|
1019
|
+
const stat = await fs.stat(path.join(sysAbs, folderName));
|
|
1020
|
+
if (stat.isDirectory()) matches.push({ abs: path.join(sysAbs, folderName), rel: path.posix.join(sysRel, folderName) });
|
|
1021
|
+
} catch {}
|
|
1022
|
+
} catch {}
|
|
1023
|
+
const items = [];
|
|
1024
|
+
for (const m of matches) {
|
|
1025
|
+
const files = await findFiles(m.abs, m.rel, ['.html']);
|
|
1026
|
+
for (const f of files) {
|
|
1027
|
+
const fname = f.split('/').pop().replace(/\.html$/i, '');
|
|
1028
|
+
const group = f.split('/').slice(-2, -1)[0] || folderName;
|
|
1029
|
+
// For "index.html" prefer the group name as the label (e.g. "desktop", "mobile")
|
|
1030
|
+
const label = (fname.toLowerCase() === 'index') ? group : fname;
|
|
1031
|
+
items.push({ label, path: f, group });
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return items;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const previewGallery = await galleryFor('preview');
|
|
1038
|
+
const uiKitsGallery = await galleryFor('ui_kits');
|
|
1039
|
+
|
|
1040
|
+
return {
|
|
1041
|
+
project: CFG.name,
|
|
1042
|
+
designRoot: DESIGN_REL,
|
|
1043
|
+
systemDir: sysRel,
|
|
1044
|
+
readme,
|
|
1045
|
+
readmePath,
|
|
1046
|
+
tokens,
|
|
1047
|
+
tokenGroups,
|
|
1048
|
+
tokensPath,
|
|
1049
|
+
previewGallery,
|
|
1050
|
+
uiKitsGallery,
|
|
1051
|
+
rootClass: CFG.rootClass,
|
|
1052
|
+
themeDefault: CFG.themeDefault,
|
|
1053
|
+
teamAccentDefault: CFG.teamAccentDefault,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// ---------- Client + runtime static files ----------
|
|
1058
|
+
|
|
1059
|
+
const CLIENT_DIR = path.join(__dirname, 'client');
|
|
1060
|
+
|
|
1061
|
+
async function serveStaticFrom(rootDir, relPath, res) {
|
|
1062
|
+
const safe = relPath.replace(/\.\./g, '');
|
|
1063
|
+
const fp = path.join(rootDir, safe);
|
|
1064
|
+
if (!fp.startsWith(rootDir + path.sep) && fp !== rootDir) {
|
|
1065
|
+
res.writeHead(403); res.end('Forbidden'); return;
|
|
1066
|
+
}
|
|
1067
|
+
try {
|
|
1068
|
+
const data = await fs.readFile(fp);
|
|
1069
|
+
const ext = path.extname(fp).toLowerCase();
|
|
1070
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream', 'Cache-Control': 'no-store' });
|
|
1071
|
+
res.end(data);
|
|
1072
|
+
} catch {
|
|
1073
|
+
res.writeHead(404); res.end('Not found');
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const serveClientFile = (rel, res) => serveStaticFrom(CLIENT_DIR, rel, res);
|
|
1078
|
+
|
|
1079
|
+
// ---------- HTTP ----------
|
|
1080
|
+
|
|
1081
|
+
const port = parseInt(process.env.PORT || '0', 10) || await findFreePort(4321);
|
|
1082
|
+
|
|
1083
|
+
const server = http.createServer(async (req, res) => {
|
|
1084
|
+
try {
|
|
1085
|
+
const reqPath = req.url || '/';
|
|
1086
|
+
if (reqPath === '/_health') {
|
|
1087
|
+
res.writeHead(200, { 'Content-Type': MIME['.json'] });
|
|
1088
|
+
res.end(JSON.stringify({ ok: true, app: 'design', project: CFG.name, pid: process.pid, port }));
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
if (reqPath === '/_active') {
|
|
1092
|
+
res.writeHead(200, { 'Content-Type': MIME['.json'] });
|
|
1093
|
+
res.end(JSON.stringify(activeState));
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
if (reqPath === '/_config') {
|
|
1097
|
+
const safeCfg = { ...CFG };
|
|
1098
|
+
res.writeHead(200, { 'Content-Type': MIME['.json'] });
|
|
1099
|
+
res.end(JSON.stringify(safeCfg, null, 2));
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
if (reqPath === '/_index-data') {
|
|
1103
|
+
const data = await buildIndexData();
|
|
1104
|
+
res.writeHead(200, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-store' });
|
|
1105
|
+
res.end(JSON.stringify(data));
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
if (reqPath === '/_system-data') {
|
|
1109
|
+
const data = await buildSystemData();
|
|
1110
|
+
res.writeHead(200, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-store' });
|
|
1111
|
+
res.end(JSON.stringify(data));
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
if (reqPath.startsWith('/_canvas-state')) {
|
|
1115
|
+
const url = new URL(reqPath, 'http://x');
|
|
1116
|
+
if (req.method === 'GET') {
|
|
1117
|
+
const file = url.searchParams.get('file');
|
|
1118
|
+
if (!file) { res.writeHead(400); res.end('file query param required'); return; }
|
|
1119
|
+
const state = await loadCanvasState(file);
|
|
1120
|
+
res.writeHead(200, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-store' });
|
|
1121
|
+
res.end(JSON.stringify(state || {}));
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (req.method === 'POST') {
|
|
1125
|
+
try {
|
|
1126
|
+
const body = await readJsonBody(req);
|
|
1127
|
+
if (!body || typeof body.file !== 'string' || !body.file) {
|
|
1128
|
+
res.writeHead(400); res.end('body must include file (string)');
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
await saveCanvasState(body.file, body);
|
|
1132
|
+
res.writeHead(204); res.end();
|
|
1133
|
+
} catch (e) {
|
|
1134
|
+
res.writeHead(400); res.end('invalid JSON: ' + e.message);
|
|
1135
|
+
}
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
res.writeHead(405); res.end('Method not allowed');
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
if (reqPath.startsWith('/_comments') && req.method === 'GET') {
|
|
1142
|
+
const url = new URL(reqPath, 'http://x');
|
|
1143
|
+
if (url.pathname === '/_comments-all') {
|
|
1144
|
+
const all = await loadAllComments();
|
|
1145
|
+
res.writeHead(200, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-store' });
|
|
1146
|
+
res.end(JSON.stringify(all));
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
if (url.pathname === '/_comments') {
|
|
1150
|
+
const file = url.searchParams.get('file');
|
|
1151
|
+
if (!file) { res.writeHead(400); res.end('file query param required'); return; }
|
|
1152
|
+
const comments = await loadCommentsForFile(file);
|
|
1153
|
+
res.writeHead(200, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-store' });
|
|
1154
|
+
res.end(JSON.stringify({ file, comments }));
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (reqPath === '/' || reqPath === '/index.html') {
|
|
1159
|
+
// Serve client/index.html
|
|
1160
|
+
try {
|
|
1161
|
+
const data = await fs.readFile(path.join(CLIENT_DIR, 'index.html'));
|
|
1162
|
+
res.writeHead(200, { 'Content-Type': MIME['.html'], 'Cache-Control': 'no-store' });
|
|
1163
|
+
res.end(data);
|
|
1164
|
+
} catch (e) {
|
|
1165
|
+
res.writeHead(500); res.end('Client UI missing — expected at ' + path.join(CLIENT_DIR, 'index.html'));
|
|
1166
|
+
}
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
if (reqPath.startsWith('/_client/')) {
|
|
1170
|
+
const rel = decodeURIComponent(reqPath.slice('/_client/'.length));
|
|
1171
|
+
await serveClientFile(rel, res);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
const fp = safePath(reqPath);
|
|
1175
|
+
if (!fp) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
1176
|
+
let stat;
|
|
1177
|
+
try { stat = await fs.stat(fp); }
|
|
1178
|
+
catch { res.writeHead(404); res.end('Not found'); return; }
|
|
1179
|
+
|
|
1180
|
+
if (stat.isDirectory()) {
|
|
1181
|
+
const idx = path.join(fp, 'index.html');
|
|
1182
|
+
try {
|
|
1183
|
+
const data = await fs.readFile(idx);
|
|
1184
|
+
res.writeHead(200, { 'Content-Type': MIME['.html'] });
|
|
1185
|
+
res.end(data);
|
|
1186
|
+
} catch {
|
|
1187
|
+
res.writeHead(404);
|
|
1188
|
+
res.end('Directory listing disabled');
|
|
1189
|
+
}
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const ext = path.extname(fp).toLowerCase();
|
|
1194
|
+
const mime = MIME[ext] || 'application/octet-stream';
|
|
1195
|
+
let data = await fs.readFile(fp);
|
|
1196
|
+
|
|
1197
|
+
if (ext === '.html' && fp.startsWith(DESIGN_ROOT + path.sep)) {
|
|
1198
|
+
let html = data.toString('utf8');
|
|
1199
|
+
html = injectInspector(html);
|
|
1200
|
+
data = Buffer.from(html, 'utf8');
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'no-store' });
|
|
1204
|
+
res.end(data);
|
|
1205
|
+
} catch (e) {
|
|
1206
|
+
res.writeHead(500);
|
|
1207
|
+
res.end('Server error: ' + e.message);
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
server.on('upgrade', (req, socket) => {
|
|
1212
|
+
if ((req.url || '').startsWith('/_ws')) {
|
|
1213
|
+
attachWs(req, socket);
|
|
1214
|
+
} else {
|
|
1215
|
+
socket.destroy();
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
server.listen(port, '127.0.0.1', async () => {
|
|
1220
|
+
await loadActive();
|
|
1221
|
+
await writeServerInfo(port);
|
|
1222
|
+
const url = `http://localhost:${port}`;
|
|
1223
|
+
console.log(`\n ${PROJECT_LABEL} — local browser`);
|
|
1224
|
+
console.log(` ─────────────────────────────`);
|
|
1225
|
+
console.log(` ${url}`);
|
|
1226
|
+
console.log(` Project: ${CFG.name}`);
|
|
1227
|
+
console.log(` Config: ${CFG._source}`);
|
|
1228
|
+
console.log(` Design: ${DESIGN_ROOT}`);
|
|
1229
|
+
console.log(` Active: ${ACTIVE_FILE}`);
|
|
1230
|
+
console.log(` Press Ctrl+C to stop.\n`);
|
|
1231
|
+
if (process.platform === 'darwin' && !process.env.NO_OPEN) {
|
|
1232
|
+
exec(`open ${url}`);
|
|
1233
|
+
} else if (process.platform === 'linux' && !process.env.NO_OPEN) {
|
|
1234
|
+
exec(`xdg-open ${url}`);
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
const shutdown = () => {
|
|
1239
|
+
console.log('\n Stopping…');
|
|
1240
|
+
removeServerInfo();
|
|
1241
|
+
for (const s of wsClients) { try { s.end(); } catch {} }
|
|
1242
|
+
server.close(() => process.exit(0));
|
|
1243
|
+
};
|
|
1244
|
+
process.on('SIGINT', shutdown);
|
|
1245
|
+
process.on('SIGTERM', shutdown);
|
|
1246
|
+
process.on('exit', () => removeServerInfo());
|