@1agh/maude 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +32 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +126 -2
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +74 -10
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- package/plugins/design/dev-server/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +25 -21
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +333 -19
- package/plugins/design/dev-server/client/app.jsx +155 -7
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +80 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +24 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +126 -13
- package/plugins/design/dev-server/dist/comment-mount.js +78 -11
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +26 -3
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +123 -1
- package/plugins/design/dev-server/sync/agent.ts +95 -0
- package/plugins/design/dev-server/sync/codec.ts +155 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/index.ts +479 -35
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +49 -1
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// T4.5 (DDR-054 §3 F3) — untrusted-marker for sync-written files. Verifies the
|
|
2
|
+
// `_untrusted/INDEX.json` marker + the managed `.claudeignore` block: written
|
|
3
|
+
// for the syncable set, cleared when empty, stale entries dropped on re-write,
|
|
4
|
+
// and user-authored `.claudeignore` content outside the block preserved.
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
11
|
+
|
|
12
|
+
import type { Context } from '../context.ts';
|
|
13
|
+
import type { CanvasDescriptor } from '../sync/index.ts';
|
|
14
|
+
import { clearUntrustedMarkers, writeUntrustedMarkers } from '../sync/untrusted.ts';
|
|
15
|
+
|
|
16
|
+
let repoRoot: string;
|
|
17
|
+
let designRoot: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
repoRoot = mkdtempSync(join(tmpdir(), 'untrusted-'));
|
|
21
|
+
designRoot = join(repoRoot, '.design');
|
|
22
|
+
mkdirSync(designRoot, { recursive: true });
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
/* tmpdir — left for the OS to reap */
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function ctxOf(): Context {
|
|
29
|
+
return { paths: { repoRoot, designRoot } } as unknown as Context;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function desc(slug: string, body: string): CanvasDescriptor {
|
|
33
|
+
return {
|
|
34
|
+
slug,
|
|
35
|
+
html: join(designRoot, body),
|
|
36
|
+
comments: join(designRoot, '_comments', `${slug}.json`),
|
|
37
|
+
annotations: join(designRoot, `${slug}.annotations.svg`),
|
|
38
|
+
meta: join(designRoot, body.replace(/\.(tsx|html)$/i, '.meta.json')),
|
|
39
|
+
css: join(designRoot, body.replace(/\.(tsx|html)$/i, '.css')),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const HUB = 'https://hub.example.com';
|
|
44
|
+
|
|
45
|
+
describe('writeUntrustedMarkers — T4.5', () => {
|
|
46
|
+
test('writes INDEX.json + a managed .claudeignore block for the synced set', () => {
|
|
47
|
+
const ctx = ctxOf();
|
|
48
|
+
writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
|
|
49
|
+
|
|
50
|
+
const indexAbs = join(designRoot, '_untrusted', 'INDEX.json');
|
|
51
|
+
expect(existsSync(indexAbs)).toBe(true);
|
|
52
|
+
const index = JSON.parse(readFileSync(indexAbs, 'utf8'));
|
|
53
|
+
expect(index.hubUrl).toBe(HUB);
|
|
54
|
+
expect(index.canvases[0].slug).toBe('ui-a');
|
|
55
|
+
expect(index.canvases[0].body).toBe('.design/ui/a.tsx');
|
|
56
|
+
expect(index.canvases[0].meta).toBe('.design/ui/a.meta.json'); // Gap 2: meta is untrusted too
|
|
57
|
+
expect(index.canvases[0].css).toBe('.design/ui/a.css'); // Gap 3: css too
|
|
58
|
+
expect(index.note.toLowerCase()).toContain('untrusted');
|
|
59
|
+
|
|
60
|
+
const ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
|
|
61
|
+
expect(ci).toContain('maude:sync-untrusted begin');
|
|
62
|
+
expect(ci).toContain('.design/ui/a.tsx');
|
|
63
|
+
expect(ci).toContain('.design/ui-a.annotations.svg');
|
|
64
|
+
expect(ci).toContain('.design/ui/a.meta.json');
|
|
65
|
+
expect(ci).toContain('.design/ui/a.css');
|
|
66
|
+
expect(ci).toContain('maude:sync-untrusted end');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('re-write with a smaller set drops the stale entry', () => {
|
|
70
|
+
const ctx = ctxOf();
|
|
71
|
+
writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx'), desc('ui-b', 'ui/b.tsx')], HUB);
|
|
72
|
+
writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
|
|
73
|
+
const ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
|
|
74
|
+
expect(ci).toContain('.design/ui/a.tsx');
|
|
75
|
+
expect(ci).not.toContain('.design/ui/b.tsx');
|
|
76
|
+
// Only one managed block (no accumulation).
|
|
77
|
+
expect(ci.match(/maude:sync-untrusted begin/g)?.length).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('empty set clears INDEX.json + the .claudeignore block', () => {
|
|
81
|
+
const ctx = ctxOf();
|
|
82
|
+
writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
|
|
83
|
+
writeUntrustedMarkers(ctx, [], HUB);
|
|
84
|
+
expect(existsSync(join(designRoot, '_untrusted', 'INDEX.json'))).toBe(false);
|
|
85
|
+
// .claudeignore had only our block → removed entirely.
|
|
86
|
+
expect(existsSync(join(repoRoot, '.claudeignore'))).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('preserves user-authored .claudeignore content outside the block', () => {
|
|
90
|
+
writeFileSync(join(repoRoot, '.claudeignore'), 'secrets/\n*.key\n');
|
|
91
|
+
const ctx = ctxOf();
|
|
92
|
+
writeUntrustedMarkers(ctx, [desc('ui-a', 'ui/a.tsx')], HUB);
|
|
93
|
+
let ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
|
|
94
|
+
expect(ci).toContain('secrets/');
|
|
95
|
+
expect(ci).toContain('*.key');
|
|
96
|
+
expect(ci).toContain('.design/ui/a.tsx');
|
|
97
|
+
// Clearing leaves the user content intact.
|
|
98
|
+
clearUntrustedMarkers(ctx);
|
|
99
|
+
ci = readFileSync(join(repoRoot, '.claudeignore'), 'utf8');
|
|
100
|
+
expect(ci).toContain('secrets/');
|
|
101
|
+
expect(ci).not.toContain('.design/ui/a.tsx');
|
|
102
|
+
expect(ci).not.toContain('maude:sync-untrusted');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from '../use-tool-mode.tsx';
|
|
13
13
|
|
|
14
14
|
describe('use-tool-mode / static', () => {
|
|
15
|
-
test('DEFAULT_TOOLS exposes V/H/C +
|
|
15
|
+
test('DEFAULT_TOOLS exposes V/H/C + draw set B/R/O/N/A/T/E (Phase 21 sticky+text)', () => {
|
|
16
16
|
expect(DEFAULT_TOOLS.map((t) => t.id)).toEqual([
|
|
17
17
|
'move',
|
|
18
18
|
'hand',
|
|
@@ -20,26 +20,54 @@ describe('use-tool-mode / static', () => {
|
|
|
20
20
|
'pen',
|
|
21
21
|
'rect',
|
|
22
22
|
'ellipse',
|
|
23
|
+
'sticky',
|
|
23
24
|
'arrow',
|
|
25
|
+
'text',
|
|
24
26
|
'eraser',
|
|
25
27
|
]);
|
|
26
|
-
expect(DEFAULT_TOOLS.map((t) => t.shortcut)).toEqual([
|
|
28
|
+
expect(DEFAULT_TOOLS.map((t) => t.shortcut)).toEqual([
|
|
29
|
+
'V',
|
|
30
|
+
'H',
|
|
31
|
+
'C',
|
|
32
|
+
'B',
|
|
33
|
+
'R',
|
|
34
|
+
'O',
|
|
35
|
+
'N',
|
|
36
|
+
'A',
|
|
37
|
+
'T',
|
|
38
|
+
'E',
|
|
39
|
+
]);
|
|
27
40
|
});
|
|
28
41
|
|
|
29
42
|
test('DEFAULT_TOOLS is immutable (Object.freeze applied)', () => {
|
|
30
43
|
expect(Object.isFrozen(DEFAULT_TOOLS)).toBe(true);
|
|
31
44
|
});
|
|
32
45
|
|
|
33
|
-
test('default cursors per tool', () => {
|
|
46
|
+
test('default cursors per tool (Phase 21 — custom SVG cursors + native fallback)', () => {
|
|
34
47
|
const byId = Object.fromEntries(DEFAULT_TOOLS.map((t) => [t.id, t.cursor]));
|
|
48
|
+
// Move keeps the system arrow; every other tool ships a data-URI SVG cursor
|
|
49
|
+
// that falls back to the right native cursor if the image is rejected.
|
|
35
50
|
expect(byId.move).toBe('default');
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
for (const id of [
|
|
52
|
+
'hand',
|
|
53
|
+
'comment',
|
|
54
|
+
'pen',
|
|
55
|
+
'rect',
|
|
56
|
+
'ellipse',
|
|
57
|
+
'sticky',
|
|
58
|
+
'arrow',
|
|
59
|
+
'text',
|
|
60
|
+
'eraser',
|
|
61
|
+
]) {
|
|
62
|
+
expect(byId[id]).toContain('url("data:image/svg+xml,');
|
|
63
|
+
expect(byId[id]).toContain('32'); // 32×32 cursor
|
|
64
|
+
}
|
|
65
|
+
// Native fallbacks are preserved after the custom URL.
|
|
66
|
+
expect(byId.hand).toMatch(/, grab$/);
|
|
67
|
+
expect(byId.text).toMatch(/, text$/);
|
|
68
|
+
expect(byId.eraser).toMatch(/, cell$/);
|
|
69
|
+
expect(byId.pen).toMatch(/, crosshair$/);
|
|
70
|
+
expect(byId.rect).toMatch(/, crosshair$/);
|
|
43
71
|
});
|
|
44
72
|
});
|
|
45
73
|
|
|
@@ -35,13 +35,13 @@ const PALETTE_CSS = `
|
|
|
35
35
|
transform: translateX(-50%);
|
|
36
36
|
display: flex;
|
|
37
37
|
align-items: stretch;
|
|
38
|
-
background: var(--
|
|
39
|
-
border: 1px solid var(--
|
|
38
|
+
background: var(--maude-chrome-bg-0, #ffffff);
|
|
39
|
+
border: 1px solid var(--maude-chrome-fg-0, #1c1917);
|
|
40
40
|
border-radius: 8px;
|
|
41
|
-
box-shadow: 0 6px 24px color-mix(in oklab,
|
|
42
|
-
font-family: var(--
|
|
41
|
+
box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
|
|
42
|
+
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
43
43
|
font-size: 12px;
|
|
44
|
-
color: var(--
|
|
44
|
+
color: var(--maude-chrome-fg-0, #1a1a1a);
|
|
45
45
|
z-index: 6;
|
|
46
46
|
user-select: none;
|
|
47
47
|
/* Intentionally NO overflow:hidden — the zoom popover (.dc-tp-popover) is
|
|
@@ -56,7 +56,7 @@ const PALETTE_CSS = `
|
|
|
56
56
|
}
|
|
57
57
|
.dc-tool-palette .dc-tp-sep {
|
|
58
58
|
width: 1px;
|
|
59
|
-
background: var(--
|
|
59
|
+
background: var(--maude-chrome-border, rgba(0,0,0,0.08));
|
|
60
60
|
margin: 6px 0;
|
|
61
61
|
}
|
|
62
62
|
.dc-tool-palette button {
|
|
@@ -70,14 +70,14 @@ const PALETTE_CSS = `
|
|
|
70
70
|
display: inline-flex;
|
|
71
71
|
align-items: center;
|
|
72
72
|
justify-content: center;
|
|
73
|
-
color: var(--fg-1, rgba(40,30,20,0.75));
|
|
73
|
+
color: var(--maude-chrome-fg-1, rgba(40,30,20,0.75));
|
|
74
74
|
cursor: pointer;
|
|
75
75
|
position: relative;
|
|
76
76
|
transition: background-color 80ms linear, color 80ms linear;
|
|
77
77
|
}
|
|
78
78
|
.dc-tool-palette button:hover {
|
|
79
79
|
background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
|
|
80
|
-
color: var(--fg-0, #1a1a1a);
|
|
80
|
+
color: var(--maude-chrome-fg-0, #1a1a1a);
|
|
81
81
|
}
|
|
82
82
|
.dc-tool-palette button:focus-visible {
|
|
83
83
|
outline: 2px solid var(--maude-hud-accent, #d63b1f);
|
|
@@ -117,7 +117,7 @@ const PALETTE_CSS = `
|
|
|
117
117
|
height: 6px;
|
|
118
118
|
background: var(--maude-hud-accent, #d63b1f);
|
|
119
119
|
border-radius: 1px;
|
|
120
|
-
box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
|
|
120
|
+
box-shadow: 0 0 0 1px var(--maude-chrome-bg-0, #ffffff);
|
|
121
121
|
opacity: 0;
|
|
122
122
|
transition: opacity 50ms linear 50ms;
|
|
123
123
|
pointer-events: none;
|
|
@@ -132,7 +132,7 @@ const PALETTE_CSS = `
|
|
|
132
132
|
min-width: 56px;
|
|
133
133
|
padding: 0 8px;
|
|
134
134
|
font-variant-numeric: tabular-nums;
|
|
135
|
-
font-family: var(--
|
|
135
|
+
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
136
136
|
font-size: 11px;
|
|
137
137
|
letter-spacing: 0.04em;
|
|
138
138
|
border-radius: 6px;
|
|
@@ -145,16 +145,16 @@ const PALETTE_CSS = `
|
|
|
145
145
|
position: absolute;
|
|
146
146
|
right: 4px;
|
|
147
147
|
bottom: 44px;
|
|
148
|
-
background: var(--
|
|
149
|
-
border: 1px solid var(--
|
|
148
|
+
background: var(--maude-chrome-bg-0, #ffffff);
|
|
149
|
+
border: 1px solid var(--maude-chrome-fg-0, #1c1917);
|
|
150
150
|
border-radius: 8px;
|
|
151
|
-
box-shadow: 0 6px 24px color-mix(in oklab,
|
|
151
|
+
box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
|
|
152
152
|
display: flex;
|
|
153
153
|
flex-direction: column;
|
|
154
154
|
padding: 4px;
|
|
155
155
|
min-width: 160px;
|
|
156
156
|
z-index: 7;
|
|
157
|
-
font-family: var(--
|
|
157
|
+
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
158
158
|
}
|
|
159
159
|
.dc-tp-popover button {
|
|
160
160
|
appearance: none;
|
|
@@ -171,7 +171,7 @@ const PALETTE_CSS = `
|
|
|
171
171
|
gap: 16px;
|
|
172
172
|
width: 100%;
|
|
173
173
|
}
|
|
174
|
-
.dc-tp-popover button:hover { background:
|
|
174
|
+
.dc-tp-popover button:hover { background: color-mix(in oklab, var(--maude-chrome-fg-0, #1c1917) 5%, transparent); }
|
|
175
175
|
.dc-tp-popover .dc-tp-kbd {
|
|
176
176
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
177
177
|
font-size: 11px;
|
|
@@ -189,7 +189,9 @@ function ensurePaletteStyles(): void {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
const NAV_TOOLS = ['move', 'hand', 'comment'] as const;
|
|
192
|
-
|
|
192
|
+
// Phase 21 — sticky clusters with the paper primitives (after ellipse); text
|
|
193
|
+
// sits at the constructive end before the destructive eraser, which stays last.
|
|
194
|
+
const DRAW_TOOLS = ['pen', 'rect', 'ellipse', 'sticky', 'arrow', 'text', 'eraser'] as const;
|
|
193
195
|
|
|
194
196
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
195
197
|
// Component
|
|
@@ -23,11 +23,11 @@ const HUD_CSS = `
|
|
|
23
23
|
z-index: 7;
|
|
24
24
|
pointer-events: none;
|
|
25
25
|
padding: 6px 10px;
|
|
26
|
-
background: var(--bg-2, rgba(20, 20, 20, 0.85));
|
|
27
|
-
color: var(--fg-1, rgba(255, 255, 255, 0.85));
|
|
28
|
-
border: 1px solid var(--border
|
|
26
|
+
background: var(--maude-chrome-bg-2, rgba(20, 20, 20, 0.85));
|
|
27
|
+
color: var(--maude-chrome-fg-1, rgba(255, 255, 255, 0.85));
|
|
28
|
+
border: 1px solid var(--maude-chrome-border, rgba(255, 255, 255, 0.08));
|
|
29
29
|
border-radius: 6px;
|
|
30
|
-
font: 11px var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
30
|
+
font: 11px var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
31
31
|
letter-spacing: 0.02em;
|
|
32
32
|
user-select: none;
|
|
33
33
|
opacity: 0;
|
|
@@ -203,14 +203,30 @@ interface StoreHost {
|
|
|
203
203
|
|
|
204
204
|
/**
|
|
205
205
|
* Prefer `window.top` so all canvas iframes (children of the dev-server
|
|
206
|
-
* shell) read + write the same Map. Falls back to `window`
|
|
207
|
-
* cross-origin —
|
|
208
|
-
*
|
|
206
|
+
* shell) read + write the same Map. Falls back to `window` when top is
|
|
207
|
+
* cross-origin — which IS the case under the T2 (9.1-A) segregated canvas
|
|
208
|
+
* origin: the canvas iframe and the shell are different origins, so any
|
|
209
|
+
* PROPERTY access on `window.top` throws SecurityError. Then `globalThis`
|
|
210
|
+
* (Node / Bun test runtime where window is absent).
|
|
211
|
+
*
|
|
212
|
+
* Note: merely reading `window.top` (the reference) never throws — only
|
|
213
|
+
* touching a property of a cross-origin window does. So the guard MUST poke a
|
|
214
|
+
* property inside the try, or the cross-origin throw escapes into React render
|
|
215
|
+
* (it did: the UndoStackProvider's useRef initializer crashed the whole canvas
|
|
216
|
+
* mount to 0 children). Under A1 each iframe falls back to its own `window`
|
|
217
|
+
* store — undo works per-session; cross-close/reopen sharing via window.top is
|
|
218
|
+
* intentionally dropped for the origin isolation (see DDR-0xx / phase-9.1).
|
|
209
219
|
*/
|
|
210
220
|
function getStoreHost(): StoreHost {
|
|
211
221
|
if (typeof window !== 'undefined') {
|
|
212
222
|
try {
|
|
213
|
-
|
|
223
|
+
const top = window.top;
|
|
224
|
+
if (top && top !== window) {
|
|
225
|
+
// Poke a property — throws if `top` is a cross-origin window.
|
|
226
|
+
void (top as unknown as StoreHost).__maude_undo_stacks;
|
|
227
|
+
return top as unknown as StoreHost;
|
|
228
|
+
}
|
|
229
|
+
return window as unknown as StoreHost;
|
|
214
230
|
} catch {
|
|
215
231
|
return window as unknown as StoreHost;
|
|
216
232
|
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type EllipseStroke,
|
|
23
23
|
type PenStroke,
|
|
24
24
|
type RectStroke,
|
|
25
|
+
type StickyStroke,
|
|
25
26
|
type Stroke,
|
|
26
27
|
type StrokesStoreValue,
|
|
27
28
|
strokeBBox,
|
|
@@ -58,9 +59,17 @@ function ensureResizeStyles(): void {
|
|
|
58
59
|
|
|
59
60
|
type Corner = 'nw' | 'ne' | 'sw' | 'se' | 'ep1' | 'ep2';
|
|
60
61
|
|
|
61
|
-
/** Stroke types that expose resize handles
|
|
62
|
-
function isResizable(
|
|
63
|
-
|
|
62
|
+
/** Stroke types that expose resize handles. Text inherits its anchor bbox. */
|
|
63
|
+
function isResizable(
|
|
64
|
+
s: Stroke
|
|
65
|
+
): s is RectStroke | EllipseStroke | ArrowStroke | PenStroke | StickyStroke {
|
|
66
|
+
return (
|
|
67
|
+
s.tool === 'rect' ||
|
|
68
|
+
s.tool === 'ellipse' ||
|
|
69
|
+
s.tool === 'arrow' ||
|
|
70
|
+
s.tool === 'pen' ||
|
|
71
|
+
s.tool === 'sticky'
|
|
72
|
+
);
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
/**
|
|
@@ -75,7 +84,9 @@ function resizeStroke(
|
|
|
75
84
|
wx: number,
|
|
76
85
|
wy: number
|
|
77
86
|
): Partial<Stroke> | null {
|
|
78
|
-
if (start.tool === 'rect') {
|
|
87
|
+
if (start.tool === 'rect' || start.tool === 'sticky') {
|
|
88
|
+
// Sticky resizes exactly like a rect — it shares x / y / w / h. Text
|
|
89
|
+
// re-wraps inside the foreignObject automatically (no special handling).
|
|
79
90
|
const bbox = { x: start.x, y: start.y, w: start.w, h: start.h };
|
|
80
91
|
const left = corner === 'nw' || corner === 'sw' ? wx : bbox.x;
|
|
81
92
|
const right = corner === 'ne' || corner === 'se' ? wx : bbox.x + bbox.w;
|
|
@@ -86,7 +97,7 @@ function resizeStroke(
|
|
|
86
97
|
y: Math.min(top, bottom),
|
|
87
98
|
w: Math.abs(right - left),
|
|
88
99
|
h: Math.abs(bottom - top),
|
|
89
|
-
} as Partial<RectStroke>;
|
|
100
|
+
} as Partial<RectStroke | StickyStroke>;
|
|
90
101
|
}
|
|
91
102
|
if (start.tool === 'ellipse') {
|
|
92
103
|
// Treat the four corners as the bbox of the ellipse. Drag any corner →
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
useState,
|
|
22
22
|
} from 'react';
|
|
23
23
|
|
|
24
|
+
import { TOOL_CURSORS } from './canvas-cursors.ts';
|
|
24
25
|
import type { Tool } from './input-router.tsx';
|
|
25
26
|
|
|
26
27
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -35,19 +36,21 @@ export interface ToolDescriptor {
|
|
|
35
36
|
cursor: string;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
// Phase 21 — every tool ships a custom 32×32 SVG cursor (canvas-cursors.ts)
|
|
40
|
+
// with a white outline halo so the glyph reads on any background. The native
|
|
41
|
+
// crosshair/text/cell were thin + tiny ("pen almost invisible"); these mirror
|
|
42
|
+
// the tool-palette icons. `move` keeps the system arrow on purpose.
|
|
38
43
|
export const DEFAULT_TOOLS: readonly ToolDescriptor[] = Object.freeze([
|
|
39
|
-
{ id: 'move', label: 'Move', shortcut: 'V', cursor:
|
|
40
|
-
{ id: 'hand', label: 'Hand', shortcut: 'H', cursor:
|
|
41
|
-
{ id: 'comment', label: 'Comment', shortcut: 'C', cursor:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
{ id: '
|
|
47
|
-
{ id: '
|
|
48
|
-
{ id: '
|
|
49
|
-
{ id: 'arrow', label: 'Arrow', shortcut: 'A', cursor: 'crosshair' },
|
|
50
|
-
{ id: 'eraser', label: 'Eraser', shortcut: 'E', cursor: 'cell' },
|
|
44
|
+
{ id: 'move', label: 'Move', shortcut: 'V', cursor: TOOL_CURSORS.move },
|
|
45
|
+
{ id: 'hand', label: 'Hand', shortcut: 'H', cursor: TOOL_CURSORS.hand },
|
|
46
|
+
{ id: 'comment', label: 'Comment', shortcut: 'C', cursor: TOOL_CURSORS.comment },
|
|
47
|
+
{ id: 'pen', label: 'Pen', shortcut: 'B', cursor: TOOL_CURSORS.pen },
|
|
48
|
+
{ id: 'rect', label: 'Rect', shortcut: 'R', cursor: TOOL_CURSORS.rect },
|
|
49
|
+
{ id: 'ellipse', label: 'Ellipse', shortcut: 'O', cursor: TOOL_CURSORS.ellipse },
|
|
50
|
+
{ id: 'sticky', label: 'Sticky', shortcut: 'N', cursor: TOOL_CURSORS.sticky },
|
|
51
|
+
{ id: 'arrow', label: 'Arrow', shortcut: 'A', cursor: TOOL_CURSORS.arrow },
|
|
52
|
+
{ id: 'text', label: 'Text', shortcut: 'T', cursor: TOOL_CURSORS.text },
|
|
53
|
+
{ id: 'eraser', label: 'Eraser', shortcut: 'E', cursor: TOOL_CURSORS.eraser },
|
|
51
54
|
]);
|
|
52
55
|
|
|
53
56
|
interface ToolContextValue {
|
|
@@ -25,6 +25,16 @@ export type WsData =
|
|
|
25
25
|
remote: string;
|
|
26
26
|
kind: 'collab';
|
|
27
27
|
slug: string;
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
// T2 (9.1-A) — HMR-only socket for the segregated canvas origin. Receives
|
|
31
|
+
// ONLY `canvas-hmr` broadcasts; never the privileged inspector feed
|
|
32
|
+
// (comments / ai-activity / git-lifecycle / sync:status / selection) and
|
|
33
|
+
// ignores all inbound messages. Hub-pushed canvas code on the canvas
|
|
34
|
+
// origin can open this, but it leaks nothing and mutates nothing.
|
|
35
|
+
id: string;
|
|
36
|
+
remote: string;
|
|
37
|
+
kind: 'canvas-hmr';
|
|
28
38
|
};
|
|
29
39
|
|
|
30
40
|
/**
|
|
@@ -77,6 +87,8 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
|
|
|
77
87
|
}
|
|
78
88
|
}
|
|
79
89
|
|
|
90
|
+
// Privileged inspector feed — comments, selection, ai-activity, git-lifecycle,
|
|
91
|
+
// sync:status, fs:*. ONLY the same-origin inspector clients (the shell) get it.
|
|
80
92
|
function broadcast(payload: unknown) {
|
|
81
93
|
const msg = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
82
94
|
for (const ws of clients) {
|
|
@@ -85,6 +97,17 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
|
|
|
85
97
|
}
|
|
86
98
|
}
|
|
87
99
|
|
|
100
|
+
// HMR feed — `canvas-hmr` reload signals. Safe to deliver to the segregated
|
|
101
|
+
// canvas origin, so both inspector (shell) AND canvas-hmr (canvas iframe)
|
|
102
|
+
// sockets receive it. T2 (9.1-A).
|
|
103
|
+
function broadcastHmr(payload: unknown) {
|
|
104
|
+
const msg = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
105
|
+
for (const ws of clients) {
|
|
106
|
+
if (ws.data.kind !== 'inspector' && ws.data.kind !== 'canvas-hmr') continue;
|
|
107
|
+
send(ws, msg);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
88
111
|
// Wire bus -> WS broadcasts. inspect.ts emits 'selected' / 'active' after every
|
|
89
112
|
// state write; fs-watch.ts emits 'fs:*' on every save.
|
|
90
113
|
ctx.bus.on('selected', (sel) => broadcast({ type: 'selected', selected: sel }));
|
|
@@ -106,9 +129,17 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
|
|
|
106
129
|
// canvas iframes both subscribe.
|
|
107
130
|
ctx.bus.on('git-lifecycle', (payload: unknown) => broadcast({ type: 'git-lifecycle', payload }));
|
|
108
131
|
|
|
132
|
+
// Phase 9 Task 8 — hub-down offline mode. The linked-mode sync runtime emits
|
|
133
|
+
// 'sync:status' on every connection-state change (online / connecting /
|
|
134
|
+
// offline / offline-long, queued-op count, conflict notices). Browser tabs
|
|
135
|
+
// render the offline/synced/escalation banner from this. Solo mode never
|
|
136
|
+
// emits, so this is a no-op for unlinked projects.
|
|
137
|
+
ctx.bus.on('sync:status', (payload: unknown) => broadcast({ type: 'sync:status', payload }));
|
|
138
|
+
|
|
109
139
|
// HMR broadcaster — turns fs:any change events into `canvas-hmr` messages.
|
|
110
140
|
// The iframe-side client (in _shell.html) decides reload strategy from `mode`.
|
|
111
|
-
|
|
141
|
+
// Uses broadcastHmr so the segregated canvas origin's HMR-only sockets get it.
|
|
142
|
+
createHmrBroadcaster(ctx, (msg) => broadcastHmr(msg));
|
|
112
143
|
|
|
113
144
|
// Bind a connection to its room. Stored per-socket so close() can find the
|
|
114
145
|
// right room to disconnect from. Multiplexed via ws.data.id.
|
|
@@ -138,6 +169,11 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
|
|
|
138
169
|
await room.connect(conn);
|
|
139
170
|
return;
|
|
140
171
|
}
|
|
172
|
+
if (ws.data.kind === 'canvas-hmr') {
|
|
173
|
+
// HMR-only: join the broadcast set but get NO inspector snapshot.
|
|
174
|
+
clients.add(ws);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
141
177
|
clients.add(ws);
|
|
142
178
|
send(ws, { type: 'snapshot', state: inspect.state });
|
|
143
179
|
},
|
|
@@ -159,6 +195,9 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Colla
|
|
|
159
195
|
clients.delete(ws);
|
|
160
196
|
},
|
|
161
197
|
async message(ws, raw) {
|
|
198
|
+
// HMR-only canvas-origin socket: never accepts inbound messages (the
|
|
199
|
+
// canvas iframe only listens for canvas-hmr; it never sends).
|
|
200
|
+
if (ws.data.kind === 'canvas-hmr') return;
|
|
162
201
|
if (ws.data.kind === 'collab') {
|
|
163
202
|
const binding = collabConns.get(ws.data.id);
|
|
164
203
|
if (!binding) return;
|
|
@@ -28,6 +28,26 @@
|
|
|
28
28
|
<meta charset="utf-8" />
|
|
29
29
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
30
30
|
<title>Canvas</title>
|
|
31
|
+
<script>
|
|
32
|
+
// T2 (9.1 / DDR-060 F1 re-audit) — best-effort WebRTC denial in the canvas
|
|
33
|
+
// iframe. CSP `webrtc 'block'` is specified but UNIMPLEMENTED in shipping
|
|
34
|
+
// Chrome/Firefox as of 2026 (Chromium #40188662, Firefox bug 1783489), so
|
|
35
|
+
// it is currently a no-op, and `connect-src 'self'` does NOT cover WebRTC
|
|
36
|
+
// (it is not Fetch). Hub-pushed canvas JS could otherwise exfil via
|
|
37
|
+
// ICE/STUN. Locking these to undefined raises the bar against the naive
|
|
38
|
+
// case before any canvas module loads. NOT bulletproof — a nested
|
|
39
|
+
// same-origin (about:blank) frame can re-acquire them — so WebRTC exfil
|
|
40
|
+
// stays a DOCUMENTED residual of opt-in linked mode (see the linked-mode
|
|
41
|
+
// README banner). `webrtc 'block'` stays in the CSP for when browsers
|
|
42
|
+
// enforce it. The canvas runtime itself uses zero WebRTC.
|
|
43
|
+
for (var __k of ['RTCPeerConnection', 'webkitRTCPeerConnection', 'RTCDataChannel']) {
|
|
44
|
+
try {
|
|
45
|
+
Object.defineProperty(window, __k, { value: undefined, configurable: false, writable: false });
|
|
46
|
+
} catch (_) {
|
|
47
|
+
/* already locked / not present */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
31
51
|
<style>
|
|
32
52
|
:root {
|
|
33
53
|
color-scheme: light dark;
|
|
@@ -150,13 +170,28 @@
|
|
|
150
170
|
const v = msg.version || Date.now();
|
|
151
171
|
// Match by exact filename when we have one; otherwise refresh all.
|
|
152
172
|
const targetFile = (msg.file || '').split('/').pop();
|
|
173
|
+
let matched = false;
|
|
153
174
|
for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
|
|
154
175
|
const href = link.getAttribute('href') || '';
|
|
155
176
|
if (!targetFile || href.includes(targetFile)) {
|
|
156
177
|
const base = href.split('?')[0];
|
|
157
178
|
link.setAttribute('href', base + '?v=' + v);
|
|
179
|
+
matched = true;
|
|
158
180
|
}
|
|
159
181
|
}
|
|
182
|
+
// A per-canvas `<slug>.css` imported by the TSX is INLINED into the
|
|
183
|
+
// canvas module as a <style> by Bun.build — there is no <link> to
|
|
184
|
+
// swap, so the loop above matches nothing and the edit is silently
|
|
185
|
+
// dropped until a manual hard-reload (RCA hmr-inlined-css-dropped).
|
|
186
|
+
// When nothing matched AND the changed CSS lives in THIS canvas's
|
|
187
|
+
// directory, reload so the rebuilt module re-inlines the fresh CSS.
|
|
188
|
+
// Scoped to same-dir so an unrelated specimen's CSS write doesn't
|
|
189
|
+
// reload every open tab.
|
|
190
|
+
if (!matched && msg.file && canvasRel) {
|
|
191
|
+
const cssDir = msg.file.replace(/[^/]+$/, '');
|
|
192
|
+
const canvasDir = canvasRel.replace(/[^/]+$/, '');
|
|
193
|
+
if (cssDir === canvasDir) location.reload();
|
|
194
|
+
}
|
|
160
195
|
} else if (msg.mode === 'module' || msg.mode === 'hard') {
|
|
161
196
|
// Only reload when the change touches *this* canvas (or `_lib`).
|
|
162
197
|
if (msg.mode === 'hard' || !msg.file || (canvasRel && msg.file === canvasRel)) {
|
|
@@ -253,11 +288,24 @@
|
|
|
253
288
|
'Canvas module at ' + canvasUrl + ' has no default export (or it is not a component).'
|
|
254
289
|
);
|
|
255
290
|
}
|
|
291
|
+
const rootEl = document.getElementById('canvas-root');
|
|
256
292
|
mountCanvas(Canvas, {
|
|
257
|
-
rootEl
|
|
293
|
+
rootEl,
|
|
258
294
|
file: designRel + '/' + canvasRel,
|
|
259
295
|
commentsEnabled,
|
|
260
296
|
});
|
|
297
|
+
// T2 (9.1-A) diagnostic — tell the parent the canvas mounted + how many
|
|
298
|
+
// root children it rendered. Lets the shell (and tooling) confirm a
|
|
299
|
+
// cross-origin canvas actually rendered, which a parent-page screenshot
|
|
300
|
+
// of an OOPIF cannot show. Harmless heartbeat; ignored if unhandled.
|
|
301
|
+
setTimeout(() => {
|
|
302
|
+
try {
|
|
303
|
+
window.parent.postMessage(
|
|
304
|
+
{ dgn: 'canvas-mounted', file: designRel + '/' + canvasRel, children: rootEl ? rootEl.children.length : 0 },
|
|
305
|
+
'*'
|
|
306
|
+
);
|
|
307
|
+
} catch (e) { /* parent gone */ }
|
|
308
|
+
}, 800);
|
|
261
309
|
} catch (err) {
|
|
262
310
|
showError((err && err.stack) || String(err));
|
|
263
311
|
}
|
|
@@ -29,6 +29,19 @@
|
|
|
29
29
|
* server (canvas-pipeline.ts pass 1). Don't author them by hand — they'd be
|
|
30
30
|
* stripped + replaced. `/design:handoff` strips them again before the registry
|
|
31
31
|
* sidecar leaves dev (production has no business with dev-time scaffolding).
|
|
32
|
+
*
|
|
33
|
+
* Theme model (two ISOLATED layers — system-review D9):
|
|
34
|
+
* • Canvas-shell CHROME (workspace plane, floating toolbar, minimap, zoom
|
|
35
|
+
* HUD, halos) auto-follows the Maude dev-server theme via `data-maude-theme`
|
|
36
|
+
* on the iframe <html> — the dev-server sets it; never theme the chrome
|
|
37
|
+
* from canvas code.
|
|
38
|
+
* • ARTBOARDS keep THEIR design system's theme. The `data-theme` on the
|
|
39
|
+
* `{{ROOT_CLASS}}` wrapper pins this artboard to a specific DS theme block;
|
|
40
|
+
* leave it at the DS default ({{THEME_DEFAULT}}). Do NOT hardcode a theme
|
|
41
|
+
* the DS doesn't intend — a reviewer flips an individual artboard at will
|
|
42
|
+
* via right-click → Theme ▸ Light / Dark / Follow chrome (enabled when the
|
|
43
|
+
* DS ships both light + dark token blocks). Only pin a non-default theme
|
|
44
|
+
* when the canvas is intentionally single-theme.
|
|
32
45
|
*/
|
|
33
46
|
|
|
34
47
|
import { useState } from "react";
|